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
31 changes: 17 additions & 14 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,20 @@ private function renderNode(NodeInterface $node): string
{
return match (true) {
$node instanceof HeadingNode => $this->renderHeading($node),
$node instanceof ParagraphNode => '<p>' . $this->renderChildren($node->children) . '</p>',
$node instanceof BlockquoteNode => '<blockquote>' . $this->renderChildren($node->children) . '</blockquote>',
$node instanceof ParagraphNode => '<p>' . $this->renderChildren($node->children) . "</p>\n",
$node instanceof BlockquoteNode => "<blockquote>\n" . $this->renderChildren($node->children) . "</blockquote>\n",
$node instanceof ListNode => $this->renderList($node),
$node instanceof ListItemNode => $this->renderListItem($node),
$node instanceof FencedCodeNode => $this->renderFencedCode($node),
$node instanceof IndentedCodeNode => '<pre><code>' . $this->esc($node->content) . '</code></pre>',
$node instanceof HorizontalRuleNode => '<hr>',
$node instanceof IndentedCodeNode => '<pre><code>' . $this->esc($node->content) . "</code></pre>\n",
$node instanceof HorizontalRuleNode => "<hr />\n",
$node instanceof ColumnsNode => $this->renderColumns($node),
// Raw HTML blocks: sanitize if allowRawHtml, else escape (XSS-safe default).
$node instanceof RawHtmlBlockNode =>
$this->allowRawHtml
? $this->sanitizer->sanitize($node->content)
: $this->esc($node->content),
$node instanceof HardBreakNode => '<br>',
? $this->sanitizer->sanitize($node->content) . "\n"
: $this->esc($node->content) . "\n",
$node instanceof HardBreakNode => "<br />\n",
// HTML entities pass through verbatim — validated by InlineParser, no esc() needed.
$node instanceof HtmlEntityNode => $node->entity,
$node instanceof TextNode => $this->esc($node->text),
Expand Down Expand Up @@ -112,7 +112,7 @@ private function renderChildren(array $nodes): string
private function renderHeading(HeadingNode $node): string
{
$tag = 'h' . $node->level;
return '<' . $tag . '>' . $this->renderChildren($node->children) . '</' . $tag . '>';
return '<' . $tag . '>' . $this->renderChildren($node->children) . '</' . $tag . ">\n";
}

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

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

if ($node->checked === null) {
return '<li>' . $content . '</li>';
if ($loose) {
return "<li>\n" . $content . "</li>\n";
}
return '<li>' . $content . "</li>\n";
}
$checkbox = '<input type="checkbox" disabled' . ($node->checked ? ' checked' : '') . '>';
return '<li>' . $checkbox . ' ' . $content . '</li>';
return '<li>' . $checkbox . ' ' . $content . "</li>\n";
}

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

$html = '';
if ($inlinePart !== []) {
$html .= '<p>' . $this->renderChildren($inlinePart) . '</p>';
$html .= '<p>' . $this->renderChildren($inlinePart) . "</p>\n";
}
foreach ($blockPart as $block) {
$html .= $this->renderNode($block);
Expand All @@ -187,7 +190,7 @@ private function renderFencedCode(FencedCodeNode $node): string
$classAttr = ($lang ?? '') !== ''
? ' class="' . $this->esc('language-' . (string) $lang) . '"'
: '';
return '<pre><code' . $classAttr . '>' . $this->esc($node->content) . '</code></pre>';
return '<pre><code' . $classAttr . '>' . $this->esc($node->content) . "</code></pre>\n";
}

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

private function renderAutolink(AutolinkNode $node): string
Expand Down
60 changes: 30 additions & 30 deletions tests/Integration/MarkdownParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public function testFullDocumentPipeline(): void
$this->assertStringContainsString('<strong>bold</strong>', $html);
$this->assertMatchesRegularExpression('/<ul>\s*<li>item<\/li>\s*<\/ul>/', $html);
$this->assertStringContainsString('<blockquote>', $html);
$this->assertStringContainsString('<hr>', $html);
$this->assertStringContainsString('<hr />', $html);
}

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

