diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | .phpcs.xml | 13 | ||||
-rw-r--r-- | Code.php | 234 | ||||
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | README.md | 39 | ||||
-rw-r--r-- | composer.json | 11 | ||||
-rw-r--r-- | examples/languages.php | 12 | ||||
-rw-r--r-- | examples/namespacesWithCodePages.php | 1 | ||||
-rw-r--r-- | extension.json | 43 | ||||
-rw-r--r-- | i18n/en.json | 21 |
10 files changed, 397 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..987e2a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +composer.lock +vendor diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 0000000..ed598e5 --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,13 @@ +<?xml version="1.0"?> +<ruleset> + <rule ref="./vendor/mediawiki/mediawiki-codesniffer/MediaWiki"> + <exclude name="Generic.Files.OneObjectStructurePerFile.MultipleFound"/> + <exclude name="MediaWiki.Commenting.FunctionComment.MissingDocumentationProtected"/> + <exclude name="MediaWiki.Commenting.FunctionComment.MissingDocumentationPublic"/> + <exclude name="MediaWiki.Files.ClassMatchesFilename.NotMatch"/> + </rule> + <file>.</file> + <exclude-pattern>/examples/*</exclude-pattern> + <arg name="extensions" value="php"/> + <arg name="encoding" value="UTF-8"/> +</ruleset> diff --git a/Code.php b/Code.php new file mode 100644 index 0000000..ca02b3c --- /dev/null +++ b/Code.php @@ -0,0 +1,234 @@ +<?php +namespace MediaWiki\Extension\Code; + +use Content; +use ErrorPageError; +use ExtensionRegistry; +use Html; +use MediaWiki\Content\Renderer\ContentParseParams; +use MediaWiki\MediaWikiServices; +use Parser; +use ParserOutput; +use Sanitizer; +use SpecialPage; +use TextContent; +use TextContentHandler; +use Title; +use WikiPage; +use Xml; + +class CodeHooks { + public static function onParserFirstCallInit( Parser $parser ) { + global $wgCode_languages; + foreach ( $wgCode_languages as $lang ) { + $parser->setHook( + $lang['tag'], + fn ( $input, $args ) => renderCode( $input, $lang, $parser->getOutput(), $args ) + ); + } + } + + public static function onContentHandlerDefaultModelFor( Title $title, &$model ) { + if ( getLangByPageName( $title->getDBkey() ) ) { + $model = CodeContent::MODEL; + return false; + } else { + return true; + } + } +} + +function getLangByPageName( string $dbKey ) { + global $wgCode_languages; + $suffix = '.' . pathinfo( $dbKey, PATHINFO_EXTENSION ); + return current( array_filter( $wgCode_languages, fn( $lang ) => $lang['suffix'] == $suffix ) ); +} + +function pre( $code, $attr ) { + return Xml::element( 'pre', Sanitizer::validateTagAttributes( $attr, 'pre' ), $code ); +} + +/** + * Render the given code and return the HTML. + * + * @param string $code The code to be rendered. + * @param ? $lang The language configuration. + * @param OutputPage|ParserOutput|null $outputPage + * @param ?array $tagAttrs Associative array of tag attributes or null in case of a code page. + * @return string The rendered HTML. + */ +function renderCode( string $code, $lang, $outputPage, ?array $tagAttrs = null ) { + if ( ExtensionRegistry::getInstance()->isLoaded( 'SyntaxHighlight' ) ) { + $result = \SyntaxHighlight::highlight( $code, $lang['pygmentsLexer'], $tagAttrs ?? [ 'line' => true ] ); + if ( $result->isOk() ) { + $out = $result->getValue(); + $outputPage->addModuleStyles( [ 'ext.pygments' ] ); + } else { + $out = pre( $code, $tagAttrs ); + } + } else { + $out = pre( $code, $tagAttrs ); + } + + foreach ( $lang['linkifiers'] as $regex => $href ) { + $out = preg_replace_callback( $regex, fn( $match ) => + Xml::element( 'a', [ + 'href' => str_replace( '$1', $match[0], $href ), + 'tabindex' => -1 + ], $match[0] ), + $out ); + } + + foreach ( $lang['actions'] as $action => $url ) { + if ( str_contains( $url, '$url' ) ) { + // FUTURE: it would be nice to still display such actions for code pages + continue; + } + + $encodedCode = rawurlencode( trim( $code ) ); + + // The wfMessage allows admins to create MediaWiki:codeaction-label with a template call + // if they want to enable their users to customize or localize the code action labels. + $out .= Xml::openElement( 'a', [ 'href' => str_replace( '$code', $encodedCode, $url ) ] ); + $out .= wfMessage( "codeaction-label", $action )->parse(); + $out .= Xml::closeElement( 'a' ) . ' '; + } + + return $out; +} + +class ContentHandler extends TextContentHandler { + public function __construct( $modelId = CodeContent::MODEL ) { + parent::__construct( $modelId, [ CONTENT_FORMAT_TEXT ] ); + } + + public function fillParserOutput( Content $content, ContentParseParams $params, ParserOutput &$output ) { + $lang = getLangByPageName( $params->getPage()->getDBkey() ); + $out = ''; + if ( !$lang ) { + global $wgCode_languages; + $supportedSuffixes = implode( ', ', array_map( fn( $lang ) => $lang['suffix'], $wgCode_languages ) ); + + MediaWikiServices::getInstance()->getTrackingCategories() + ->addTrackingCategory( $output, 'codepage-error-category', $params->getPage() ); + + $out = Html::errorBox( wfMessage( 'codepage-unknown-suffix', 'code', "[$supportedSuffixes]" ) ); + $lang = [ + 'pygmentsLexer' => 'text', + 'actions' => [], + 'linkifiers' => [], + ]; + } + $out .= renderCode( $content->getText(), $lang, $output ); + $output->setText( $out ); + } + + protected function getContentClass() { + return CodeContent::class; + } +} + +class CodeContent extends TextContent { + // Has to match the ContentHandlers entry in extension.json. + public const MODEL = 'code'; + + public function __construct( $text, $model_id = self::MODEL ) { + parent::__construct( $text, $model_id ); + } +} + +class SpecialCodeAction extends SpecialPage { + public function __construct() { + parent::__construct( 'CodeAction' ); + } + + public function execute( $par ) { + $namespacesWithCodePages = $this->getConfig()->get( 'Code_namespacesWithCodePages' ); + $languages = $this->getConfig()->get( 'Code_languages' ); + + if ( empty( $namespacesWithCodePages ) ) { + $exampleConfig = htmlspecialchars( file_get_contents( __DIR__ . '/examples/namespacesWithCodePages.php' ) ); + throw new ErrorPageError( 'codeactions-unavailable-title', 'codeactions-unavailable-no-namespaces', "<pre>$exampleConfig</pre>" ); + } + + $exampleLang = current( array_filter( $languages, fn( $lang ) => count( $lang['actions'] ) > 0 ) ); + if ( !$exampleLang ) { + $exampleConfig = htmlspecialchars( file_get_contents( __DIR__ . '/examples/languages.php' ) ); + throw new ErrorPageError( 'codeactions-unavailable-title', 'codeactions-unavailable-no-lang-with-actions', "<pre>$exampleConfig</pre>" ); + } + + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + + $output = $this->getOutput(); + $args = explode( '/', $par, 2 ); + + if ( count( $args ) != 2 ) { + $formatter = MediaWikiServices::getInstance()->getTitleFormatter(); + + $title = $formatter->formatTitle( array_keys( $namespacesWithCodePages )[0], 'Example' . $exampleLang['suffix'] ); + $action = htmlspecialchars( array_keys( $exampleLang['actions'] )[0] ); + $output->setPageTitle( $this->msg( 'notargettitle' ) ); + $output->addWikiMsg( 'codeaction-howto', $this->getPageTitle(), $action, $title ); + $output->addWikiTextAsInterface( + Xml::element( 'h2', null, $this->msg( 'codeactions-available' ) ) + ); + $output->addHTML( + Xml::buildTable( + array_map( fn ( $lang ) => [ $lang['suffix'], implode( ', ', array_keys( $lang['actions'] ) ) ], $languages ), + [ 'class' => 'wikitable' ], [ $this->msg( 'code-pagesuffix' ), $this->msg( 'codeactions-available' ) ] ) + ); + return; + } + [ $action, $pagename ] = $args; + + $title = Title::newFromText( $pagename ); + if ( !$title ) { + throw new ErrorPageError( 'badtitle', 'title-invalid' ); + } + + if ( !array_key_exists( $title->getNamespace(), $namespacesWithCodePages ) ) { + $nsName = $this->getContext()->getLanguage()->getFormattedNsText( $title->getNamespace() ); + throw new ErrorPageError( 'codeaction-unsupported-namespace-title', 'codeaction-unsupported-namespace-text', $nsName ); + } + + $lang = getLangByPageName( $title->getDBkey() ); + if ( !$lang ) { + $supportedSuffixes = implode( ', ', array_map( fn( $lang ) => $lang['suffix'], $languages ) ); + throw new ErrorPageError( 'codeaction-unknown-suffix-title', 'codeaction-unknown-suffix-text', [ $pagename, "[$supportedSuffixes]" ] ); + } + + $url = $lang['actions'][$action] ?? null; + + if ( !$url ) { + $supportedActions = implode( ', ', array_keys( $lang['actions'] ) ); + throw new ErrorPageError( 'codeaction-unknown-action-title', 'codeaction-unknown-action-text', [ $action, "[$supportedActions]" ] ); + } + + if ( str_contains( $url, '$code' ) ) { + $permissionManager = MediaWikiServices::getInstance()->getPermissionManager(); + + $errors = $permissionManager->getPermissionErrors( 'read', $this->getUser(), $title ); + if ( !empty( $errors ) ) { + $output->showPermissionsErrorPage( $errors, 'read' ); + return; + } + + $content = WikiPage::factory( $title )->getContent(); + if ( $content == null ) { + throw new ErrorPageError( 'codeaction-page-not-found-title', 'codeaction-page-not-found-text', $title ); + } + $content = ContentHandler::getContentText( $content ); + + $codePrefix = $lang['codePrefix'] ?? null; + if ( $codePrefix ) { + $content = str_replace( '$url', $title->getFullURL(), $codePrefix ) . "\n\n" . $content; + } + + $url = str_replace( '$code', rawurlencode( $content ), $url ); + } else { + $url = str_replace( '$url', rawurlencode( $title->getFullURL() ), $url ); + } + + $output->redirect( $url ); + } +} @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Martin Fischer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9370d1f --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Extension:Code + +A MediaWiki extension that builds on [SyntaxHighlight] +to provide the following (all in a configurable manner): + +* shorter tags + e.g. `<query>` instead of `<syntaxhighlight lang=sparql>` + +* code actions + e.g. automatically link the [WDQS] for SPARQL code blocks[^1] + +* code linkification + e.g. automatically link Wikidata identifiers in code blocks + +* code pages + e.g. automatically higlight pages with names ending in `.rq` + as SPARQL (and also display the code actions for them) + +Note that code actions are also linkable from other pages via the +`Special:CodeAction` special page, e.g. `Special:CodeAction/run/Example.rq` +attempts to execute the `run` action for the `Example.rq` code page +and redirect the user accordingly. + +## Installation + +1. Place the extension in your extensions directory. +2. Add `wfLoadExtension('Code');` to your `LocalSettings.php`. +3. Visit `Special:CodeAction` it will tell you what other configuration you need. + +(If you want syntax highlighting via [SyntaxHighlight], additionally add +`wfLoadExtension('SyntaxHighlight_GeSHi');` to your `LocalSettings.php`). + + +[^1]: While this can also be achieved just via MediaWiki templates, +this bears the problem that `|` has to be escaped as `{{!}}`, which can +be quite annoying for languages like SPARQL that use `|` as an operator. + +[SyntaxHighlight]: https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:SyntaxHighlight +[WDQS]: https://query.wikidata.org/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a2224fb --- /dev/null +++ b/composer.json @@ -0,0 +1,11 @@ +{ + "require-dev": { + "mediawiki/mediawiki-codesniffer": "40.0.1" + }, + "scripts": { + "test": [ + "phpcs -p -s" + ], + "fix": "phpcbf" + } +} diff --git a/examples/languages.php b/examples/languages.php new file mode 100644 index 0000000..3710f86 --- /dev/null +++ b/examples/languages.php @@ -0,0 +1,12 @@ +$wgCode_languages[] = [ + 'tag' => 'query', + 'pygmentsLexer' => 'sparql', + 'actions' => [ + 'run' => 'https://query.wikidata.org/#$code', + 'embed' => 'https://query.wikidata.org/embed.html#$code', + ], + 'linkifiers' => [ + '/\\b[QP][0-9]+\\b/' => 'https://www.wikidata.org/entity/$1', + ], + 'suffix' => '.rq', +]; diff --git a/examples/namespacesWithCodePages.php b/examples/namespacesWithCodePages.php new file mode 100644 index 0000000..b3eb4e1 --- /dev/null +++ b/examples/namespacesWithCodePages.php @@ -0,0 +1 @@ +$wgCode_namespacesWithCodePages[NS_MAIN] = true; diff --git a/extension.json b/extension.json new file mode 100644 index 0000000..cfa2c2b --- /dev/null +++ b/extension.json @@ -0,0 +1,43 @@ +{ + "name": "Code", + "manifest_version": 2, + "type": "other", + "license-name": "MIT", + "author": "[https://push-f.com/ Martin Fischer]", + "url": "https://www.mediawiki.org/wiki/Extension:Code", + "version": "0.1.0", + "description": "Provides code pages, code actions and code linkification.", + "config_prefix": "wgCode_", + "config": { + "namespacesWithCodePages": { + "value": {}, + "merge_strategy": "array_plus" + }, + "languages": { + "value": {} + } + }, + "MessagesDirs": { + "VoteExtension": [ + "i18n" + ] + }, + "AutoloadClasses": { + "MediaWiki\\Extension\\Code\\CodeHooks": "Code.php", + "MediaWiki\\Extension\\Code\\SpecialCodeAction": "Code.php", + "MediaWiki\\Extension\\Code\\ContentHandler": "Code.php" + }, + "ContentHandlers": { + "code": "MediaWiki\\Extension\\Code\\ContentHandler" + }, + "SpecialPages": { + "CodeAction": "MediaWiki\\Extension\\Code\\SpecialCodeAction" + }, + "Hooks": { + "ParserFirstCallInit": "MediaWiki\\Extension\\Code\\CodeHooks::onParserFirstCallInit", + "ContentHandlerDefaultModelFor": "MediaWiki\\Extension\\Code\\CodeHooks::onContentHandlerDefaultModelFor" + }, + "TrackingCategories": [ + "codepage-error-category" + ] +} diff --git a/i18n/en.json b/i18n/en.json new file mode 100644 index 0000000..2a4aae7 --- /dev/null +++ b/i18n/en.json @@ -0,0 +1,21 @@ +{ + "codeaction": "Code action", + "codeactions-unavailable-title": "Code actions are not available", + "codeactions-unavailable-no-namespaces": "The wiki administrators have not enabled code pages in any namespaces, for example by setting: $1", + "codeactions-unavailable-no-lang-with-actions": "The wiki administrators have not configured code actions for any code language, for example by setting: $1", + "codeaction-howto": "You have not specified a code action and page to perform this function on. For example <code>$1/$2/$3</code> would execute the <code>$2</code> action for the page <code>$3</code>.", + "codeactions-available": "Available code actions", + "code-pagesuffix": "Page suffix", + "codeaction-page-not-found-title": "Code page not found", + "codeaction-page-not-found-text": "Could not execute code action because [[$1]] does not exist but you may create it.", + "codeaction-label": "$1", + "codeaction-unsupported-namespace-title": "Unsupported namespace", + "codeaction-unsupported-namespace-text": "Code pages are not enabled for the $1 namespace.", + "codeaction-unknown-suffix-title": "Unsupported title suffix", + "codeaction-unknown-suffix-text": "Could not recognize a suffix in \"$1\". Expected one of $2.", + "codeaction-unknown-action-title": "Unsupported action", + "codeaction-unknown-action-text": "The action \"$1\" is not supported for this language. Expected one of $2.", + "codepage-unknown-suffix": "The \"$1\" content model could not recognize a language from the page title. Expected the title to end with one of the following suffixes: $2", + "codepage-error-category": "Code pages with unrecognized languages", + "codepage-error-category-desc": "The page has the \"code\" content model but the title could not be recognized (wasn't configured in <code>$wgCode_languages</code>)." +} |