Skip to content

Commit 32e7ac4

Browse files
committed
feat(renderer): emit CommonMark-compliant XHTML output
Block elements now trail \n, containers have internal newlines, void elements use self-closing syntax (<hr />, <br />, <img />).
1 parent b2e720a commit 32e7ac4

32 files changed

Lines changed: 424 additions & 189 deletions

src/Renderer/HtmlRenderer.php

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,20 @@ private function renderNode(NodeInterface $node): string
6262
{
6363
return match (true) {
6464
$node instanceof HeadingNode => $this->renderHeading($node),
65-
$node instanceof ParagraphNode => '<p>' . $this->renderChildren($node->children) . '</p>',
66-
$node instanceof BlockquoteNode => '<blockquote>' . $this->renderChildren($node->children) . '</blockquote>',
65+
$node instanceof ParagraphNode => '<p>' . $this->renderChildren($node->children) . "</p>\n",
66+
$node instanceof BlockquoteNode => "<blockquote>\n" . $this->renderChildren($node->children) . "</blockquote>\n",
6767
$node instanceof ListNode => $this->renderList($node),
6868
$node instanceof ListItemNode => $this->renderListItem($node),
6969
$node instanceof FencedCodeNode => $this->renderFencedCode($node),
70-
$node instanceof IndentedCodeNode => '<pre><code>' . $this->esc($node->content) . '</code></pre>',
71-
$node instanceof HorizontalRuleNode => '<hr>',
70+
$node instanceof IndentedCodeNode => '<pre><code>' . $this->esc($node->content) . "</code></pre>\n",
71+
$node instanceof HorizontalRuleNode => "<hr />\n",
7272
$node instanceof ColumnsNode => $this->renderColumns($node),
7373
// Raw HTML blocks: sanitize if allowRawHtml, else escape (XSS-safe default).
7474
$node instanceof RawHtmlBlockNode =>
7575
$this->allowRawHtml
76-
? $this->sanitizer->sanitize($node->content)
77-
: $this->esc($node->content),
78-
$node instanceof HardBreakNode => '<br>',
76+
? $this->sanitizer->sanitize($node->content) . "\n"
77+
: $this->esc($node->content) . "\n",
78+
$node instanceof HardBreakNode => "<br />\n",
7979
// HTML entities pass through verbatim — validated by InlineParser, no esc() needed.
8080
$node instanceof HtmlEntityNode => $node->entity,
8181
$node instanceof TextNode => $this->esc($node->text),
@@ -112,7 +112,7 @@ private function renderChildren(array $nodes): string
112112
private function renderHeading(HeadingNode $node): string
113113
{
114114
$tag = 'h' . $node->level;
115-
return '<' . $tag . '>' . $this->renderChildren($node->children) . '</' . $tag . '>';
115+
return '<' . $tag . '>' . $this->renderChildren($node->children) . '</' . $tag . ">\n";
116116
}
117117

118118
private function renderList(ListNode $node): string
@@ -122,18 +122,21 @@ private function renderList(ListNode $node): string
122122
foreach ($node->children as $item) {
123123
$inner .= $this->renderListItem($item, $node->loose);
124124
}
125-
return '<' . $tag . '>' . $inner . '</' . $tag . '>';
125+
return '<' . $tag . ">\n" . $inner . '</' . $tag . ">\n";
126126
}
127127

128128
private function renderListItem(ListItemNode $node, bool $loose = false): string
129129
{
130130
$content = $this->renderListItemContent($node->children, $loose);
131131

132132
if ($node->checked === null) {
133-
return '<li>' . $content . '</li>';
133+
if ($loose) {
134+
return "<li>\n" . $content . "</li>\n";
135+
}
136+
return '<li>' . $content . "</li>\n";
134137
}
135138
$checkbox = '<input type="checkbox" disabled' . ($node->checked ? ' checked' : '') . '>';
136-
return '<li>' . $checkbox . ' ' . $content . '</li>';
139+
return '<li>' . $checkbox . ' ' . $content . "</li>\n";
137140
}
138141

139142
/**
@@ -161,7 +164,7 @@ private function renderListItemContent(array $children, bool $loose): string
161164

162165
$html = '';
163166
if ($inlinePart !== []) {
164-
$html .= '<p>' . $this->renderChildren($inlinePart) . '</p>';
167+
$html .= '<p>' . $this->renderChildren($inlinePart) . "</p>\n";
165168
}
166169
foreach ($blockPart as $block) {
167170
$html .= $this->renderNode($block);
@@ -187,7 +190,7 @@ private function renderFencedCode(FencedCodeNode $node): string
187190
$classAttr = ($lang ?? '') !== ''
188191
? ' class="' . $this->esc('language-' . (string) $lang) . '"'
189192
: '';
190-
return '<pre><code' . $classAttr . '>' . $this->esc($node->content) . '</code></pre>';
193+
return '<pre><code' . $classAttr . '>' . $this->esc($node->content) . "</code></pre>\n";
191194
}
192195

193196
private function renderLink(LinkNode $node): string
@@ -205,7 +208,7 @@ private function renderImage(ImageNode $node): string
205208
$titleAttr = $node->title !== null
206209
? ' title="' . $this->esc($node->title) . '"'
207210
: '';
208-
return '<img src="' . $this->esc($node->src) . '" alt="' . $this->esc($node->alt) . '"' . $titleAttr . '>';
211+
return '<img src="' . $this->esc($node->src) . '" alt="' . $this->esc($node->alt) . '"' . $titleAttr . ' />';
209212
}
210213

211214
private function renderAutolink(AutolinkNode $node): string

tests/Integration/MarkdownParserTest.php

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public function testFullDocumentPipeline(): void
7474
$this->assertStringContainsString('<strong>bold</strong>', $html);
7575
$this->assertMatchesRegularExpression('/<ul>\s*<li>item<\/li>\s*<\/ul>/', $html);
7676
$this->assertStringContainsString('<blockquote>', $html);
77-
$this->assertStringContainsString('<hr>', $html);
77+
$this->assertStringContainsString('<hr />', $html);
7878
}
7979

8080
// ── Guard: input validation ───────────────────────────────────────────────
@@ -238,31 +238,31 @@ public function testSimpleNestedList(): void
238238
$md = "- a\n - b\n - c\n- d";
239239
$html = $this->parser->parse($md);
240240

241-
$this->assertSame('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>', $html);
241+
$this->assertSame("<ul>\n<li>a<ul>\n<li>b</li>\n<li>c</li>\n</ul>\n</li>\n<li>d</li>\n</ul>\n", $html);
242242
}
243243

244244
public function testThreeLevelNesting(): void
245245
{
246246
$md = "- a\n - b\n - c";
247247
$html = $this->parser->parse($md);
248248

249-
$this->assertSame('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li></ul>', $html);
249+
$this->assertSame("<ul>\n<li>a<ul>\n<li>b<ul>\n<li>c</li>\n</ul>\n</li>\n</ul>\n</li>\n</ul>\n", $html);
250250
}
251251

252252
public function testMixedNestingTypes(): void
253253
{
254254
$md = "1. first\n - nested\n2. second";
255255
$html = $this->parser->parse($md);
256256

257-
$this->assertSame('<ol><li>first<ul><li>nested</li></ul></li><li>second</li></ol>', $html);
257+
$this->assertSame("<ol>\n<li>first<ul>\n<li>nested</li>\n</ul>\n</li>\n<li>second</li>\n</ol>\n", $html);
258258
}
259259

260260
public function testFlatListUnchanged(): void
261261
{
262262
$md = "- a\n- b\n- c";
263263
$html = $this->parser->parse($md);
264264

265-
$this->assertSame('<ul><li>a</li><li>b</li><li>c</li></ul>', $html);
265+
$this->assertSame("<ul>\n<li>a</li>\n<li>b</li>\n<li>c</li>\n</ul>\n", $html);
266266
}
267267

268268
public function testInlineContentInNestedItem(): void
@@ -271,7 +271,7 @@ public function testInlineContentInNestedItem(): void
271271
$html = $this->parser->parse($md);
272272

273273
$this->assertSame(
274-
'<ul><li><strong>bold</strong><ul><li><em>em</em></li></ul></li></ul>',
274+
"<ul>\n<li><strong>bold</strong><ul>\n<li><em>em</em></li>\n</ul>\n</li>\n</ul>\n",
275275
$html
276276
);
277277
}
@@ -282,7 +282,7 @@ public function testLargeDepthGapCollapsesToDirectNesting(): void
282282
$md = "- top\n" . str_repeat(' ', 20) . "- deep";
283283
$html = $this->parser->parse($md);
284284

285-
$this->assertSame('<ul><li>top<ul><li>deep</li></ul></li></ul>', $html);
285+
$this->assertSame("<ul>\n<li>top<ul>\n<li>deep</li>\n</ul>\n</li>\n</ul>\n", $html);
286286
}
287287

288288
public function testUlFollowedByOlAtSameDepthProducesTwoSeparateLists(): void
@@ -292,7 +292,7 @@ public function testUlFollowedByOlAtSameDepthProducesTwoSeparateLists(): void
292292
$md = "- ul item\n1. ol item";
293293
$html = $this->parser->parse($md);
294294

295-
$this->assertSame('<ul><li>ul item</li></ul><ol><li>ol item</li></ol>', $html);
295+
$this->assertSame("<ul>\n<li>ul item</li>\n</ul>\n<ol>\n<li>ol item</li>\n</ol>\n", $html);
296296
}
297297

298298
public function testDepthCapAt32DoesNotCrashAndProducesTwoLists(): void
@@ -325,7 +325,7 @@ public function testNestedBlockquoteMixedLevels(): void
325325
$html = $this->parser->parse($md);
326326

327327
$this->assertSame(
328-
'<blockquote><p>outer</p><blockquote><p>inner</p></blockquote><p>outer again</p></blockquote>',
328+
"<blockquote>\n<p>outer</p>\n<blockquote>\n<p>inner</p>\n</blockquote>\n<p>outer again</p>\n</blockquote>\n",
329329
$html,
330330
);
331331
}
@@ -336,7 +336,7 @@ public function testThreeLevelBlockquote(): void
336336
$html = $this->parser->parse($md);
337337

338338
$this->assertSame(
339-
'<blockquote><blockquote><blockquote><p>triple</p></blockquote></blockquote></blockquote>',
339+
"<blockquote>\n<blockquote>\n<blockquote>\n<p>triple</p>\n</blockquote>\n</blockquote>\n</blockquote>\n",
340340
$html,
341341
);
342342
}
@@ -347,7 +347,7 @@ public function testLevelDecreaseThenIncrease(): void
347347
$html = $this->parser->parse($md);
348348

349349
$this->assertSame(
350-
'<blockquote><p>a</p><blockquote><p>b</p></blockquote><p>c</p><blockquote><p>d</p></blockquote></blockquote>',
350+
"<blockquote>\n<p>a</p>\n<blockquote>\n<p>b</p>\n</blockquote>\n<p>c</p>\n<blockquote>\n<p>d</p>\n</blockquote>\n</blockquote>\n",
351351
$html,
352352
);
353353
}
@@ -379,7 +379,7 @@ public function testMultiLineSameLevelJoinedWithSpace(): void
379379
$md = "> line one\n> line two";
380380
$html = $this->parser->parse($md);
381381

382-
$this->assertSame('<blockquote><p>line one line two</p></blockquote>', $html);
382+
$this->assertSame("<blockquote>\n<p>line one line two</p>\n</blockquote>\n", $html);
383383
}
384384

385385
public function testLevelSkipOneToThree(): void
@@ -389,7 +389,7 @@ public function testLevelSkipOneToThree(): void
389389
$html = $this->parser->parse($md);
390390

391391
$this->assertSame(
392-
'<blockquote><p>a</p><blockquote><blockquote><p>c</p></blockquote></blockquote></blockquote>',
392+
"<blockquote>\n<p>a</p>\n<blockquote>\n<blockquote>\n<p>c</p>\n</blockquote>\n</blockquote>\n</blockquote>\n",
393393
$html,
394394
);
395395
}
@@ -401,7 +401,7 @@ public function testBlockquoteAdjacentToParagraph(): void
401401
$html = $this->parser->parse($md);
402402

403403
$this->assertSame(
404-
'<blockquote><p>quoted</p></blockquote><p>plain paragraph</p>',
404+
"<blockquote>\n<p>quoted</p>\n</blockquote>\n<p>plain paragraph</p>\n",
405405
$html,
406406
);
407407
}
@@ -416,7 +416,7 @@ public function testBlankLineBetweenBlockquoteLevelsSeparatesNodes(): void
416416

417417
// Two separate top-level blockquotes (blank line resets context).
418418
$this->assertSame(
419-
'<blockquote><p>first</p></blockquote><blockquote><p>second</p></blockquote>',
419+
"<blockquote>\n<p>first</p>\n</blockquote>\n<blockquote>\n<p>second</p>\n</blockquote>\n",
420420
$html,
421421
);
422422
}
@@ -426,13 +426,13 @@ public function testBlankLineBetweenBlockquoteLevelsSeparatesNodes(): void
426426
public function testCheckedTaskItemRendersCheckbox(): void
427427
{
428428
$html = $this->parser->parse('- [x] Done');
429-
$this->assertSame('<ul><li><input type="checkbox" disabled checked> Done</li></ul>', $html);
429+
$this->assertSame("<ul>\n<li><input type=\"checkbox\" disabled checked> Done</li>\n</ul>\n", $html);
430430
}
431431

432432
public function testUncheckedTaskItemRendersCheckbox(): void
433433
{
434434
$html = $this->parser->parse('- [ ] Todo');
435-
$this->assertSame('<ul><li><input type="checkbox" disabled> Todo</li></ul>', $html);
435+
$this->assertSame("<ul>\n<li><input type=\"checkbox\" disabled> Todo</li>\n</ul>\n", $html);
436436
}
437437

438438
public function testMixedTaskList(): void
@@ -481,49 +481,49 @@ public function testTwoTrailingSpacesProducesBr(): void
481481
{
482482
// Two trailing spaces before \n must become <br> not a space.
483483
$html = $this->parser->parse("foo \nbar");
484-
$this->assertSame('<p>foo<br>bar</p>', $html);
484+
$this->assertSame("<p>foo<br />\nbar</p>\n", $html);
485485
}
486486

487487
public function testBackslashLineBreakProducesBr(): void
488488
{
489489
// Trailing backslash before \n must become <br>.
490490
$html = $this->parser->parse("foo\\\nbar");
491-
$this->assertSame('<p>foo<br>bar</p>', $html);
491+
$this->assertSame("<p>foo<br />\nbar</p>\n", $html);
492492
}
493493

494494
public function testNoTrailingSpacesProducesSoftBreak(): void
495495
{
496496
// Without trailing spaces the lines are soft-joined with a space.
497497
$html = $this->parser->parse("foo\nbar");
498-
$this->assertSame('<p>foo bar</p>', $html);
498+
$this->assertSame("<p>foo bar</p>\n", $html);
499499
}
500500

501501
public function testHardBreakInsideInlineContent(): void
502502
{
503503
// Hard break after inline strong element.
504504
$html = $this->parser->parse("**bold** \ntext");
505-
$this->assertSame('<p><strong>bold</strong><br>text</p>', $html);
505+
$this->assertSame("<p><strong>bold</strong><br />\ntext</p>\n", $html);
506506
}
507507

508508
public function testMultipleHardBreaksInOneParagraph(): void
509509
{
510510
// Multiple hard breaks in sequence.
511511
$html = $this->parser->parse("a \nb \nc");
512-
$this->assertSame('<p>a<br>b<br>c</p>', $html);
512+
$this->assertSame("<p>a<br />\nb<br />\nc</p>\n", $html);
513513
}
514514

515515
public function testThreeTrailingSpacesStillOneBr(): void
516516
{
517517
// Three or more trailing spaces → still one <br>.
518518
$html = $this->parser->parse("foo \nbar");
519-
$this->assertSame('<p>foo<br>bar</p>', $html);
519+
$this->assertSame("<p>foo<br />\nbar</p>\n", $html);
520520
}
521521

522522
public function testHardBreakOnLastLineOfParagraphStripped(): void
523523
{
524524
// Hard break on last line of a paragraph → no trailing <br> (CommonMark §6.7).
525525
$html = $this->parser->parse("foo ");
526-
$this->assertSame('<p>foo</p>', $html);
526+
$this->assertSame("<p>foo</p>\n", $html);
527527
}
528528

529529
public function testDoubleBackslashIsNotHardBreak(): void
@@ -538,7 +538,7 @@ public function testHardBreakLineWithUnsafeUrlIsFiltered(): void
538538
// isSafeUrl() must still fire on content that carries hard_break=true.
539539
$html = $this->parser->parse("[click](javascript:alert(1)) \nafter");
540540
$this->assertStringNotContainsString('href="javascript:', $html);
541-
$this->assertStringContainsString('<br>', $html);
541+
$this->assertStringContainsString('<br />', $html);
542542
}
543543

544544
public function testFencedCodeInnerLineWithTrailingSpacesIsNotHardBreak(): void
@@ -562,14 +562,14 @@ public function testSoftBreakBetweenParagraphLinesUnchanged(): void
562562
{
563563
// Pre-existing soft-break behavior must not regress.
564564
$html = $this->parser->parse("line one\nline two\nline three");
565-
$this->assertSame('<p>line one line two line three</p>', $html);
565+
$this->assertSame("<p>line one line two line three</p>\n", $html);
566566
}
567567

568568
public function testBlankLineSeparatesParagraphs(): void
569569
{
570570
// Blank line separation of paragraphs must not regress.
571571
$html = $this->parser->parse("para one\n\npara two");
572-
$this->assertSame('<p>para one</p><p>para two</p>', $html);
572+
$this->assertSame("<p>para one</p>\n<p>para two</p>\n", $html);
573573
}
574574

575575
// ── Inline HTML in headings — full pipeline (story 29) ───────────────────
@@ -620,7 +620,7 @@ public function testHardBreakIsNotMisidentifiedAsRawHtmlInline(): void
620620

621621
// HTML output must be the canonical hard-break form.
622622
$html = $this->parser->parse("foo \nbar");
623-
$this->assertSame('<p>foo<br>bar</p>', $html);
623+
$this->assertSame("<p>foo<br />\nbar</p>\n", $html);
624624

625625
// AST: single ParagraphNode child.
626626
$this->assertCount(1, $doc->children);
@@ -655,7 +655,7 @@ public function testParseDefaultEscapesRawHtmlBlock(): void
655655
// Output is bare escaped text with no <p> wrapper.
656656
$html = $this->parser->parse('<div>raw</div>');
657657

658-
$this->assertSame('&lt;div&gt;raw&lt;/div&gt;', $html);
658+
$this->assertSame("&lt;div&gt;raw&lt;/div&gt;\n", $html);
659659
}
660660

661661
public function testParseWithAllowRawHtmlPassesThroughBlock(): void
@@ -690,6 +690,6 @@ public function testParseBackwardCompatibilityDefaultParam(): void
690690
// Existing callers of parse($md) still work — no exception, returns string.
691691
$html = $this->parser->parse('hello');
692692

693-
$this->assertSame('<p>hello</p>', $html);
693+
$this->assertSame("<p>hello</p>\n", $html);
694694
}
695695
}
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
1-
<blockquote><p>This is a quote</p></blockquote><blockquote><p>Another quote</p></blockquote>
1+
<blockquote>
2+
<p>This is a quote</p>
3+
</blockquote>
4+
<blockquote>
5+
<p>Another quote</p>
6+
</blockquote>
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
<pre><code class="language-php">echo &#039;hello&#039;;</code></pre><p>Inline <code>code</code> here.</p>
1+
<pre><code class="language-php">echo &#039;hello&#039;;</code></pre>
2+
<p>Inline <code>code</code> here.</p>
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
<div class="grid grid-cols-2 gap-4"><div class="min-w-0"><p>&lt;script&gt;alert(1)&lt;/script&gt;</p></div><div class="min-w-0"><p>[xss](javascript:alert(1))</p></div></div>
1+
<div class="grid grid-cols-2 gap-4"><div class="min-w-0"><p>&lt;script&gt;alert(1)&lt;/script&gt;</p>
2+
</div><div class="min-w-0"><p>[xss](javascript:alert(1))</p>
3+
</div></div>
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
1-
<div class="grid grid-cols-2 gap-4"><div class="min-w-0"><h2>Left column</h2><p>Text with <strong>bold</strong> and <em>italic</em>.</p><ul><li>item one</li><li>item two</li></ul></div><div class="min-w-0"><h2>Right column</h2><p>A <a href="https://example.com">link</a> and <code>inline code</code>.</p></div></div>
1+
<div class="grid grid-cols-2 gap-4"><div class="min-w-0"><h2>Left column</h2>
2+
<p>Text with <strong>bold</strong> and <em>italic</em>.</p>
3+
<ul>
4+
<li>item one</li>
5+
<li>item two</li>
6+
</ul>
7+
</div><div class="min-w-0"><h2>Right column</h2>
8+
<p>A <a href="https://example.com">link</a> and <code>inline code</code>.</p>
9+
</div></div>
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
<p><strong>bold text</strong></p><p><em>italic text</em></p><p><strong>also bold</strong></p><p><em>also italic</em></p>
1+
<p><strong>bold text</strong></p>
2+
<p><em>italic text</em></p>
3+
<p><strong>also bold</strong></p>
4+
<p><em>also italic</em></p>

0 commit comments

Comments
 (0)