From cb697049529d549c9b12936ac9d841d1f102a3b7 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Fri, 17 Jul 2026 01:16:49 +0200 Subject: [PATCH 1/6] Promote solitary thumbnail embeds out of their paragraph For https://github.com/ProfessionalWiki/NativeMarkdown/issues/15 A `thumb` embed alone on a line rendered as `

...

`. A `
` is flow content, invalid inside a phrasing-only `

`, so browsers auto-close the paragraph and leave a stray empty `

` behind it. The HTML is set directly on ParserOutput, so it never passes through RemexHtml to be fixed. After parsing, a paragraph whose only content is a thumbnail file embed is now replaced by the embed node itself, so its `

` is emitted as a sibling of the surrounding blocks, the way wikitext renders a standalone thumbnail. Embeds sharing their paragraph with other content, and inline non-thumbnail embeds, are left untouched, since they render as valid phrasing content in place. Co-Authored-By: Claude Fable 5 --- src/Application/MarkdownRenderer.php | 50 +++++++++++++++++++ .../Application/MarkdownRendererTest.php | 19 +++++++ 2 files changed, 69 insertions(+) diff --git a/src/Application/MarkdownRenderer.php b/src/Application/MarkdownRenderer.php index e0c7a1b..77712c1 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; @@ -436,6 +437,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 +446,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. Embeds that + * share their paragraph with other content are left wrapped, since they render + * inline there. + */ + 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/tests/phpunit/Application/MarkdownRendererTest.php b/tests/phpunit/Application/MarkdownRendererTest.php index 93fec75..6f154c7 100644 --- a/tests/phpunit/Application/MarkdownRendererTest.php +++ b/tests/phpunit/Application/MarkdownRendererTest.php @@ -544,6 +544,25 @@ 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]]' ); From 9c26c298257c91e871512fc81331209e272cac71 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Fri, 17 Jul 2026 01:17:11 +0200 Subject: [PATCH 2/6] Frame missing-file thumbnail embeds and keep their caption For https://github.com/ProfessionalWiki/NativeMarkdown/issues/15 A `thumb` embed of a missing file fell through to the plain upload red link, which is labelled only with the alt text, so the caption was silently dropped. A missing file with `thumb` now goes through the same core thumbnail path as an existing one, passing a false File. Core frames a broken-thumb box that keeps the caption visible and puts the upload link inside, the way wikitext renders a missing thumbnail. Missing files without `thumb` keep the bare upload link. Co-Authored-By: Claude Fable 5 --- .../MediaWikiFileEmbedRenderer.php | 19 ++++++-- .../MediaWikiFileEmbedRendererTest.php | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/Persistence/MediaWikiFileEmbedRenderer.php b/src/Persistence/MediaWikiFileEmbedRenderer.php index c4f5331..1bd9fc4 100644 --- a/src/Persistence/MediaWikiFileEmbedRenderer.php +++ b/src/Persistence/MediaWikiFileEmbedRenderer.php @@ -67,11 +67,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 +167,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/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( '