diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b6a390..2e00ba0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: mediawiki !mediawiki/extensions/ !mediawiki/vendor/ - key: mw_${{ matrix.mw }}-php${{ matrix.php }}-v1 + key: mw_${{ matrix.mw }}-php${{ matrix.php }}-v2 - name: Cache Composer cache uses: actions/cache@v4 @@ -66,6 +66,14 @@ jobs: - run: composer update + # Optional soft dependency, not part of the checkout, so clone it on every + # job. Removed first: a restored MediaWiki cache can already hold a copy, + # which would make the clone fail on a non-empty directory. + - name: Clone SyntaxHighlight + run: | + rm -rf extensions/SyntaxHighlight_GeSHi + git clone --depth 1 -b ${{ matrix.mw }} https://github.com/wikimedia/mediawiki-extensions-SyntaxHighlight_GeSHi extensions/SyntaxHighlight_GeSHi + - name: Run update.php run: php maintenance/update.php --quick @@ -146,7 +154,7 @@ jobs: mediawiki !mediawiki/extensions/ !mediawiki/vendor/ - key: mw_static_analysis-v1 + key: mw_static_analysis-v2 - name: Cache Composer cache uses: actions/cache@v4 @@ -173,6 +181,15 @@ jobs: - name: Composer update run: composer update --no-progress --no-interaction --prefer-dist --optimize-autoloader + # SyntaxHighlight is only a soft dependency, but the Persistence adapter + # references its class, so phpstan and psalm need its source to resolve it. + # Removed first: a restored MediaWiki cache can already hold a copy. + - name: Clone SyntaxHighlight + working-directory: mediawiki + run: | + rm -rf extensions/SyntaxHighlight_GeSHi + git clone --depth 1 -b ${{ matrix.mw }} https://github.com/wikimedia/mediawiki-extensions-SyntaxHighlight_GeSHi extensions/SyntaxHighlight_GeSHi + - name: PHPStan run: php vendor/bin/phpstan analyse --error-format=checkstyle --no-progress | cs2pr @@ -210,7 +227,7 @@ jobs: mediawiki !mediawiki/extensions/ !mediawiki/vendor/ - key: mw_static_analysis-v1 + key: mw_static_analysis-v2 - name: Cache Composer cache uses: actions/cache@v4 diff --git a/.github/workflows/installMediaWiki.sh b/.github/workflows/installMediaWiki.sh index a5a1f3e..95d044c 100755 --- a/.github/workflows/installMediaWiki.sh +++ b/.github/workflows/installMediaWiki.sh @@ -28,6 +28,10 @@ echo '$wgShowExceptionDetails = true;' >> LocalSettings.php echo '$wgShowDBErrorBacktrace = true;' >> LocalSettings.php echo '$wgDevelopmentWarnings = true;' >> LocalSettings.php +# Optional soft dependency: enables the integration tests that exercise the +# SyntaxHighlight adapter (they skip themselves when it is not loaded). +echo 'wfLoadExtension( "SyntaxHighlight_GeSHi" );' >> LocalSettings.php + echo 'wfLoadExtension( "'$EXTENSION_NAME'" );' >> LocalSettings.php cat <> composer.local.json diff --git a/README.md b/README.md index 1c5ef39..d0339ba 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,18 @@ For Markdown syntax highlighting in the editor, also install the [CodeEditor ext alt="The wiki edit form with Markdown syntax highlighting" /> +For syntax highlighting of fenced code blocks in the rendered page, install the +[SyntaxHighlight extension] (bundled with MediaWiki). When it is installed, a fenced block whose info string +names a language is highlighted server-side with Pygments, exactly like a wikitext `` block; +without it, code blocks render as plain preformatted text. + +````markdown +```python +def greet(name): + print(f"Hello {name}") +``` +```` + ## Configuration New pages use the Markdown content model where the wiki's configuration says so. Defaults apply to page @@ -246,6 +258,7 @@ Initial release for MediaWiki 1.43+ with these features: [Composer install]: https://professional.wiki/en/articles/installing-mediawiki-extensions-with-composer [LocalSettings.php]: https://www.mediawiki.org/wiki/Manual:LocalSettings.php [CodeEditor extension]: https://www.mediawiki.org/wiki/Extension:CodeEditor +[SyntaxHighlight extension]: https://www.mediawiki.org/wiki/Extension:SyntaxHighlight [Scribunto]: https://www.mediawiki.org/wiki/Extension:Scribunto [MediaWiki MCP Server]: https://github.com/ProfessionalWiki/MediaWiki-MCP-Server [Extension:WikiMarkdown]: https://www.mediawiki.org/wiki/Extension:WikiMarkdown diff --git a/extension.json b/extension.json index 190f6ca..74c03d5 100644 --- a/extension.json +++ b/extension.json @@ -26,6 +26,17 @@ ] }, + "ResourceFileModulePaths": { + "localBasePath": "resources", + "remoteExtPath": "NativeMarkdown/resources" + }, + + "ResourceModules": { + "ext.nativeMarkdown.styles": { + "styles": "ext.nativeMarkdown.styles.css" + } + }, + "AutoloadNamespaces": { "ProfessionalWiki\\NativeMarkdown\\": "src/", "ProfessionalWiki\\NativeMarkdown\\Tests\\": "tests/phpunit/" diff --git a/phpstan.neon b/phpstan.neon index 14c5380..17222a3 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,6 +7,10 @@ parameters: - ../../includes - ../../tests/phpunit - ../../vendor + # Optional Extension:SyntaxHighlight, referenced by the Persistence adapter + # but not part of the scanned MediaWiki core. Cloned in CI; present in the + # dev env. Scanned for symbols only, so its own code is not analysed. + - ../../extensions/SyntaxHighlight_GeSHi/includes bootstrapFiles: - ../../includes/AutoLoader.php treatPhpDocTypesAsCertain: false diff --git a/psalm.xml b/psalm.xml index 9da5ad4..4ae1951 100644 --- a/psalm.xml +++ b/psalm.xml @@ -17,6 +17,10 @@ + + diff --git a/resources/ext.nativeMarkdown.styles.css b/resources/ext.nativeMarkdown.styles.css new file mode 100644 index 0000000..3f3d6b0 --- /dev/null +++ b/resources/ext.nativeMarkdown.styles.css @@ -0,0 +1,14 @@ +/*! + * Base styles for markdown content. + * + * Skins style bare `code` for inline use, giving it a background, a border and + * padding. Since `code` is an inline element, that background paints once per + * line box, so a code block ends up with a pill behind each of its lines inside + * the box `pre` already draws. Wikitext never nests the two elements, so no skin + * resets this; CommonMark renders every code block as `pre > code`. + */ +.mw-parser-output pre > code { + background-color: transparent; + border: 0; + padding: 0; +} diff --git a/src/Application/CodeHighlighter.php b/src/Application/CodeHighlighter.php new file mode 100644 index 0000000..655cf9b --- /dev/null +++ b/src/Application/CodeHighlighter.php @@ -0,0 +1,40 @@ +` rendering, so highlighting is always + * an enhancement over, never a replacement for, plain code blocks. + */ +interface CodeHighlighter { + + /** + * @param string $code The raw code, exactly as written between the fences. + * @param string $language The first word of the fence's info string. + * @return string|null Ready-to-embed HTML for the highlighted block, or null + * when the code cannot be highlighted (unknown language, highlighter + * unavailable or failing, size limit exceeded, empty code). + */ + public function highlight( string $code, string $language ): ?string; + + /** + * ResourceLoader modules the highlighted HTML needs to be interactive. + * + * @return string[] + */ + public function modules(): array; + + /** + * ResourceLoader style modules the highlighted HTML needs to look right. + * + * @return string[] + */ + public function styleModules(): array; + +} diff --git a/src/Application/CommonMark/HighlightedCodeRenderer.php b/src/Application/CommonMark/HighlightedCodeRenderer.php new file mode 100644 index 0000000..df9238b --- /dev/null +++ b/src/Application/CommonMark/HighlightedCodeRenderer.php @@ -0,0 +1,33 @@ +` block. Stateless: the highlighting decision is made in + * the pipeline and only its result is read back here at render time. + */ +final class HighlightedCodeRenderer implements NodeRendererInterface { + + /** + * Data-bag key under which MarkdownRenderer stashes the highlighted HTML of a + * fenced code block, read back here at render time. + */ + public const HIGHLIGHTED_HTML_KEY = 'native_markdown_highlighted_html'; + + public function render( Node $node, ChildNodeRendererInterface $childRenderer ): ?string { + /** @var string|null $highlightedHtml */ + $highlightedHtml = $node->data->get( self::HIGHLIGHTED_HTML_KEY, null ); + + return $highlightedHtml; + } + +} diff --git a/src/Application/MarkdownRenderer.php b/src/Application/MarkdownRenderer.php index 311c845..e0c7a1b 100644 --- a/src/Application/MarkdownRenderer.php +++ b/src/Application/MarkdownRenderer.php @@ -8,6 +8,7 @@ use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\Extension\DefaultAttributes\DefaultAttributesExtension; +use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; use League\CommonMark\Extension\CommonMark\Node\Block\Heading; use League\CommonMark\Extension\CommonMark\Node\Inline\Image; use League\CommonMark\Extension\CommonMark\Node\Inline\Link; @@ -29,6 +30,7 @@ use League\CommonMark\Util\HtmlFilter; use ProfessionalWiki\NativeMarkdown\Application\CommonMark\FileEmbedNode; use ProfessionalWiki\NativeMarkdown\Application\CommonMark\FileEmbedNodeRenderer; +use ProfessionalWiki\NativeMarkdown\Application\CommonMark\HighlightedCodeRenderer; use ProfessionalWiki\NativeMarkdown\Application\CommonMark\ImageLinkRenderer; use ProfessionalWiki\NativeMarkdown\Application\CommonMark\MarkdownLinkRenderer; use ProfessionalWiki\NativeMarkdown\Application\CommonMark\SpacedLinkParser; @@ -68,6 +70,16 @@ final class MarkdownRenderer { */ private const MAX_TEMPLATE_EXPANSIONS_PER_RENDER = 100; + /** + * Upper bound on syntax-highlight attempts per page render. Each attempt can + * shell out to an external highlighter (pygmentize), and unlike wikitext's + * `` there is no wikitext-parser expensive-function budget + * bounding it in the ContentHandler context, so the count is capped here. 100 + * is generous for real pages while turning the worst case into a fixed number + * of invocations; blocks past the cap render with the default escaped renderer. + */ + private const MAX_HIGHLIGHTED_BLOCKS_PER_RENDER = 100; + private MarkdownParser $parser; private HtmlRenderer $htmlRenderer; private FrontMatterParser $frontMatterParser; @@ -83,6 +95,7 @@ public function __construct( private readonly WikiTitleParser $titleParser, private readonly PageLinkRenderer $pageLinkRenderer, private readonly FileEmbedRenderer $fileEmbedRenderer, + private readonly CodeHighlighter $codeHighlighter, bool $allowExternalImages, int $maxNestingLevel, private readonly ?string $tocPlaceholderHtml, @@ -136,6 +149,10 @@ private function newEnvironment( $environment->addRenderer( WikiLinkNode::class, new WikiLinkNodeRenderer( $this->pageLinkRenderer ) ); $environment->addRenderer( FileEmbedNode::class, new FileEmbedNodeRenderer( $this->fileEmbedRenderer ) ); $environment->addRenderer( TocPlaceholder::class, new TocPlaceholderRenderer() ); + // Runs above league's default FencedCodeRenderer, but is a no-op unless the + // pipeline stashed highlighted HTML on the node, in which case it returns + // that; otherwise it returns null and the default renderer takes over. + $environment->addRenderer( FencedCode::class, new HighlightedCodeRenderer(), self::RENDERER_OVERRIDE_PRIORITY ); $environment->addRenderer( Link::class, new MarkdownLinkRenderer( $this->pageLinkRenderer, $this->externalUrlDetector, $noFollowExternalLinks ), @@ -206,6 +223,10 @@ private function renderDocument( $this->preloadFileLookups( $files, $generateHtml ); $sections = $this->processHeadings( $document ); + // Only while rendering HTML: a metadata-only parse needs no highlighting, + // and skipping it avoids the highlighter's cost when the HTML is discarded. + $highlighted = $generateHtml && $this->highlightCodeBlocks( $document ); + return new RenderedMarkdown( html: $generateHtml ? $this->renderHtml( $document, $sections ) : '', links: $links, @@ -213,10 +234,50 @@ private function renderDocument( files: $files, sections: $sections, externalLinks: $this->collectExternalLinks( $document ), - frontMatter: $frontMatter + frontMatter: $frontMatter, + modules: $highlighted ? $this->codeHighlighter->modules() : [], + styleModules: $highlighted ? $this->codeHighlighter->styleModules() : [] ); } + /** + * Delegates each fenced block that has an info string to the code highlighter, + * up to MAX_HIGHLIGHTED_BLOCKS_PER_RENDER attempts, and stashes each non-null + * result on its node for HighlightedCodeRenderer to emit. Blocks with no info + * string, blocks the highlighter declines, and every block past the cap keep + * the default escaped rendering. + * + * @return bool Whether at least one block was highlighted, so the caller + * registers the highlighter's ResourceLoader modules only when they matter. + */ + private function highlightCodeBlocks( Document $document ): bool { + $attemptCount = 0; + $anyHighlighted = false; + + foreach ( $this->nodesOfType( $document, FencedCode::class ) as $node ) { + $language = $node->getInfoWords()[0] ?? ''; + + if ( $language === '' ) { + continue; + } + + if ( $attemptCount >= self::MAX_HIGHLIGHTED_BLOCKS_PER_RENDER ) { + break; + } + + $attemptCount++; + + $html = $this->codeHighlighter->highlight( $node->getLiteral(), $language ); + + if ( $html !== null ) { + $node->data->set( HighlightedCodeRenderer::HIGHLIGHTED_HTML_KEY, $html ); + $anyHighlighted = true; + } + } + + return $anyHighlighted; + } + /** * Replaces the raw wikitext of each template call with the expander's HTML, * up to MAX_TEMPLATE_EXPANSIONS_PER_RENDER calls. Unbalanced block calls, and @@ -334,7 +395,9 @@ private function escapedSourceFallback( string $markdown, bool $generateHtml ): files: [], sections: [], externalLinks: [], - frontMatter: null + frontMatter: null, + modules: [], + styleModules: [] ); } diff --git a/src/Application/NoOpCodeHighlighter.php b/src/Application/NoOpCodeHighlighter.php new file mode 100644 index 0000000..713fa3a --- /dev/null +++ b/src/Application/NoOpCodeHighlighter.php @@ -0,0 +1,25 @@ +|null $frontMatter + * @param string[] $modules ResourceLoader modules the rendered HTML needs + * @param string[] $styleModules ResourceLoader style modules the rendered HTML needs */ public function __construct( public readonly string $html, @@ -25,7 +27,9 @@ public function __construct( public readonly array $files, public readonly array $sections, public readonly array $externalLinks, - public readonly ?array $frontMatter + public readonly ?array $frontMatter, + public readonly array $modules, + public readonly array $styleModules ) { } diff --git a/src/EntryPoints/MarkdownContentHandler.php b/src/EntryPoints/MarkdownContentHandler.php index 514eb23..de8510b 100644 --- a/src/EntryPoints/MarkdownContentHandler.php +++ b/src/EntryPoints/MarkdownContentHandler.php @@ -27,6 +27,8 @@ final class MarkdownContentHandler extends TextContentHandler { private const FRONT_MATTER_DATA_KEY = 'nativemarkdown-front-matter'; + private const CONTENT_STYLES_MODULE = 'ext.nativeMarkdown.styles'; + // Matches the threshold used by the wikitext parser ("enough" headings for a ToC). private const TOC_SECTION_THRESHOLD = 4; @@ -142,6 +144,7 @@ protected function fillParserOutput( Content $content, ContentParseParams $cpoPa $this->registerFiles( $output, $rendered ); $this->registerExternalLinks( $output, $rendered ); $this->registerFrontMatter( $output, $rendered ); + $this->registerModules( $output, $rendered ); if ( $templateExpander !== null ) { $templateExpander->mergeInto( $output ); @@ -212,6 +215,16 @@ private function registerFrontMatter( ParserOutput $output, RenderedMarkdown $re } } + /** + * Loads the base styles every markdown page needs, since skins do not style + * CommonMark's markup correctly on their own, plus the modules the render + * itself needs, such as the highlighter's for highlighted code blocks. + */ + private function registerModules( ParserOutput $output, RenderedMarkdown $rendered ): void { + $output->addModuleStyles( array_merge( [ self::CONTENT_STYLES_MODULE ], $rendered->styleModules ) ); + $output->addModules( $rendered->modules ); + } + /** * Sets the table of contents from the page's own markdown headings, * authoritatively: it overwrites any TOCData and SHOW_TOC flag a transcluded diff --git a/src/NativeMarkdownExtension.php b/src/NativeMarkdownExtension.php index 4fcc9f9..ae3248f 100644 --- a/src/NativeMarkdownExtension.php +++ b/src/NativeMarkdownExtension.php @@ -8,13 +8,17 @@ use MediaWiki\Page\PageReference; use MediaWiki\Parser\Parser; use MediaWiki\Parser\ParserOptions; +use MediaWiki\Registration\ExtensionRegistry; +use ProfessionalWiki\NativeMarkdown\Application\CodeHighlighter; use ProfessionalWiki\NativeMarkdown\Application\MarkdownDefaultPolicy; use ProfessionalWiki\NativeMarkdown\Application\MarkdownRenderer; +use ProfessionalWiki\NativeMarkdown\Application\NoOpCodeHighlighter; use ProfessionalWiki\NativeMarkdown\Application\RedirectSyntax; use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiFileEmbedRenderer; use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiPageLinkRenderer; use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiTemplateExpander; use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiTitleParser; +use ProfessionalWiki\NativeMarkdown\Persistence\SyntaxHighlightCodeHighlighter; /** * Composition root. All object graph wiring happens here; MediaWikiServices @@ -69,6 +73,7 @@ private function newMarkdownRenderer(): MarkdownRenderer { $services->getLinkRenderer(), $this->defaultThumbnailWidth( $services ) ), + codeHighlighter: $this->newCodeHighlighter(), allowExternalImages: (bool)$services->getMainConfig()->get( 'NativeMarkdownAllowExternalImages' ), maxNestingLevel: self::MAX_NESTING_LEVEL, tocPlaceholderHtml: Parser::TOC_PLACEHOLDER, @@ -78,6 +83,19 @@ private function newMarkdownRenderer(): MarkdownRenderer { ); } + /** + * Delegates fenced-code highlighting to Extension:SyntaxHighlight when it is + * installed, and to a no-op otherwise, so highlighting is a silent + * enhancement rather than a hard dependency. + */ + private function newCodeHighlighter(): CodeHighlighter { + if ( ExtensionRegistry::getInstance()->isLoaded( 'SyntaxHighlight' ) ) { + return new SyntaxHighlightCodeHighlighter(); + } + + return new NoOpCodeHighlighter(); + } + /** * The width a bare `thumb` embed renders at, mirroring how the wikitext * parser sizes a thumbnail: the default thumbnail preference indexed into diff --git a/src/Persistence/SyntaxHighlightCodeHighlighter.php b/src/Persistence/SyntaxHighlightCodeHighlighter.php new file mode 100644 index 0000000..710abe1 --- /dev/null +++ b/src/Persistence/SyntaxHighlightCodeHighlighter.php @@ -0,0 +1,59 @@ +` uses, so a code block looks the + * same whether it is written in Markdown or wikitext. + */ +final class SyntaxHighlightCodeHighlighter implements CodeHighlighter { + + public function highlight( string $code, string $language ): ?string { + // Trim the same way SyntaxHighlight's own tag hook does, for parity with a + // wikitext code block. No Parser is passed: there is none in ContentHandler + // context, and MarkdownRenderer's per-render cap replaces the wikitext + // parser's expensive-function budget. + $trimmedCode = rtrim( trim( $code, "\n" ) ); + + // Empty code has nothing to highlight; SyntaxHighlight would still wrap it + // in an empty highlight div, so short-circuit to the default rendering. + if ( $trimmedCode === '' ) { + return null; + } + + try { + $status = SyntaxHighlight::highlight( $trimmedCode, $language ); + } catch ( \Exception ) { + // unwrap() throws a RuntimeException on unexpected Pygments output. + return null; + } + + // A non-good status covers an unknown language, an exceeded size limit and + // a Pygments invocation failure: all should degrade to default rendering. + if ( !$status->isGood() ) { + return null; + } + + // highlight()'s contract makes the caller responsible for loading + // ext.pygments; modules() and styleModules() below provide that. + /** @psalm-suppress MixedAssignment Status::getValue() is untyped; guarded by is_string(). */ + $html = $status->getValue(); + + return is_string( $html ) ? $html : null; + } + + public function modules(): array { + return [ 'ext.pygments.view' ]; + } + + public function styleModules(): array { + return [ 'ext.pygments' ]; + } + +} diff --git a/tests/phpunit/Application/FixtureRenderingTest.php b/tests/phpunit/Application/FixtureRenderingTest.php index ebd11f4..381161d 100644 --- a/tests/phpunit/Application/FixtureRenderingTest.php +++ b/tests/phpunit/Application/FixtureRenderingTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use ProfessionalWiki\NativeMarkdown\Application\MarkdownRenderer; +use ProfessionalWiki\NativeMarkdown\Application\NoOpCodeHighlighter; use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeFileEmbedRenderer; use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakePageLinkRenderer; use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeWikiTitleParser; @@ -45,6 +46,7 @@ private function render( string $markdown ): string { titleParser: new FakeWikiTitleParser(), pageLinkRenderer: new FakePageLinkRenderer(), fileEmbedRenderer: new FakeFileEmbedRenderer(), + codeHighlighter: new NoOpCodeHighlighter(), allowExternalImages: false, maxNestingLevel: 100, tocPlaceholderHtml: null, diff --git a/tests/phpunit/Application/MarkdownRendererTest.php b/tests/phpunit/Application/MarkdownRendererTest.php index 4bdd585..93fec75 100644 --- a/tests/phpunit/Application/MarkdownRendererTest.php +++ b/tests/phpunit/Application/MarkdownRendererTest.php @@ -5,12 +5,15 @@ namespace ProfessionalWiki\NativeMarkdown\Tests\Application; use PHPUnit\Framework\TestCase; +use ProfessionalWiki\NativeMarkdown\Application\CodeHighlighter; use ProfessionalWiki\NativeMarkdown\Application\MarkdownRenderer; +use ProfessionalWiki\NativeMarkdown\Application\NoOpCodeHighlighter; use ProfessionalWiki\NativeMarkdown\Application\PageLinkRenderer; use ProfessionalWiki\NativeMarkdown\Application\RenderedMarkdown; use ProfessionalWiki\NativeMarkdown\Application\Section; use ProfessionalWiki\NativeMarkdown\Application\TemplateExpander; use ProfessionalWiki\NativeMarkdown\Tests\FrontMatterBombs; +use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeCodeHighlighter; use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeFileEmbedRenderer; use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakePageLinkRenderer; use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeTemplateExpander; @@ -22,6 +25,8 @@ /** * @covers \ProfessionalWiki\NativeMarkdown\Application\MarkdownRenderer * @covers \ProfessionalWiki\NativeMarkdown\Application\RenderedMarkdown + * @covers \ProfessionalWiki\NativeMarkdown\Application\NoOpCodeHighlighter + * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\HighlightedCodeRenderer * @covers \ProfessionalWiki\NativeMarkdown\Application\FileEmbed * @covers \ProfessionalWiki\NativeMarkdown\Application\HeadingAnchorBuilder * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\WikiLinkParser @@ -51,12 +56,14 @@ private function newRenderer( bool $allowExternalImages = false, ?PageLinkRenderer $pageLinkRenderer = null, ?string $tocPlaceholderHtml = null, - bool $templateTransclusion = false + bool $templateTransclusion = false, + ?CodeHighlighter $codeHighlighter = null ): MarkdownRenderer { return new MarkdownRenderer( titleParser: new FakeWikiTitleParser(), pageLinkRenderer: $pageLinkRenderer ?? new FakePageLinkRenderer(), fileEmbedRenderer: new FakeFileEmbedRenderer(), + codeHighlighter: $codeHighlighter ?? new NoOpCodeHighlighter(), allowExternalImages: $allowExternalImages, maxNestingLevel: 100, tocPlaceholderHtml: $tocPlaceholderHtml, @@ -73,10 +80,16 @@ private function render( bool $generateHtml = true, ?string $tocPlaceholderHtml = null, bool $templateTransclusion = false, - ?TemplateExpander $templateExpander = null + ?TemplateExpander $templateExpander = null, + ?CodeHighlighter $codeHighlighter = null ): RenderedMarkdown { - return $this->newRenderer( $allowExternalImages, $pageLinkRenderer, $tocPlaceholderHtml, $templateTransclusion ) - ->render( $markdown, $generateHtml, $templateExpander ); + return $this->newRenderer( + $allowExternalImages, + $pageLinkRenderer, + $tocPlaceholderHtml, + $templateTransclusion, + $codeHighlighter + )->render( $markdown, $generateHtml, $templateExpander ); } private function extractPlainText( string $markdown ): string { @@ -649,6 +662,7 @@ public function testFileLookupsArePreloadedInOneBatchBeforeRendering(): void { titleParser: new FakeWikiTitleParser(), pageLinkRenderer: new FakePageLinkRenderer(), fileEmbedRenderer: $fileEmbedRenderer, + codeHighlighter: new NoOpCodeHighlighter(), allowExternalImages: false, maxNestingLevel: 100, tocPlaceholderHtml: null, @@ -1285,4 +1299,128 @@ public function testCapBoundaryExpandsTheHundredthCallButLeavesTheHundredFirstLi $this->assertStringContainsString( '

