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
\nccc\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"));
+ }
+}