From 7f7589999582e56a1dcf0e5af02ba12e6223a17f Mon Sep 17 00:00:00 2001 From: "H. C. Kruse" <6594492+octfx@users.noreply.github.com> Date: Wed, 12 Jul 2023 02:06:57 +0000 Subject: [PATCH] feat: add support for nested tabbers in Codex (#95) * refactor: Apply some code cleanup * feat: WIP dynamic nested tabber in codex * feat: Make deeply nested tabbers work * doc: fix comment position --------- Co-authored-by: alistair3149 --- composer.json | 3 +- includes/Hooks.php | 2 +- includes/Tabber.php | 95 ++++++++++++++----- includes/TabberParsoid.php | 44 ++++----- includes/TabberTransclude.php | 51 +++++----- modules/ext.tabberNeue.codex/App.vue | 5 + modules/ext.tabberNeue.codex/TabContent.vue | 49 +++++++++- .../ext.tabberNeue.codex.js | 18 +--- 8 files changed, 175 insertions(+), 92 deletions(-) diff --git a/composer.json b/composer.json index d800cc3..ebf7c56 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ }, "scripts": { "fix": [ - "minus-x fix ." + "minus-x fix .", + "phpcbf" ], "test": [ "parallel-lint . --exclude vendor --exclude node_modules", diff --git a/includes/Hooks.php b/includes/Hooks.php index 893320d..6ce73de 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -25,7 +25,7 @@ class Hooks implements ParserFirstCallInitHook { * * @param Parser $parser */ - public function onParserFirstCallInit( $parser ) { + public function onParserFirstCallInit( $parser ): void { $parser->setHook( 'tabber', Tabber::class . '::parserHook' ); $parser->setHook( 'tabbertransclude', TabberTransclude::class . '::parserHook' ); } diff --git a/includes/Tabber.php b/includes/Tabber.php index 8d4b8dd..4ab7028 100644 --- a/includes/Tabber.php +++ b/includes/Tabber.php @@ -14,40 +14,53 @@ declare( strict_types=1 ); namespace MediaWiki\Extension\TabberNeue; +use JsonException; use MediaWiki\MediaWikiServices; use Parser; use PPFrame; class Tabber { + /** + * Critical rendering styles + * See ext.tabberNeue.inline.less + * + * @var string + */ + public static $criticalInlineStyle = '.client-js .tabber__header{height:2.6em;box-shadow:inset 0 -1px 0 0;opacity:.1}.client-js .tabber__header:after{position:absolute;width:16ch;height:.5em;border-radius:40px;margin-top:1em;margin-left:.75em;background:#000;content:""}.client-js .tabber__noscript,.client-js .tabber__panel:not( :first-child ){display:none}'; + + /** + * Flag that checks if this is a nested tabber + * @var bool + */ + private static $isNested = false; + + private static $useCodex = false; + /** * Parser callback for tag * - * @param string $input + * @param string|null $input * @param array $args * @param Parser $parser Mediawiki Parser Object * @param PPFrame $frame Mediawiki PPFrame Object * * @return string HTML */ - public static function parserHook( string $input, array $args, Parser $parser, PPFrame $frame ) { - $tabber = new Tabber(); - $html = $tabber->render( $input, $parser, $frame ); + public static function parserHook( ?string $input, array $args, Parser $parser, PPFrame $frame ) { + self::$useCodex = MediaWikiServices::getInstance()->getMainConfig()->get( 'TabberNeueUseCodex' ); + + $html = self::render( $input, $parser, $frame ); if ( $input === null ) { - return; + return ''; } - $useCodex = MediaWikiServices::getInstance()->getMainConfig()->get( 'TabberNeueUseCodex' ); - - if ( $useCodex === true ) { + if ( self::$useCodex === true ) { $parser->getOutput()->addModules( [ 'ext.tabberNeue.codex' ] ); } else { - // Critial rendering styles + // Critical rendering styles // See ext.tabberNeue.inline.less - $style = sprintf( - '', - '.client-js .tabber__header{height:2.6em;box-shadow:inset 0 -1px 0 0;opacity:.1}.client-js .tabber__header:after{position:absolute;width:16ch;height:.5em;border-radius:40px;margin-top:1em;margin-left:.75em;background:#000;content:""}.client-js .tabber__noscript,.client-js .tabber__panel:not( :first-child ){display:none}' - ); + $style = sprintf( '', self::$criticalInlineStyle ); $parser->getOutput()->addHeadItem( $style, true ); $parser->getOutput()->addModules( [ 'ext.tabberNeue.legacy' ] ); } @@ -65,18 +78,28 @@ class Tabber { * * @return string HTML */ - public static function render( $input, Parser $parser, PPFrame $frame ) { - $arr = explode( "|-|", $input ); + public static function render( string $input, Parser $parser, PPFrame $frame ): string { + $arr = explode( '|-|', $input ); $htmlTabs = ''; foreach ( $arr as $tab ) { $htmlTabs .= self::buildTab( $tab, $parser, $frame ); } - $html = '
' . + if ( self::$useCodex && self::$isNested ) { + $tab = rtrim( implode( '},', explode( '}', $htmlTabs ) ), ',' ); + $tab = strip_tags( html_entity_decode( $tab ) ); + $tab = str_replace( ',,', ',', $tab ); + $tab = str_replace( ',]', ']', $tab ); + + return sprintf( '[%s]', $tab ); + } + $htmlTabs = preg_replace( '/\\\n/', '', $htmlTabs ); + $htmlTabs = preg_replace( '/\\\*/', '', $htmlTabs ); + $htmlTabs = str_replace( [ '"[', ']"' ], [ '[', ']' ], $htmlTabs ); + + return '
' . '
' . '
' . $htmlTabs . '
'; - - return $html; } /** @@ -87,27 +110,47 @@ class Tabber { * @param PPFrame $frame Mediawiki PPFrame Object * * @return string HTML + * @throws JsonException */ - private static function buildTab( $tab, Parser $parser, PPFrame $frame ) { + private static function buildTab( string $tab, Parser $parser, PPFrame $frame ): string { if ( empty( trim( $tab ) ) ) { return ''; } - // Use array_pad to make sure at least 2 array values are always returned - list( $tabName, $tabBody ) = array_pad( explode( '=', $tab, 2 ), 2, '' ); + [ $tabName, $tabBody ] = array_pad( explode( '=', $tab, 2 ), 2, '' ); // Use language converter to get variant title and also escape html $tabName = $parser->getTargetLanguageConverter()->convertHtml( trim( $tabName ) ); - $tabBody = $parser->recursiveTagParse( trim( $tabBody ), $frame ); + $tabBody = trim( $tabBody ); + + // A nested tabber which should return json in codex + if ( self::$useCodex && strpos( $tabBody, '{{#tag:tabber' ) !== false ) { + self::$isNested = true; + $tabBody = $parser->recursiveTagParse( $tabBody, $frame ); + self::$isNested = false; + // The outermost tabber that must be parsed fully in codex for correct json + } elseif ( self::$useCodex ) { + $tabBody = $parser->recursiveTagParseFully( $tabBody, $frame ); + // Normal mode + } else { + $tabBody = $parser->recursiveTagParse( $tabBody, $frame ); + } + + if ( self::$useCodex && self::$isNested ) { + return json_encode( [ + 'label' => $tabName, + 'content' => $tabBody + ], + JSON_THROW_ON_ERROR + ); + } // If $tabBody does not have any HTML element (i.e. just a text node), wrap it in

- if ( substr( $tabBody, 0, 1 ) !== '<' ) { + if ( $tabBody[0] !== '<' ) { $tabBody = '

' . $tabBody . '

'; } - $tab = '
' . $tabBody . '
'; - - return $tab; } } diff --git a/includes/TabberParsoid.php b/includes/TabberParsoid.php index e537312..5492ccc 100644 --- a/includes/TabberParsoid.php +++ b/includes/TabberParsoid.php @@ -35,47 +35,45 @@ class TabberParsoid extends ExtensionTagHandler implements ExtensionModule { /** @inheritDoc */ public function sourceToDom( ParsoidExtensionAPI $extApi, string $src, array $extArgs ) { $html = self::render( $extApi, $src ); - $extApi->addModules( [ 'ext.tabberNeue.codex' ] ); + $extApi->getMetadata()->addModules( [ 'ext.tabberNeue.codex' ] ); return $extApi->htmlToDom( $html ); } /** * Renders the necessary HTML for a tag. * - * @param PParsoidExtensionAPI $extApi + * @param ParsoidExtensionAPI $extApi * @param string $src The input URL between the beginning and ending tags. * * @return string HTML */ - public static function render( ParsoidExtensionAPI $extApi, string $src ) { - $arr = explode( "|-|", $src ); + public static function render( ParsoidExtensionAPI $extApi, string $src ): string { + $arr = explode( '|-|', $src ); $htmlTabs = ''; foreach ( $arr as $tab ) { $htmlTabs .= self::buildTab( $extApi, $tab ); } - $html = '
' . + return '
' . '
' . '
' . $htmlTabs . "
"; - - return $html; } /** * Build individual tab. * - * @param PParsoidExtensionAPI $extApi + * @param ParsoidExtensionAPI $extApi * @param string $tab Tab information * * @return string HTML */ - private static function buildTab( ParsoidExtensionAPI $extApi, string $tab ) { + private static function buildTab( ParsoidExtensionAPI $extApi, string $tab ): string { if ( empty( trim( $tab ) ) ) { return ''; } // Use array_pad to make sure at least 2 array values are always returned - list( $tabName, $tabBody ) = array_pad( explode( '=', $tab, 2 ), 2, '' ); + [ $tabName, $tabBody ] = array_pad( explode( '=', $tab, 2 ), 2, '' ); /* * Use language converter to get variant title and also escape html @@ -84,21 +82,19 @@ class TabberParsoid extends ExtensionTagHandler implements ExtensionModule { */ // $tabName = $parser->getTargetLanguageConverter()->convertHtml( trim( $tabName ) ); $tabBody = $extApi->domToHTML( - $extApi->wikitextToDOM( - $tabBody, - [ - 'parseOpts' => [ - 'extTag' => 'tabber', - 'context' => 'inline', - ] - ], - true // sol - ) - ); + $extApi->wikitextToDOM( + $tabBody, + [ + 'parseOpts' => [ + 'extTag' => 'tabber', + 'context' => 'inline', + ] + ], + true // sol + ) + ); - $tab = '
' . $tabBody . '
'; - - return $tab; } } diff --git a/includes/TabberTransclude.php b/includes/TabberTransclude.php index 64ada80..4629f54 100644 --- a/includes/TabberTransclude.php +++ b/includes/TabberTransclude.php @@ -14,7 +14,7 @@ declare( strict_types=1 ); namespace MediaWiki\Extension\TabberNeue; -use Hooks; +use Exception; use MediaWiki\MediaWikiServices; use Parser; use PPFrame; @@ -24,27 +24,23 @@ class TabberTransclude { /** * Parser callback for tag * - * @param string $input + * @param string|null $input * @param array $args * @param Parser $parser Mediawiki Parser Object * @param PPFrame $frame Mediawiki PPFrame Object * * @return string HTML */ - public static function parserHook( string $input, array $args, Parser $parser, PPFrame $frame ) { - $tabberTransclude = new TabberTransclude(); - $html = $tabberTransclude->render( $input, $parser, $frame ); + public static function parserHook( ?string $input, array $args, Parser $parser, PPFrame $frame ) { + $html = self::render( $input, $parser, $frame ); if ( $input === null ) { - return; + return ''; } - // Critial rendering styles + // Critical rendering styles // See ext.tabberNeue.inline.less - $style = sprintf( - '', - '.client-js .tabber__header{height:2.6em;box-shadow:inset 0 -1px 0 0;opacity:.1}.client-js .tabber__header:after{position:absolute;width:16ch;height:.5em;border-radius:40px;margin-top:1em;margin-left:.75em;background:#000;content:""}.client-js .tabber__noscript,.client-js .tabber__panel:not( :first-child ){display:none}' - ); + $style = sprintf( '', Tabber::$criticalInlineStyle ); $parser->getOutput()->addHeadItem( $style, true ); $parser->getOutput()->addModules( [ 'ext.tabberNeue.legacy' ] ); @@ -61,19 +57,22 @@ class TabberTransclude { * * @return string HTML */ - public static function render( $input, Parser $parser, PPFrame $frame ) { + public static function render( string $input, Parser $parser, PPFrame $frame ): string { $selected = true; $arr = explode( "\n", $input ); $htmlTabs = ''; foreach ( $arr as $tab ) { - $htmlTabs .= self::buildTabTransclude( $tab, $parser, $frame, $selected ); + try { + $htmlTabs .= self::buildTabTransclude( $tab, $parser, $frame, $selected ); + } catch ( Exception $e ) { + // This can happen if a $currentTitle is null + continue; + } } - $html = '
' . + return '
' . '
' . '
' . $htmlTabs . '
'; - - return $html; } /** @@ -85,23 +84,22 @@ class TabberTransclude { * @param bool &$selected The tab is the selected one * * @return string HTML + * @throws Exception */ - private static function buildTabTransclude( $tab, Parser $parser, PPFrame $frame, &$selected ) { + private static function buildTabTransclude( string $tab, Parser $parser, PPFrame $frame, bool &$selected ): string { if ( empty( trim( $tab ) ) ) { return ''; } - $tabBody = ''; $dataProps = []; // Use array_pad to make sure at least 2 array values are always returned - list( $pageName, $tabName ) = array_pad( explode( '|', $tab, 2 ), 2, '' ); + [ $pageName, $tabName ] = array_pad( explode( '|', $tab, 2 ), 2, '' ); $title = Title::newFromText( trim( $pageName ) ); if ( !$title ) { if ( empty( $tabName ) ) { $tabName = $pageName; } $tabBody = sprintf( '
Invalid title: %s
', $pageName ); - $pageName = ''; } else { $pageName = $title->getPrefixedText(); if ( empty( $tabName ) ) { @@ -127,11 +125,18 @@ class TabberTransclude { urlencode( $currentTitle->getPrefixedText() ), urlencode( $pageName ) ); - $dataProps['load-url'] = wfExpandUrl( wfScript( 'api' ) . $query, PROTO_CANONICAL ); + + $utils = MediaWikiServices::getInstance()->getUrlUtils(); + $utils->expand( wfScript( 'api' ) . $query, PROTO_CANONICAL ); + + $dataProps['load-url'] = $utils->expand( wfScript( 'api' ) . $query, PROTO_CANONICAL ); $oldTabBody = $tabBody; // Allow extensions to update the lazy loaded tab - Hooks::run( 'TabberNeueRenderLazyLoadedTab', [ &$tabBody, &$dataProps, $parser, $frame ] ); - if ( $oldTabBody != $tabBody ) { + MediaWikiServices::getInstance()->getHookContainer()->run( + 'TabberNeueRenderLazyLoadedTab', + [ &$tabBody, &$dataProps, $parser, $frame ] + ); + if ( $oldTabBody !== $tabBody ) { $parser->getOutput()->recordOption( 'tabberneuelazyupdated' ); } } diff --git a/modules/ext.tabberNeue.codex/App.vue b/modules/ext.tabberNeue.codex/App.vue index 9259eb2..b7856b8 100644 --- a/modules/ext.tabberNeue.codex/App.vue +++ b/modules/ext.tabberNeue.codex/App.vue @@ -45,6 +45,11 @@ module.exports = exports = defineComponent( { default: false } }, + methods: { + escapeId( id ) { + return mw.util.escapeIdForAttribute( id ) + } + }, data: function () { return { tabsData: this.tabberData.tabsData, diff --git a/modules/ext.tabberNeue.codex/TabContent.vue b/modules/ext.tabberNeue.codex/TabContent.vue index dc177d4..68bcafb 100644 --- a/modules/ext.tabberNeue.codex/TabContent.vue +++ b/modules/ext.tabberNeue.codex/TabContent.vue @@ -1,5 +1,19 @@