{{T101}}

', $html ); } + public function testFencedCodeWithLanguageIsRenderedByTheHighlighter(): void { + $html = $this->render( + "```python\nprint('hi')\n```", + codeHighlighter: new FakeCodeHighlighter() + )->html; + + $this->assertStringContainsString( '
HIGHLIGHTED
', $html ); + $this->assertStringNotContainsString( 'html; + + $this->assertStringContainsString( '
', $html );
+	}
+
+	public function testFencedCodeWithoutInfoStringIsNotHighlighted(): void {
+		$highlighter = new FakeCodeHighlighter();
+
+		$this->render( "```\nplain code\n```", codeHighlighter: $highlighter );
+
+		$this->assertSame( [], $highlighter->calls );
+	}
+
+	public function testIndentedCodeBlockIsNotHighlighted(): void {
+		$highlighter = new FakeCodeHighlighter();
+
+		$this->render( "    indented code line\n", codeHighlighter: $highlighter );
+
+		$this->assertSame( [], $highlighter->calls );
+	}
+
+	public function testFirstInfoWordIsPassedAsTheLanguage(): void {
+		$highlighter = new FakeCodeHighlighter();
+
+		$this->render( "```python line-numbers\ncode\n```", codeHighlighter: $highlighter );
+
+		$this->assertSame( 'python', $highlighter->calls[0]['language'] );
+	}
+
+	public function testFencedCodeLiteralIsPassedToTheHighlighter(): void {
+		$highlighter = new FakeCodeHighlighter();
+
+		$this->render( "```js\nconst x = 1;\n```", codeHighlighter: $highlighter );
+
+		$this->assertSame( "const x = 1;\n", $highlighter->calls[0]['code'] );
+	}
+
+	public function testMetadataOnlyRenderDoesNotHighlightCode(): void {
+		$highlighter = new FakeCodeHighlighter();
+
+		$this->render( "```python\nprint('hi')\n```", generateHtml: false, codeHighlighter: $highlighter );
+
+		$this->assertSame( [], $highlighter->calls );
+	}
+
+	public function testHighlighterModulesAreReportedWhenABlockIsHighlighted(): void {
+		$result = $this->render(
+			"```python\nprint('hi')\n```",
+			codeHighlighter: new FakeCodeHighlighter(
+				modules: [ 'ext.demo.view' ],
+				styleModules: [ 'ext.demo' ]
+			)
+		);
+
+		$this->assertSame( [ 'ext.demo.view' ], $result->modules );
+		$this->assertSame( [ 'ext.demo' ], $result->styleModules );
+	}
+
+	public function testNoModulesWhenDocumentHasNoHighlightableCode(): void {
+		$result = $this->render(
+			'Just a paragraph with no code.',
+			codeHighlighter: new FakeCodeHighlighter()
+		);
+
+		$this->assertSame( [], $result->modules );
+		$this->assertSame( [], $result->styleModules );
+	}
+
+	public function testNoModulesWhenEveryBlockDeclinesHighlighting(): void {
+		$result = $this->render(
+			"```python\nprint('hi')\n```",
+			codeHighlighter: new FakeCodeHighlighter( html: null )
+		);
+
+		$this->assertSame( [], $result->modules );
+		$this->assertSame( [], $result->styleModules );
+	}
+
+	/**
+	 * A stack of distinct fenced blocks ```lang1 .. ```lang$count, each with a
+	 * one-word language so every block is a highlight candidate.
+	 */
+	private function fencedCodeBlocks( int $count ): string {
+		return implode(
+			"\n\n",
+			array_map(
+				static fn ( int $number ) => "```lang$number\ncode $number\n```",
+				range( 1, $count )
+			)
+		);
+	}
+
+	public function testHighlightAttemptsAreCappedPerRender(): void {
+		$highlighter = new FakeCodeHighlighter();
+
+		$this->render( $this->fencedCodeBlocks( 101 ), codeHighlighter: $highlighter );
+
+		$this->assertCount( 100, $highlighter->calls );
+	}
+
+	public function testFencedBlockPastTheHighlightCapKeepsDefaultRendering(): void {
+		$html = $this->render(
+			$this->fencedCodeBlocks( 101 ),
+			codeHighlighter: new FakeCodeHighlighter()
+		)->html;
+
+		$this->assertStringNotContainsString( 'class="language-lang100"', $html );
+		$this->assertStringContainsString( 'class="language-lang101"', $html );
+	}
+
 }
