Skip to content

Commit 5a6043c

Browse files
committed
feat(parser): preserve soft line breaks as newlines in paragraphs
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
1 parent 51920a4 commit 5a6043c

6 files changed

Lines changed: 129 additions & 14 deletions

File tree

src/Node/Inline/SoftBreakNode.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMarkdown\Node\Inline;
6+
7+
use PhpMarkdown\Node\InlineNodeInterface;
8+
9+
final readonly class SoftBreakNode implements InlineNodeInterface
10+
{
11+
}

src/Parser/Parser.php

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
use PhpMarkdown\Node\Block\TableNode;
2626
use PhpMarkdown\Node\Block\TableRowNode;
2727
use PhpMarkdown\Node\Inline\HardBreakNode;
28-
use PhpMarkdown\Node\Inline\TextNode;
2928
use PhpMarkdown\Node\InlineNodeInterface;
3029

3130
/**
@@ -215,16 +214,27 @@ private function buildParagraphChildren(array $tokens): array
215214
{
216215
$result = [];
217216
$lastIdx = count($tokens) - 1;
217+
$group = [];
218218

219219
foreach ($tokens as $idx => $token) {
220-
$inlineNodes = $this->inlineParser->parse($token->content, $this->linkRefs, $this->footnoteDefs);
221-
array_push($result, ...$inlineNodes);
222-
223-
if ($idx < $lastIdx) {
224-
// Hard break on last token is stripped per CommonMark §6.7.
225-
$result[] = ($token->meta['hard_break'] ?? false) === true
226-
? new HardBreakNode()
227-
: new TextNode(' ');
220+
$content = preg_replace('/^(?:[ ]{1,3}|\t)/', '', $token->content) ?? $token->content;
221+
$isHardBreak = ($token->meta['hard_break'] ?? false) === true;
222+
$isLast = ($idx === $lastIdx);
223+
224+
if (!$isLast && !$isHardBreak) {
225+
$content = rtrim($content, ' ');
226+
}
227+
228+
$group[] = $content;
229+
230+
if ($isHardBreak || $isLast) {
231+
$nodes = $this->inlineParser->parse(implode("\n", $group), $this->linkRefs, $this->footnoteDefs);
232+
array_push($result, ...$nodes);
233+
$group = [];
234+
235+
if ($isHardBreak && !$isLast) {
236+
$result[] = new HardBreakNode();
237+
}
228238
}
229239
}
230240

src/Renderer/HtmlRenderer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use PhpMarkdown\Node\Inline\EmphasisNode;
2727
use PhpMarkdown\Node\Inline\FootnoteRefNode;
2828
use PhpMarkdown\Node\Inline\HardBreakNode;
29+
use PhpMarkdown\Node\Inline\SoftBreakNode;
2930
use PhpMarkdown\Node\Inline\HtmlEntityNode;
3031
use PhpMarkdown\Node\Inline\ImageNode;
3132
use PhpMarkdown\Node\Inline\LinkNode;
@@ -76,6 +77,7 @@ private function renderNode(NodeInterface $node): string
7677
? $this->sanitizer->sanitize($node->content) . "\n"
7778
: $this->esc($node->content) . "\n",
7879
$node instanceof HardBreakNode => "<br />\n",
80+
$node instanceof SoftBreakNode => "\n",
7981
// HTML entities pass through verbatim — validated by InlineParser, no esc() needed.
8082
$node instanceof HtmlEntityNode => $node->entity,
8183
$node instanceof TextNode => $this->esc($node->text),

tests/Integration/MarkdownParserTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -493,9 +493,9 @@ public function testBackslashLineBreakProducesBr(): void
493493

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

501501
public function testHardBreakInsideInlineContent(): void
@@ -560,9 +560,9 @@ public function testBlockquoteLineWithTrailingSpacesNoHardBreak(): void
560560

561561
public function testSoftBreakBetweenParagraphLinesUnchanged(): void
562562
{
563-
// Pre-existing soft-break behavior must not regress.
563+
// Soft-break renders as newline per CommonMark §6.7.
564564
$html = $this->parser->parse("line one\nline two\nline three");
565-
$this->assertSame("<p>line one line two line three</p>\n", $html);
565+
$this->assertSame("<p>line one\nline two\nline three</p>\n", $html);
566566
}
567567

568568
public function testBlankLineSeparatesParagraphs(): void

tests/Integration/fixtures/hard-line-breaks.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
bar</p>
33
<p>foo<br />
44
bar</p>
5-
<p>foo bar</p>
5+
<p>foo
6+
bar</p>
67
<p><strong>bold</strong><br />
78
text</p>
89
<p>a<br />
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMarkdown\Tests\Unit\Renderer;
6+
7+
use PhpMarkdown\MarkdownParser;
8+
use PhpMarkdown\Node\Block\DocumentNode;
9+
use PhpMarkdown\Node\Block\ParagraphNode;
10+
use PhpMarkdown\Node\Inline\SoftBreakNode;
11+
use PhpMarkdown\Renderer\HtmlRenderer;
12+
use PHPUnit\Framework\TestCase;
13+
14+
final class SoftLineBreakTest extends TestCase
15+
{
16+
private MarkdownParser $parser;
17+
18+
protected function setUp(): void
19+
{
20+
$this->parser = new MarkdownParser();
21+
}
22+
23+
private function parseAndRender(string $markdown): string
24+
{
25+
return $this->parser->parse($markdown);
26+
}
27+
28+
public function testSingleNewlineInParagraphPreservedAsSoftBreak(): void
29+
{
30+
$this->assertSame("<p>foo\nbaz</p>\n", $this->parseAndRender("foo\nbaz"));
31+
}
32+
33+
public function testTrailingSpacesBeforeNewlineStrippedNotHardBreak(): void
34+
{
35+
$this->assertSame("<p>foo<br />\nbaz</p>\n", $this->parseAndRender("foo \nbaz"));
36+
}
37+
38+
public function testSingleTrailingSpaceBeforeNewlineIsSoftBreak(): void
39+
{
40+
$this->assertSame("<p>foo\nbaz</p>\n", $this->parseAndRender("foo \nbaz"));
41+
}
42+
43+
public function testTwoLineParagraphPreservesInternalNewline(): void
44+
{
45+
$this->assertSame("<p>aaa\nbbb</p>\n<p>ccc\nddd</p>\n", $this->parseAndRender("aaa\nbbb\n\nccc\nddd"));
46+
}
47+
48+
public function testLeadingSpacesOnFirstLineStripped(): void
49+
{
50+
$this->assertSame("<p>aaa\nbbb</p>\n", $this->parseAndRender(" aaa\nbbb"));
51+
}
52+
53+
public function testLeadingSpacesOnContinuationLineStripped(): void
54+
{
55+
$this->assertSame("<p>aaa\nbbb</p>\n", $this->parseAndRender(" aaa\n bbb"));
56+
}
57+
58+
public function testEmphasisSpanningSoftLineBreak(): void
59+
{
60+
$this->assertSame("<p><em>foo\nbar</em></p>\n", $this->parseAndRender("*foo\nbar*"));
61+
}
62+
63+
public function testStrongEmphasisSpanningSoftLineBreak(): void
64+
{
65+
$this->assertSame("<p><strong>foo\nbar</strong></p>\n", $this->parseAndRender("**foo\nbar**"));
66+
}
67+
68+
public function testTabBeforeContinuationTextStripped(): void
69+
{
70+
$this->assertSame("<p>foo\nbaz</p>\n", $this->parseAndRender("foo\n\tbaz"));
71+
}
72+
73+
public function testSoftBreakNodeRendersAsNewlineNotSpace(): void
74+
{
75+
$renderer = new HtmlRenderer();
76+
$document = new DocumentNode([
77+
new ParagraphNode([new SoftBreakNode()]),
78+
]);
79+
$this->assertSame("<p>\n</p>\n", $renderer->render($document));
80+
}
81+
82+
public function testHardBreakWithTwoTrailingSpacesUnaffectedBySoftBreak(): void
83+
{
84+
$this->assertSame("<p>foo<br />\nbar</p>\n", $this->parseAndRender("foo \nbar"));
85+
}
86+
87+
public function testNoBreakNodeInsertedInSingleLineParagraph(): void
88+
{
89+
$this->assertSame("<p>foo bar</p>\n", $this->parseAndRender("foo bar"));
90+
}
91+
}

0 commit comments

Comments
 (0)