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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ php tests/phpunit/phpunit.php extensions/NativeMarkdown/tests/phpunit/
installed; without it, code blocks keep rendering as plain preformatted text
* Code blocks no longer get a background pill behind each of their lines, which happened because skins style the
`code` element for inline use and CommonMark nests it inside `pre`
* A `thumb` file embed alone on its line now renders as a standalone framed thumbnail rather than being wrapped
in a paragraph, which produced invalid HTML5 and a stray empty paragraph
* A `thumb` embed of a missing file now renders the framed box with its caption and an upload link, the way
wikitext does, instead of a bare upload link that silently dropped the caption
* Thumbnail embeds now load the same media module MediaWiki loads for wikitext thumbnails, so their magnify
affordance works

### Version 1.1.0 - 2026-07-09

Expand Down
10 changes: 10 additions & 0 deletions src/Application/FileEmbedRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,14 @@ public function preloadFiles( array $titles ): void;
*/
public function renderEmbed( FileEmbed $embed ): string;

/**
* ResourceLoader modules a rendered thumbnail needs to be interactive (its
* magnify affordance). The caller adds them only when it actually embedded a
* thumbnail, mirroring how a code highlighter's modules are added only for
* highlighted blocks.
*
* @return string[]
*/
public function modules(): array;

}
72 changes: 71 additions & 1 deletion src/Application/MarkdownRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use League\CommonMark\Extension\Table\TableCell;
use League\CommonMark\Node\Block\AbstractBlock;
use League\CommonMark\Node\Block\Document;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Node\Inline\Newline;
use League\CommonMark\Node\Inline\Text;
use League\CommonMark\Node\Node;
Expand Down Expand Up @@ -227,6 +228,10 @@ private function renderDocument(
// and skipping it avoids the highlighter's cost when the HTML is discarded.
$highlighted = $generateHtml && $this->highlightCodeBlocks( $document );

// Likewise HTML-only: a thumbnail's client-side module matters only once the
// thumbnail is actually rendered, which a metadata-only parse never does.
$rendersThumbnail = $generateHtml && $this->hasThumbnailEmbed( $files );

return new RenderedMarkdown(
html: $generateHtml ? $this->renderHtml( $document, $sections ) : '',
links: $links,
Expand All @@ -235,7 +240,10 @@ private function renderDocument(
sections: $sections,
externalLinks: $this->collectExternalLinks( $document ),
frontMatter: $frontMatter,
modules: $highlighted ? $this->codeHighlighter->modules() : [],
modules: array_merge(
$highlighted ? $this->codeHighlighter->modules() : [],
$rendersThumbnail ? $this->fileEmbedRenderer->modules() : []
),
styleModules: $highlighted ? $this->codeHighlighter->styleModules() : []
);
}
Expand Down Expand Up @@ -278,6 +286,19 @@ private function highlightCodeBlocks( Document $document ): bool {
return $anyHighlighted;
}

/**
* @param FileEmbed[] $files
*/
private function hasThumbnailEmbed( array $files ): bool {
foreach ( $files as $embed ) {
if ( $embed->thumbnail ) {
return true;
}
}

return false;
}

/**
* 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 @@ -436,13 +457,62 @@ private function preloadFileLookups( array $files, bool $generateHtml ): void {
* @param Section[] $sections
*/
private function renderHtml( Document $document, array $sections ): string {
$this->promoteSolitaryThumbnailEmbeds( $document );

if ( $this->tocPlaceholderHtml !== null && $sections !== [] ) {
$this->insertTocPlaceholder( $document );
}

return $this->htmlRenderer->renderDocument( $document )->getContent();
}

/**
* A thumbnail embed renders a block-level `<figure>`, which is invalid inside
* the phrasing-only `<p>` that CommonMark wraps a solitary inline node in:
* browsers auto-close the paragraph, shipping invalid HTML5 with a stray empty
* `<p>` behind it. When such an embed is a paragraph's only content, replace the
* paragraph with the embed so its `<figure>` becomes a sibling of the
* surrounding blocks, the way wikitext emits a standalone thumbnail. Only the
* solitary case is handled: a thumbnail that shares its paragraph with text or a
* second embed is left wrapped, outside this fix's scope.
*/
private function promoteSolitaryThumbnailEmbeds( Document $document ): void {
foreach ( $this->nodesOfType( $document, Paragraph::class ) as $paragraph ) {
$embed = $this->solitaryThumbnailEmbed( $paragraph );

if ( $embed !== null ) {
$paragraph->replaceWith( $embed );
}
}
}

/**
* The thumbnail embed that is a paragraph's sole content, ignoring surrounding
* whitespace, or null when the paragraph holds anything else: other text, a
* second embed, or a non-thumbnail embed (which renders inline and needs no
* promotion).
*/
private function solitaryThumbnailEmbed( Paragraph $paragraph ): ?FileEmbedNode {
$embed = null;

foreach ( $paragraph->children() as $child ) {
if ( $child instanceof FileEmbedNode && $child->embed->thumbnail ) {
if ( $embed !== null ) {
return null;
}

$embed = $child;
continue;
}

if ( !$this->isBlankInline( $child ) ) {
return null;
}
}

return $embed;
}

/**
* Puts the ToC placeholder before the top-level block containing the first
* heading, mirroring where the wikitext parser places the table of contents.
Expand Down
27 changes: 24 additions & 3 deletions src/Persistence/MediaWikiFileEmbedRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public function preloadFiles( array $titles ): void {
}
}

/**
* Core's Linker registers this for a thumbnail only when passed a Parser, which
* the ContentHandler render path has none of, so the caller adds it instead.
*/
public function modules(): array {
return [ 'mediawiki.page.media' ];
}

public function renderEmbed( FileEmbed $embed ): string {
$file = $this->findFile( $embed->title->dbKey );

Expand All @@ -67,11 +75,15 @@ public function renderEmbed( FileEmbed $embed ): string {

/**
* Delegates to core so the framed markup, magnify link and responsive
* variants match a wikitext `|thumb` exactly. A width is always passed:
* the requested one, or the wiki's default thumbnail size, since
* variants match a wikitext `|thumb` exactly. For a missing file (a false
* `$file`), core frames a broken-thumb box with the same visible caption and an
* upload link inside, the way wikitext renders a missing thumbnail. A width is
* always passed: the requested one, or the wiki's default thumbnail size, since
* makeThumbLink2 would otherwise fall back to a smaller built-in default.
*
* @param File|false $file
*/
private function thumbnailFrameHtml( FileEmbed $embed, File $file ): string {
private function thumbnailFrameHtml( FileEmbed $embed, $file ): string {
return Linker::makeThumbLink2(
$this->fileTitle( $embed ),
$file,
Expand Down Expand Up @@ -163,7 +175,16 @@ private function thumbnailOptions( FileEmbed $embed ): array {
return $options;
}

/**
* A `thumb` on a missing file frames a broken-thumb box that keeps the caption
* visible, as wikitext does; without it, the embed is a bare upload red link
* labelled with the alt text, and the caption would be silently dropped.
*/
private function missingFileHtml( FileEmbed $embed ): string {
if ( $embed->thumbnail ) {
return $this->thumbnailFrameHtml( $embed, false );
}

return $this->wrap(
Linker::makeBrokenImageLinkObj(
$this->fileTitle( $embed ),
Expand Down
54 changes: 54 additions & 0 deletions tests/phpunit/Application/MarkdownRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,33 @@ public function testFileEmbedRendersImageAndIsCollectedAsFile(): void {
$this->assertSame( [], $result->links );
}

public function testStandaloneThumbnailEmbedIsNotWrappedInParagraph(): void {
$html = $this->render( '[[File:Cat.png|thumb|A cat]]' )->html;

$this->assertStringContainsString( 'data-fake-file="Cat.png"', $html );
$this->assertStringNotContainsString( '<p>', $html );
}

public function testSolitaryInlineEmbedStaysWrappedInParagraph(): void {
$html = $this->render( '[[File:Cat.png|A cat]]' )->html;

$this->assertStringContainsString( '<p><img data-fake-file="Cat.png"', $html );
}

public function testThumbnailEmbedWithSurroundingTextStaysInParagraph(): void {
$html = $this->render( 'Look: [[File:Cat.png|thumb|A cat]]' )->html;

$this->assertStringContainsString( '<p>Look: <img data-fake-file="Cat.png"', $html );
}

public function testParagraphWithTwoThumbnailEmbedsKeepsBothWrapped(): void {
$html = $this->render( "[[File:Cat.png|thumb|First]]\n[[File:Dog.png|thumb|Second]]" )->html;

$this->assertStringContainsString( '<p>', $html );
$this->assertStringContainsString( 'data-fake-file="Cat.png"', $html );
$this->assertStringContainsString( 'data-fake-file="Dog.png"', $html );
}

public function testColonPrefixedFileRendersPageLinkInsteadOfEmbedding(): void {
$result = $this->render( '[[:File:Cat.png]]' );

Expand Down Expand Up @@ -1391,6 +1418,33 @@ public function testNoModulesWhenEveryBlockDeclinesHighlighting(): void {
$this->assertSame( [], $result->styleModules );
}

public function testEmbeddedThumbnailReportsTheFileRenderersMediaModules(): void {
$result = $this->render( '[[File:Cat.png|thumb]]' );

$this->assertSame( [ 'test.file.media' ], $result->modules );
}

public function testInlineEmbedReportsNoMediaModules(): void {
$result = $this->render( '[[File:Cat.png]]' );

$this->assertSame( [], $result->modules );
}

public function testMetadataOnlyRenderReportsNoMediaModules(): void {
$result = $this->render( '[[File:Cat.png|thumb]]', generateHtml: false );

$this->assertSame( [], $result->modules );
}

public function testThumbnailAndHighlightedCodeReportBothModuleSets(): void {
$result = $this->render(
"[[File:Cat.png|thumb]]\n\n```python\nx\n```",
codeHighlighter: new FakeCodeHighlighter( modules: [ 'ext.demo.view' ] )
);

$this->assertSame( [ 'ext.demo.view', 'test.file.media' ], $result->modules );
}

/**
* A stack of distinct fenced blocks ```lang1 .. ```lang$count, each with a
* one-word language so every block is a highlight candidate.
Expand Down
45 changes: 45 additions & 0 deletions tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@ public function testThumbnailUsesExplicitAltTextForImageWhileShowingCaption(): v
$this->assertStringContainsString( '<figcaption>Quarterly revenue</figcaption>', $html );
}

public function testMissingThumbnailRendersFramedBoxWithCaptionAndUploadLink(): void {
$html = $this->renderMissingFile( thumbnail: true, caption: 'Quarterly revenue' );

$this->assertStringContainsString( 'typeof="mw:Error mw:File/Thumb"', $html );
$this->assertStringContainsString( '<figcaption>Quarterly revenue</figcaption>', $html );
$this->assertStringContainsString( 'Special:Upload', $html );
}

public function testMissingThumbnailCaptionIsHtmlEscaped(): void {
$html = $this->renderMissingFile( thumbnail: true, caption: '<script>alert(1)</script>' );

$this->assertStringNotContainsString( '<script>', $html );
$this->assertStringContainsString( '&lt;script&gt;', $html );
}

public function testMissingInlineFileStaysPlainBrokenLinkWithoutFrame(): void {
$html = $this->renderMissingFile( thumbnail: false, caption: 'Quarterly revenue' );

$this->assertStringNotContainsString( '<figure', $html );
$this->assertStringContainsString( 'Special:Upload', $html );
}

public function testPreloadedFileRendersWithoutPerEmbedLookup(): void {
$repoGroup = $this->createMock( RepoGroup::class );
$repoGroup->method( 'findFiles' )->willReturn( [ 'Chart.png' => $this->newFileRenderingThumbnails() ] );
Expand Down Expand Up @@ -169,6 +191,29 @@ private function renderThumbnail(
);
}

private function renderMissingFile(
bool $thumbnail,
?string $caption,
?string $altText = null
): string {
// The upload red link only appears where uploads are enabled; vanilla
// MediaWiki defaults them off, so pin it for a deterministic assertion.
$this->overrideConfigValue( MainConfigNames::EnableUploads, true );

$repoGroup = $this->createStub( RepoGroup::class );
$repoGroup->method( 'findFile' )->willReturn( false );

return $this->newRendererWithRepoGroup( $repoGroup )->renderEmbed(
new FileEmbed(
title: $this->chartTitle(),
width: null,
altText: $altText,
caption: $caption,
thumbnail: $thumbnail
)
);
}

private function newFileWithFailingTransform(): File {
$file = $this->newExistingInlineFile();
$file->method( 'transform' )->willReturn(
Expand Down
4 changes: 4 additions & 0 deletions tests/phpunit/TestDoubles/FakeFileEmbedRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ public function renderEmbed( FileEmbed $embed ): string {
. '>';
}

public function modules(): array {
return [ 'test.file.media' ];
}

}
Loading