diff --git a/tests/phpunit/Application/XssSafetyTest.php b/tests/phpunit/Application/XssSafetyTest.php
index b34a956..4e1e41b 100644
--- a/tests/phpunit/Application/XssSafetyTest.php
+++ b/tests/phpunit/Application/XssSafetyTest.php
@@ -6,6 +6,7 @@
 
 use PHPUnit\Framework\TestCase;
 use ProfessionalWiki\NativeMarkdown\Application\MarkdownRenderer;
+use ProfessionalWiki\NativeMarkdown\Application\NoOpCodeHighlighter;
 use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeFileEmbedRenderer;
 use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakePageLinkRenderer;
 use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeWikiTitleParser;
@@ -94,6 +95,7 @@ private function render( string $markdown, bool $allowExternalImages = false ):
 			titleParser: new FakeWikiTitleParser(),
 			pageLinkRenderer: new FakePageLinkRenderer(),
 			fileEmbedRenderer: new FakeFileEmbedRenderer(),
+			codeHighlighter: new NoOpCodeHighlighter(),
 			allowExternalImages: $allowExternalImages,
 			maxNestingLevel: 100,
 			tocPlaceholderHtml: null,
diff --git a/tests/phpunit/EntryPoints/MarkdownContentHandlerTest.php b/tests/phpunit/EntryPoints/MarkdownContentHandlerTest.php
index 363db02..764ac85 100644
--- a/tests/phpunit/EntryPoints/MarkdownContentHandlerTest.php
+++ b/tests/phpunit/EntryPoints/MarkdownContentHandlerTest.php
@@ -8,6 +8,7 @@
 use MediaWiki\MainConfigNames;
 use MediaWiki\Page\PageReferenceValue;
 use MediaWiki\Parser\ParserOutput;
