From 5a6043cfcefa0b62fe8307ce40bdf26dfa2d52be Mon Sep 17 00:00:00 2001 From: jmcollin Date: Sun, 31 May 2026 18:45:04 +0200 Subject: [PATCH] feat(parser): preserve soft line breaks as newlines in paragraphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group soft-break tokens before inline parsing so delimiters like *foo\nbar* resolve correctly across line boundaries. Emit \n verbatim instead of space. - SoftBreakNode: new node type (direct AST construction support) - buildParagraphChildren(): join soft-break lines into one InlineParser call - HtmlRenderer: add SoftBreakNode arm → "\n" - Strip leading spaces (≤3 or tab) and trailing spaces from paragraph lines --- src/Node/Inline/SoftBreakNode.php | 11 +++ src/Parser/Parser.php | 28 ++++-- src/Renderer/HtmlRenderer.php | 2 + tests/Integration/MarkdownParserTest.php | 8 +- .../fixtures/hard-line-breaks.html | 3 +- tests/Unit/Renderer/SoftLineBreakTest.php | 91 +++++++++++++++++++ 6 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 src/Node/Inline/SoftBreakNode.php create mode 100644 tests/Unit/Renderer/SoftLineBreakTest.php diff --git a/src/Node/Inline/SoftBreakNode.php b/src/Node/Inline/SoftBreakNode.php new file mode 100644 index 0000000..798bffa --- /dev/null +++ b/src/Node/Inline/SoftBreakNode.php @@ -0,0 +1,11 @@ + $token) { - $inlineNodes = $this->inlineParser->parse($token->content, $this->linkRefs, $this->footnoteDefs); - array_push($result, ...$inlineNodes); - - if ($idx < $lastIdx) { - // Hard break on last token is stripped per CommonMark §6.7. - $result[] = ($token->meta['hard_break'] ?? false) === true - ? new HardBreakNode() - : new TextNode(' '); + $content = preg_replace('/^(?:[ ]{1,3}|\t)/', '', $token->content) ?? $token->content; + $isHardBreak = ($token->meta['hard_break'] ?? false) === true; + $isLast = ($idx === $lastIdx); + + if (!$isLast && !$isHardBreak) { + $content = rtrim($content, ' '); + } + + $group[] = $content; + + if ($isHardBreak || $isLast) { + $nodes = $this->inlineParser->parse(implode("\n", $group), $this->linkRefs, $this->footnoteDefs); + array_push($result, ...$nodes); + $group = []; + + if ($isHardBreak && !$isLast) { + $result[] = new HardBreakNode(); + } } } diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index aa832bb..21309f0 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -26,6 +26,7 @@ use PhpMarkdown\Node\Inline\EmphasisNode; use PhpMarkdown\Node\Inline\FootnoteRefNode; use PhpMarkdown\Node\Inline\HardBreakNode; +use PhpMarkdown\Node\Inline\SoftBreakNode; use PhpMarkdown\Node\Inline\HtmlEntityNode; use PhpMarkdown\Node\Inline\ImageNode; use PhpMarkdown\Node\Inline\LinkNode; @@ -76,6 +77,7 @@ private function renderNode(NodeInterface $node): string ? $this->sanitizer->sanitize($node->content) . "\n" : $this->esc($node->content) . "\n", $node instanceof HardBreakNode => "
\n", + $node instanceof SoftBreakNode => "\n", // HTML entities pass through verbatim — validated by InlineParser, no esc() needed. $node instanceof HtmlEntityNode => $node->entity, $node instanceof TextNode => $this->esc($node->text), diff --git a/tests/Integration/MarkdownParserTest.php b/tests/Integration/MarkdownParserTest.php index 8070614..a4e7f4c 100644 --- a/tests/Integration/MarkdownParserTest.php +++ b/tests/Integration/MarkdownParserTest.php @@ -493,9 +493,9 @@ public function testBackslashLineBreakProducesBr(): void public function testNoTrailingSpacesProducesSoftBreak(): void { - // Without trailing spaces the lines are soft-joined with a space. + // Without trailing spaces the lines are soft-joined with a newline (CommonMark §6.7). $html = $this->parser->parse("foo\nbar"); - $this->assertSame("

foo bar

\n", $html); + $this->assertSame("

foo\nbar

\n", $html); } public function testHardBreakInsideInlineContent(): void @@ -560,9 +560,9 @@ public function testBlockquoteLineWithTrailingSpacesNoHardBreak(): void public function testSoftBreakBetweenParagraphLinesUnchanged(): void { - // Pre-existing soft-break behavior must not regress. + // Soft-break renders as newline per CommonMark §6.7. $html = $this->parser->parse("line one\nline two\nline three"); - $this->assertSame("

line one line two line three

\n", $html); + $this->assertSame("

line one\nline two\nline three

\n", $html); } public function testBlankLineSeparatesParagraphs(): void diff --git a/tests/Integration/fixtures/hard-line-breaks.html b/tests/Integration/fixtures/hard-line-breaks.html index 2c6fa4c..8dcefa8 100644 --- a/tests/Integration/fixtures/hard-line-breaks.html +++ b/tests/Integration/fixtures/hard-line-breaks.html @@ -2,7 +2,8 @@ bar

foo
bar

-

foo bar

+

foo +bar

bold
text

a
diff --git a/tests/Unit/Renderer/SoftLineBreakTest.php b/tests/Unit/Renderer/SoftLineBreakTest.php new file mode 100644 index 0000000..6351338 --- /dev/null +++ b/tests/Unit/Renderer/SoftLineBreakTest.php @@ -0,0 +1,91 @@ +parser = new MarkdownParser(); + } + + private function parseAndRender(string $markdown): string + { + return $this->parser->parse($markdown); + } + + public function testSingleNewlineInParagraphPreservedAsSoftBreak(): void + { + $this->assertSame("

foo\nbaz

\n", $this->parseAndRender("foo\nbaz")); + } + + public function testTrailingSpacesBeforeNewlineStrippedNotHardBreak(): void + { + $this->assertSame("

foo
\nbaz

\n", $this->parseAndRender("foo \nbaz")); + } + + public function testSingleTrailingSpaceBeforeNewlineIsSoftBreak(): void + { + $this->assertSame("

foo\nbaz

\n", $this->parseAndRender("foo \nbaz")); + } + + public function testTwoLineParagraphPreservesInternalNewline(): void + { + $this->assertSame("

aaa\nbbb

\n

ccc\nddd

\n", $this->parseAndRender("aaa\nbbb\n\nccc\nddd")); + } + + public function testLeadingSpacesOnFirstLineStripped(): void + { + $this->assertSame("

aaa\nbbb

\n", $this->parseAndRender(" aaa\nbbb")); + } + + public function testLeadingSpacesOnContinuationLineStripped(): void + { + $this->assertSame("

aaa\nbbb

\n", $this->parseAndRender(" aaa\n bbb")); + } + + public function testEmphasisSpanningSoftLineBreak(): void + { + $this->assertSame("

foo\nbar

\n", $this->parseAndRender("*foo\nbar*")); + } + + public function testStrongEmphasisSpanningSoftLineBreak(): void + { + $this->assertSame("

foo\nbar

\n", $this->parseAndRender("**foo\nbar**")); + } + + public function testTabBeforeContinuationTextStripped(): void + { + $this->assertSame("

foo\nbaz

\n", $this->parseAndRender("foo\n\tbaz")); + } + + public function testSoftBreakNodeRendersAsNewlineNotSpace(): void + { + $renderer = new HtmlRenderer(); + $document = new DocumentNode([ + new ParagraphNode([new SoftBreakNode()]), + ]); + $this->assertSame("

\n

\n", $renderer->render($document)); + } + + public function testHardBreakWithTwoTrailingSpacesUnaffectedBySoftBreak(): void + { + $this->assertSame("

foo
\nbar

\n", $this->parseAndRender("foo \nbar")); + } + + public function testNoBreakNodeInsertedInSingleLineParagraph(): void + { + $this->assertSame("

foo bar

\n", $this->parseAndRender("foo bar")); + } +}