diff --git a/README.md b/README.md index 823e550..660a31c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Application/FileEmbedRenderer.php b/src/Application/FileEmbedRenderer.php index 903810d..71ec873 100644 --- a/src/Application/FileEmbedRenderer.php +++ b/src/Application/FileEmbedRenderer.php @@ -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; + } diff --git a/src/Application/MarkdownRenderer.php b/src/Application/MarkdownRenderer.php index e0c7a1b..8ce12c0 100644 --- a/src/Application/MarkdownRenderer.php +++ b/src/Application/MarkdownRenderer.php @@ -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; @@ -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, @@ -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() : [] ); } @@ -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 @@ -436,6 +457,8 @@ 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 ); } @@ -443,6 +466,53 @@ private function renderHtml( Document $document, array $sections ): string { return $this->htmlRenderer->renderDocument( $document )->getContent(); } + /** + * A thumbnail embed renders a block-level `
`, which is invalid inside + * the phrasing-only `

` that CommonMark wraps a solitary inline node in: + * browsers auto-close the paragraph, shipping invalid HTML5 with a stray empty + * `

` behind it. When such an embed is a paragraph's only content, replace the + * paragraph with the embed so its `

` 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. diff --git a/src/Persistence/MediaWikiFileEmbedRenderer.php b/src/Persistence/MediaWikiFileEmbedRenderer.php index c4f5331..ed31117 100644 --- a/src/Persistence/MediaWikiFileEmbedRenderer.php +++ b/src/Persistence/MediaWikiFileEmbedRenderer.php @@ -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 ); @@ -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, @@ -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 ), diff --git a/tests/phpunit/Application/MarkdownRendererTest.php b/tests/phpunit/Application/MarkdownRendererTest.php index 93fec75..8b36608 100644 --- a/tests/phpunit/Application/MarkdownRendererTest.php +++ b/tests/phpunit/Application/MarkdownRendererTest.php @@ -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( '

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

render( 'Look: [[File:Cat.png|thumb|A cat]]' )->html; + + $this->assertStringContainsString( '

Look: render( "[[File:Cat.png|thumb|First]]\n[[File:Dog.png|thumb|Second]]" )->html; + + $this->assertStringContainsString( '

', $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]]' ); @@ -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. diff --git a/tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php b/tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php index b090d49..bbe3c78 100644 --- a/tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php +++ b/tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php @@ -97,6 +97,28 @@ public function testThumbnailUsesExplicitAltTextForImageWhileShowingCaption(): v $this->assertStringContainsString( '

Quarterly revenue
', $html ); } + public function testMissingThumbnailRendersFramedBoxWithCaptionAndUploadLink(): void { + $html = $this->renderMissingFile( thumbnail: true, caption: 'Quarterly revenue' ); + + $this->assertStringContainsString( 'typeof="mw:Error mw:File/Thumb"', $html ); + $this->assertStringContainsString( '
Quarterly revenue
', $html ); + $this->assertStringContainsString( 'Special:Upload', $html ); + } + + public function testMissingThumbnailCaptionIsHtmlEscaped(): void { + $html = $this->renderMissingFile( thumbnail: true, caption: '' ); + + $this->assertStringNotContainsString( '