+use MediaWiki\Registration\ExtensionRegistry;
 use MediaWikiIntegrationTestCase;
 use ProfessionalWiki\NativeMarkdown\EntryPoints\MarkdownContent;
 use ProfessionalWiki\NativeMarkdown\Tests\FrontMatterBombs;
@@ -218,6 +219,37 @@ public function testInterwikiFragmentOnlyLinkKeepsPrefixInLabel(): void {
 		$this->assertStringContainsString( '>wikipedia:#History', $output->getRawText() );
 	}
 
+	public function testMarkdownPageLoadsContentStyles(): void {
+		$output = $this->getParserOutput( 'Just some prose, without a code block in sight.' );
+
+		$this->assertContains( 'ext.nativeMarkdown.styles', $output->getModuleStyles() );
+	}
+
+	public function testFencedCodeBlockWithLanguageIsSyntaxHighlighted(): void {
+		$this->skipIfSyntaxHighlightMissing();
+
+		$output = $this->getParserOutput( "```python\nprint('hi')\n```" );
+
+		$this->assertStringContainsString( 'mw-highlight-lang-python', $output->getRawText() );
+		$this->assertContains( 'ext.pygments', $output->getModuleStyles() );
+		$this->assertContains( 'ext.pygments.view', $output->getModules() );
+	}
+
+	public function testFencedCodeBlockWithoutLanguageIsNotHighlighted(): void {
+		$this->skipIfSyntaxHighlightMissing();
+
+		$output = $this->getParserOutput( "```\nplain code\n```" );
+
+		$this->assertStringContainsString( '
plain code', $output->getRawText() );
+		$this->assertNotContains( 'ext.pygments', $output->getModuleStyles() );
+	}
+
+	private function skipIfSyntaxHighlightMissing(): void {
+		if ( !ExtensionRegistry::getInstance()->isLoaded( 'SyntaxHighlight' ) ) {
+			$this->markTestSkipped( 'Extension:SyntaxHighlight is not installed' );
+		}
+	}
+
 	private function configureWikipediaInterwiki(): void {
 		$globalScope = 2;
 		$this->overrideConfigValues( [
diff --git a/tests/phpunit/Persistence/SyntaxHighlightCodeHighlighterTest.php b/tests/phpunit/Persistence/SyntaxHighlightCodeHighlighterTest.php
new file mode 100644
index 0000000..683a386
--- /dev/null
+++ b/tests/phpunit/Persistence/SyntaxHighlightCodeHighlighterTest.php
@@ -0,0 +1,44 @@
+isLoaded( 'SyntaxHighlight' ) ) {
+			$this->markTestSkipped( 'Extension:SyntaxHighlight is not installed' );
+		}
+	}
+
+	private function highlight( string $code, string $language ): ?string {
+		return ( new SyntaxHighlightCodeHighlighter() )->highlight( $code, $language );
+	}
+
+	public function testKnownLanguageIsHighlightedIntoPygmentsMarkup(): void {
+		$html = $this->highlight( 'print("hi")', 'python' );
+
+		$this->assertNotNull( $html );
+		$this->assertStringContainsString( 'mw-highlight-lang-python', $html );
+		$this->assertStringContainsString( 'assertNull( $this->highlight( 'print("hi")', 'definitelynotalanguage' ) );
+	}
+
+	public function testEmptyCodeIsNotHighlighted(): void {
+		$this->assertNull( $this->highlight( '', 'python' ) );
+	}
+
+}
diff --git a/tests/phpunit/TestDoubles/FakeCodeHighlighter.php b/tests/phpunit/TestDoubles/FakeCodeHighlighter.php
new file mode 100644
index 0000000..e4b72e8
--- /dev/null
+++ b/tests/phpunit/TestDoubles/FakeCodeHighlighter.php
@@ -0,0 +1,45 @@
+ */
+	public array $calls = [];
+
+	/**
+	 * @param string[] $modules
+	 * @param string[] $styleModules
+	 */
+	public function __construct(
+		private readonly ?string $html = '
HIGHLIGHTED
', + private readonly array $modules = [ 'test.pygments.view' ], + private readonly array $styleModules = [ 'test.pygments' ] + ) { + } + + public function highlight( string $code, string $language ): ?string { + $this->calls[] = [ 'code' => $code, 'language' => $language ]; + + return $this->html; + } + + public function modules(): array { + return $this->modules; + } + + public function styleModules(): array { + return $this->styleModules; + } + +}