$this->assertSame('<ul><li>a<ul><li>b</li><li>c</li></ul></li><li>d</li></ul>', $html);
$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);
}

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

$this->assertSame('<ul><li>a<ul><li>b<ul><li>c</li></ul></li></ul></li></ul>', $html);
$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);
}

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

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

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

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

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

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

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

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

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

public function testDepthCapAt32DoesNotCrashAndProducesTwoLists(): void
Expand Down Expand Up @@ -325,7 +325,7 @@ public function testNestedBlockquoteMixedLevels(): void
$html = $this->parser->parse($md);

$this->assertSame(
'<blockquote><p>outer</p><blockquote><p>inner</p></blockquote><p>outer again</p></blockquote>',
"<blockquote>\n<p>outer</p>\n<blockquote>\n<p>inner</p>\n</blockquote>\n<p>outer again</p>\n</blockquote>\n",
$html,
);
}
Expand All @@ -336,7 +336,7 @@ public function testThreeLevelBlockquote(): void
$html = $this->parser->parse($md);

$this->assertSame(
'<blockquote><blockquote><blockquote><p>triple</p></blockquote></blockquote></blockquote>',
"<blockquote>\n<blockquote>\n<blockquote>\n<p>triple</p>\n</blockquote>\n</blockquote>\n</blockquote>\n",
$html,
);
}
Expand All @@ -347,7 +347,7 @@ public function testLevelDecreaseThenIncrease(): void
$html = $this->parser->parse($md);

$this->assertSame(
'<blockquote><p>a</p><blockquote><p>b</p></blockquote><p>c</p><blockquote><p>d</p></blockquote></blockquote>',
"<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",
$html,
);
}
Expand Down Expand Up @@ -379,7 +379,7 @@ public function testMultiLineSameLevelJoinedWithSpace(): void
$md = "> line one\n> line two";
$html = $this->parser->parse($md);

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

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

$this->assertSame(
'<blockquote><p>a</p><blockquote><blockquote><p>c</p></blockquote></blockquote></blockquote>',
"<blockquote>\n<p>a</p>\n<blockquote>\n<blockquote>\n<p>c</p>\n</blockquote>\n</blockquote>\n</blockquote>\n",
$html,
);
}
Expand All @@ -401,7 +401,7 @@ public function testBlockquoteAdjacentToParagraph(): void
$html = $this->parser->parse($md);

$this->assertSame(
'<blockquote><p>quoted</p></blockquote><p>plain paragraph</p>',
"<blockquote>\n<p>quoted</p>\n</blockquote>\n<p>plain paragraph</p>\n",
$html,
);
}
Expand All @@ -416,7 +416,7 @@ public function testBlankLineBetweenBlockquoteLevelsSeparatesNodes(): void

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

$this->assertSame('<p>hello</p>', $html);
$this->assertSame("<p>hello</p>\n", $html);
}
}
7 changes: 6 additions & 1 deletion tests/Integration/fixtures/blockquotes.html
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
<blockquote><p>This is a quote</p></blockquote><blockquote><p>Another quote</p></blockquote>
<blockquote>
<p>This is a quote</p>
</blockquote>
<blockquote>
<p>Another quote</p>
</blockquote>
3 changes: 2 additions & 1 deletion tests/Integration/fixtures/code-blocks.html
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
<pre><code class="language-php">echo &#039;hello&#039;;</code></pre><p>Inline <code>code</code> here.</p>
<pre><code class="language-php">echo &#039;hello&#039;;</code></pre>
<p>Inline <code>code</code> here.</p>
4 changes: 3 additions & 1 deletion tests/Integration/fixtures/columns-xss.html
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
<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>
<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>
10 changes: 9 additions & 1 deletion tests/Integration/fixtures/columns.html
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
<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>
<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>
5 changes: 4 additions & 1 deletion tests/Integration/fixtures/emphasis.html
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
<p><strong>bold text</strong></p><p><em>italic text</em></p><p><strong>also bold</strong></p><p><em>also italic</em></p>
<p><strong>bold text</strong></p>
<p><em>italic text</em></p>
<p><strong>also bold</strong></p>
<p><em>also italic</em></p>
Loading
Loading