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
11 changes: 11 additions & 0 deletions src/Node/Inline/SoftBreakNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace PhpMarkdown\Node\Inline;

use PhpMarkdown\Node\InlineNodeInterface;

final readonly class SoftBreakNode implements InlineNodeInterface
{
}
28 changes: 19 additions & 9 deletions src/Parser/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
use PhpMarkdown\Node\Block\TableNode;
use PhpMarkdown\Node\Block\TableRowNode;
use PhpMarkdown\Node\Inline\HardBreakNode;
use PhpMarkdown\Node\Inline\TextNode;
use PhpMarkdown\Node\InlineNodeInterface;

/**
Expand Down Expand Up @@ -215,16 +214,27 @@ private function buildParagraphChildren(array $tokens): array
{
$result = [];
$lastIdx = count($tokens) - 1;
$group = [];

foreach ($tokens as $idx => $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();
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +77,7 @@ private function renderNode(NodeInterface $node): string
? $this->sanitizer->sanitize($node->content) . "\n"
: $this->esc($node->content) . "\n",
$node instanceof HardBreakNode => "<br />\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),
Expand Down
8 changes: 4 additions & 4 deletions tests/Integration/MarkdownParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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("<p>foo bar</p>\n", $html);
$this->assertSame("<p>foo\nbar</p>\n", $html);
}

public function testHardBreakInsideInlineContent(): void
Expand Down Expand Up @@ -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("<p>line one line two line three</p>\n", $html);
$this->assertSame("<p>line one\nline two\nline three</p>\n", $html);
}

public function testBlankLineSeparatesParagraphs(): void
Expand Down
3 changes: 2 additions & 1 deletion tests/Integration/fixtures/hard-line-breaks.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
bar</p>
<p>foo<br />
bar</p>
<p>foo bar</p>
<p>foo
bar</p>
<p><strong>bold</strong><br />
text</p>
<p>a<br />
Expand Down
91 changes: 91 additions & 0 deletions tests/Unit/Renderer/SoftLineBreakTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace PhpMarkdown\Tests\Unit\Renderer;

use PhpMarkdown\MarkdownParser;
use PhpMarkdown\Node\Block\DocumentNode;
use PhpMarkdown\Node\Block\ParagraphNode;
use PhpMarkdown\Node\Inline\SoftBreakNode;
use PhpMarkdown\Renderer\HtmlRenderer;
use PHPUnit\Framework\TestCase;

final class SoftLineBreakTest extends TestCase
{
private MarkdownParser $parser;

protected function setUp(): void
{
$this->parser = new MarkdownParser();
}

private function parseAndRender(string $markdown): string
{
return $this->parser->parse($markdown);
}

public function testSingleNewlineInParagraphPreservedAsSoftBreak(): void
{
$this->assertSame("<p>foo\nbaz</p>\n", $this->parseAndRender("foo\nbaz"));
}

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

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

public function testTwoLineParagraphPreservesInternalNewline(): void
{
$this->assertSame("<p>aaa\nbbb</p>\n<p>ccc\nddd</p>\n", $this->parseAndRender("aaa\nbbb\n\nccc\nddd"));
}

public function testLeadingSpacesOnFirstLineStripped(): void
{
$this->assertSame("<p>aaa\nbbb</p>\n", $this->parseAndRender(" aaa\nbbb"));
}

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

public function testEmphasisSpanningSoftLineBreak(): void
{
$this->assertSame("<p><em>foo\nbar</em></p>\n", $this->parseAndRender("*foo\nbar*"));
}

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

public function testTabBeforeContinuationTextStripped(): void
{
$this->assertSame("<p>foo\nbaz</p>\n", $this->parseAndRender("foo\n\tbaz"));
}

public function testSoftBreakNodeRendersAsNewlineNotSpace(): void
{
$renderer = new HtmlRenderer();
$document = new DocumentNode([
new ParagraphNode([new SoftBreakNode()]),
]);
$this->assertSame("<p>\n</p>\n", $renderer->render($document));
}

public function testHardBreakWithTwoTrailingSpacesUnaffectedBySoftBreak(): void
{
$this->assertSame("<p>foo<br />\nbar</p>\n", $this->parseAndRender("foo \nbar"));
}

public function testNoBreakNodeInsertedInSingleLineParagraph(): void
{
$this->assertSame("<p>foo bar</p>\n", $this->parseAndRender("foo bar"));
}
}
Loading