Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/installMediaWiki.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOT >> composer.local.json
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<syntaxhighlight>` 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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
4 changes: 4 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
<!-- The extension's own vendor: portable across the dev env and CI, where
league/commonmark is not merged into MediaWiki's vendor directory. -->
<directory name="vendor/league" />
<!-- Optional Extension:SyntaxHighlight, referenced by the Persistence
adapter but not part of the scanned MediaWiki core. Cloned in CI;
present in the dev env. -->
<directory name="../../extensions/SyntaxHighlight_GeSHi/includes" />
</extraFiles>
<issueHandlers>
<MissingPropertyType errorLevel="suppress" />
Expand Down
14 changes: 14 additions & 0 deletions resources/ext.nativeMarkdown.styles.css
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 40 additions & 0 deletions src/Application/CodeHighlighter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application;

/**
* Port for turning a fenced code block into syntax-highlighted HTML.
*
* Implementations delegate to whatever highlighter the wiki has available.
* When a block cannot be highlighted, highlight() returns null and the caller
* keeps the default escaped `<pre><code>` 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;

}
33 changes: 33 additions & 0 deletions src/Application/CommonMark/HighlightedCodeRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application\CommonMark;

use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;

/**
* Renders a fenced code block as the highlighter's trusted HTML when
* MarkdownRenderer stashed some on the node. When nothing is stashed it returns
* null, so league's default FencedCodeRenderer takes over and emits the plain
* escaped `<pre><code>` 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;
}

}
67 changes: 65 additions & 2 deletions src/Application/MarkdownRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
* `<syntaxhighlight>` 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;
Expand All @@ -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,
Expand Down Expand Up @@ -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 ),
Expand Down Expand Up @@ -206,17 +223,61 @@ 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,
categories: $categories,
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
Expand Down Expand Up @@ -334,7 +395,9 @@ private function escapedSourceFallback( string $markdown, bool $generateHtml ):
files: [],
sections: [],
externalLinks: [],
frontMatter: null
frontMatter: null,
modules: [],
styleModules: []
);
}

Expand Down
25 changes: 25 additions & 0 deletions src/Application/NoOpCodeHighlighter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application;

/**
* Highlights nothing: used when the wiki has no syntax highlighter installed,
* so every fenced block keeps the default escaped rendering.
*/
final class NoOpCodeHighlighter implements CodeHighlighter {

public function highlight( string $code, string $language ): ?string {
return null;
}

public function modules(): array {
return [];
}

public function styleModules(): array {
return [];
}

}
6 changes: 5 additions & 1 deletion src/Application/RenderedMarkdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ final class RenderedMarkdown {
* @param Section[] $sections
* @param string[] $externalLinks External URLs this document links to
* @param array<int|string, mixed>|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,
Expand All @@ -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
) {
}

Expand Down
Loading
Loading