From 6a4a073c519e5ac04cb9da639191c3d6beb47271 Mon Sep 17 00:00:00 2001
From: Mark Scherer The limerick packs laughs anatomical But the good ones I've seen
`), leading whitespace is preserved, and a blank line separates stanzas (each becomes its own paragraph). Inline djot (emphasis, links, ...) still parses normally.
+
+```php
+use Djot\Extension\LineBlockDivExtension;
+
+$converter->addExtension(new LineBlockDivExtension());
+```
+
+**Input:**
+
+```djot
+::: |
+The limerick packs laughs anatomical
+ Into space that is quite economical.
+
+But the good ones I've seen
+ So seldom are clean
+:::
+```
+
+```html
+
+ Into space that is quite economical.
+ So seldom are clean
'));
+ $this->assertStringContainsString("Stanza one a
\nStanza one b", $html);
+ $this->assertStringContainsString("Stanza two a
\nStanza two b", $html);
+ }
+
+ public function testInlineMarkupStillParses(): void
+ {
+ $djot = "::: |\nA _em_ and a [link](https://example.com)\nplain\n:::";
+
+ $html = $this->converter()->convert($djot);
+
+ $this->assertStringContainsString('em', $html);
+ $this->assertStringContainsString('link', $html);
+ }
+
+ public function testPendingAttributesAttachToTheDiv(): void
+ {
+ $djot = "{#poem .verse}\n::: |\nLine one\nLine two\n:::";
+
+ $html = $this->converter()->convert($djot);
+
+ $this->assertStringContainsString('id="poem"', $html);
+ $this->assertStringContainsString('verse', $html);
+ $this->assertStringContainsString('line-block', $html);
+ }
+
+ public function testFencedCodeInsideIsNotTreatedAsClosingFence(): void
+ {
+ $djot = "::: |\nbefore\n```\n:::\nstill code\n```\nafter\n:::";
+
+ $html = $this->converter()->convert($djot);
+
+ $this->assertStringContainsString('
', $html); + $this->assertStringContainsString('', $html); + // Indentation relative to the line block survives the blockquote dedent. + $this->assertStringContainsString("Roses are red
\n Violets are blue", $html); + } + + public function testWorksInsideListItem(): void + { + $djot = "- item\n\n ::: |\n Line one\n Indented two\n :::"; + + $html = $this->converter()->convert($djot); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString(' ', $html); + $this->assertStringContainsString("Line one
\n Indented two", $html); + } + + public function testWorksInsideBlockquotedList(): void + { + $djot = "> - x\n>\n> ::: |\n> alpha\n> beta\n> :::"; + + $html = $this->converter()->convert($djot); + + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString(' ', $html); + $this->assertStringContainsString("alpha
\n beta", $html); + } + + public function testPlainDivWithRealClassIsUntouched(): void + { + $djot = "::: warning\nHello\n:::"; + + $html = $this->converter()->convert($djot); + + $this->assertStringContainsString('', $html); + $this->assertStringNotContainsString('line-block', $html); + } +} From a5cab8cb888bc174b01ccb5e5996ec5e1d255e7e Mon Sep 17 00:00:00 2001 From: Mark SchererDate: Fri, 12 Jun 2026 15:45:28 +0200 Subject: [PATCH 2/3] Preserve line-block indentation as non-breaking spaces Leading whitespace in a `::: |` block now survives the browser's whitespace collapsing without any CSS: it is emitted via the internal non-breaking-space placeholder (U+E000, a private-use sentinel that never collides with a literal U+00A0 in the author's text). The HTML escaper already renders it as ` `; the Markdown / plain-text / ANSI renderers now collapse it to an ordinary space as their final output step - which also fixes a pre-existing case where the escaped-space placeholder leaked into non-HTML output. Tabs expand to four-column stops. A `cssIndent` option keeps raw spaces instead for an HTML + CSS (`white-space: pre-wrap`) workflow. --- docs/extensions/index.md | 12 ++- src/Extension/LineBlockDivExtension.php | 58 ++++++++++++- src/Renderer/AnsiRenderer.php | 7 +- src/Renderer/HtmlRenderer.php | 2 + src/Renderer/MarkdownRenderer.php | 7 +- src/Renderer/PlainTextRenderer.php | 7 +- .../Extension/LineBlockDivExtensionTest.php | 81 +++++++++++++++++-- 7 files changed, 162 insertions(+), 12 deletions(-) diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 43dd1076..12c500fb 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -617,6 +617,14 @@ use Djot\Extension\LineBlockDivExtension; $converter->addExtension(new LineBlockDivExtension()); ``` +By default, leading whitespace on each line is emitted as non-breaking spaces - ` ` in HTML (so the indentation survives the browser's whitespace collapsing without any CSS) and ordinary spaces in the Markdown / plain-text / ANSI renderers. Tabs expand to four-column stops. Pass `cssIndent: true` to keep raw spaces instead and style `.line-block` with `white-space: pre-wrap` yourself: + +```php +$converter->addExtension(new LineBlockDivExtension(cssIndent: true)); +``` + +`cssIndent` targets HTML + CSS: the Markdown, plain-text, and ANSI renderers trim leading whitespace, so a first line's indentation is not preserved there. Use the default if you render to those formats. + **Input:** ```djot @@ -632,9 +640,9 @@ But the good ones I've seen ```html ``` diff --git a/src/Extension/LineBlockDivExtension.php b/src/Extension/LineBlockDivExtension.php index a453cddf..6fa4fd3c 100644 --- a/src/Extension/LineBlockDivExtension.php +++ b/src/Extension/LineBlockDivExtension.php @@ -8,6 +8,7 @@ use Djot\Node\Block\LineBlock; use Djot\Node\Block\Paragraph; use Djot\Node\Inline\HardBreak; +use Djot\Node\Inline\Text; use Djot\Node\Node; use Djot\Parser\Block\FencedBlockParser; use Djot\Parser\BlockParser; @@ -57,6 +58,21 @@ class LineBlockDivExtension implements ExtensionInterface */ protected const OPENER = '/^(:{3,})[ \t]*\|[ \t]*$/'; + /** + * @param bool $cssIndent By default leading whitespace on each line is + * emitted as non-breaking spaces (` ` in HTML, ordinary spaces in the + * other formats), so the indentation survives the browser's whitespace + * collapsing without any CSS, and renders faithfully in every format. Set + * this to true to keep raw spaces instead and rely on a + * `white-space: pre-wrap` rule on the `.line-block` div. This mode targets + * HTML + CSS: the Markdown / plain-text / ANSI renderers trim leading + * whitespace, so a first line's indentation is not preserved there - prefer + * the default if you render to those formats. + */ + public function __construct(protected bool $cssIndent = false) + { + } + public function register(DjotConverter $converter): void { $converter->getParser()->addBlockPattern(self::OPENER, $this->parseLineBlockDiv(...)); @@ -211,7 +227,22 @@ protected function buildStanza(BlockParser $blockParser, array $stanza, int $bas $last = count($stanza) - 1; $index = 0; foreach ($stanza as $line) { - $inlineParser->parse($paragraph, $line, $baseLine + $index); + $content = $line; + if (!$this->cssIndent) { + // Emit leading whitespace via the internal non-breaking-space + // placeholder (U+E000, the same sentinel the parser uses for an + // escaped space). The HTML renderer turns it into a entity + // so the indent survives whitespace collapsing; the other + // renderers turn it back into a normal space. A private-use + // character is used so it never collides with a literal U+00A0 in + // the author's own text. Tabs expand to four-column tab stops. + [$columns, $content] = $this->splitLeadingWhitespace($line); + if ($columns > 0) { + $paragraph->appendChild(new Text(str_repeat("\u{E000}", $columns))); + } + } + + $inlineParser->parse($paragraph, $content, $baseLine + $index); if ($index < $last) { $paragraph->appendChild(new HardBreak()); } @@ -220,4 +251,29 @@ protected function buildStanza(BlockParser $blockParser, array $stanza, int $bas return $paragraph; } + + /** + * Split a line into its leading-whitespace width (in columns, tabs expanded + * to four-column stops) and the remaining content. + * + * @return array{0: int, 1: string} + */ + protected function splitLeadingWhitespace(string $line): array + { + $column = 0; + $offset = 0; + $length = strlen($line); + while ($offset < $length) { + if ($line[$offset] === ' ') { + $column++; + } elseif ($line[$offset] === "\t") { + $column += 4 - ($column % 4); + } else { + break; + } + $offset++; + } + + return [$column, substr($line, $offset)]; + } } diff --git a/src/Renderer/AnsiRenderer.php b/src/Renderer/AnsiRenderer.php index 0013a98f..4c51b80c 100644 --- a/src/Renderer/AnsiRenderer.php +++ b/src/Renderer/AnsiRenderer.php @@ -371,7 +371,12 @@ public function render(Document $document): string // Normalize multiple blank lines $output = preg_replace("/\n{3,}/", "\n\n", $output) ?? $output; - return trim($output) . "\n"; + $output = trim($output) . "\n"; + + // The internal non-breaking-space placeholder (U+E000) collapses to an + // ordinary space in terminal output. Done as the final step, after + // trimming, so placeholder-derived leading indentation survives. + return str_replace("\u{E000}", ' ', $output); } protected function renderNode(Node $node): string diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 5524374e..81d6e5cf 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -809,6 +809,8 @@ protected function renderLineBlock(LineBlock $node): string $attrs = $this->getRenderableAttributes($node); $attrs = $this->mergeAttribute($attrs, 'class', 'line-block'); + // Leading-indent placeholders (U+E000) are turned into entities by + // the shared text escaper, so no extra handling is needed here. return 'The limerick packs laughs anatomical
+ Into space that is quite economical.
- Into space that is quite economical.But the good ones I've seen
+ So seldom are clean
- So seldom are cleanrenderAttributeArray($attrs) . ">\n" . $this->renderChildren($node) . "\n"; } diff --git a/src/Renderer/MarkdownRenderer.php b/src/Renderer/MarkdownRenderer.php index 9640dc01..9dfd4609 100644 --- a/src/Renderer/MarkdownRenderer.php +++ b/src/Renderer/MarkdownRenderer.php @@ -97,7 +97,12 @@ public function render(Document $document): string // Normalize multiple blank lines $markdown = preg_replace("/\n{3,}/", "\n\n", $markdown) ?? $markdown; - return trim($markdown) . "\n"; + $markdown = trim($markdown) . "\n"; + + // The internal non-breaking-space placeholder (U+E000) collapses to an + // ordinary space in Markdown. Done as the final step, after trimming, so + // placeholder-derived leading indentation (e.g. in a line block) survives. + return str_replace("\u{E000}", ' ', $markdown); } protected function renderNode(Node $node): string diff --git a/src/Renderer/PlainTextRenderer.php b/src/Renderer/PlainTextRenderer.php index f891baa6..450cfb74 100644 --- a/src/Renderer/PlainTextRenderer.php +++ b/src/Renderer/PlainTextRenderer.php @@ -92,7 +92,12 @@ public function render(Document $document): string // Normalize multiple blank lines to single $text = preg_replace("/\n{3,}/", "\n\n", $text) ?? $text; - return trim($text) . "\n"; + $text = trim($text) . "\n"; + + // The internal non-breaking-space placeholder (U+E000) collapses to an + // ordinary space in plain text. Done as the final step, after trimming, so + // placeholder-derived leading indentation (e.g. in a line block) survives. + return str_replace("\u{E000}", ' ', $text); } protected function renderNode(Node $node): string diff --git a/tests/TestCase/Extension/LineBlockDivExtensionTest.php b/tests/TestCase/Extension/LineBlockDivExtensionTest.php index 90182c8c..048c1e16 100644 --- a/tests/TestCase/Extension/LineBlockDivExtensionTest.php +++ b/tests/TestCase/Extension/LineBlockDivExtensionTest.php @@ -6,14 +6,28 @@ use Djot\DjotConverter; use Djot\Extension\LineBlockDivExtension; +use Djot\Renderer\MarkdownRenderer; +use Djot\Renderer\PlainTextRenderer; use PHPUnit\Framework\TestCase; class LineBlockDivExtensionTest extends TestCase { - private function converter(): DjotConverter + /** + * @var string + */ + private const NBSP = "\u{00A0}"; + + /** + * Internal non-breaking-space placeholder (private use area). + * + * @var string + */ + private const NBSP_PLACEHOLDER = "\u{E000}"; + + private function converter(bool $cssIndent = false): DjotConverter { $converter = new DjotConverter(); - $converter->addExtension(new LineBlockDivExtension()); + $converter->addExtension(new LineBlockDivExtension($cssIndent)); return $converter; } @@ -38,13 +52,68 @@ public function testSoftBreaksBecomeHardBreaks(): void $this->assertStringContainsString("Line one
\nLine two", $html); } - public function testLeadingWhitespaceIsPreserved(): void + public function testLeadingWhitespaceIsPreservedAsNonBreakingSpaces(): void { $djot = "::: |\nFlush left\n Indented two\n:::"; $html = $this->converter()->convert($djot); + // Leading spaces become non-breaking spaces so the indent survives the + // browser's whitespace collapsing. + $this->assertStringContainsString("Flush left
\n Indented two", $html); + } + + public function testCssIndentOptionKeepsRawSpaces(): void + { + $djot = "::: |\nFlush left\n Indented two\n:::"; + + $html = $this->converter(cssIndent: true)->convert($djot); + $this->assertStringContainsString("Flush left
\n Indented two", $html); + $this->assertStringNotContainsString(' ', $html); + $this->assertStringNotContainsString(self::NBSP_PLACEHOLDER, $html); + } + + public function testNonHtmlOutputUsesRegularSpaces(): void + { + // The nbsp placeholder is an HTML-only concern; plain text gets ordinary + // spaces, so the indentation is still visible without an invisible + // placeholder leaking into the output. + $document = $this->converter()->parse("::: |\nLine one\n Indented two\n:::"); + $text = (new PlainTextRenderer())->render($document); + + $this->assertStringContainsString("Line one\n Indented two", $text); + $this->assertStringNotContainsString(self::NBSP_PLACEHOLDER, $text); + } + + public function testMarkdownPreservesIndentedFirstLine(): void + { + // The placeholder must survive the renderer's trimming so an indented + // first line keeps its indentation in Markdown too. + $document = $this->converter()->parse("::: |\n first\n second\n:::"); + $markdown = (new MarkdownRenderer())->render($document); + $firstLine = explode("\n", $markdown)[0]; + + $this->assertSame(' first', rtrim($firstLine)); + } + + public function testLiteralNonBreakingSpaceInContentIsPreserved(): void + { + // A real U+00A0 the author typed in the verse must survive in plain text + // (the placeholder uses a private-use char, so it is not clobbered). + $document = $this->converter()->parse("::: |\nice" . self::NBSP . "cream\n:::"); + $text = (new PlainTextRenderer())->render($document); + + $this->assertStringContainsString('ice' . self::NBSP . 'cream', $text); + } + + public function testTabIndentExpandsToFourColumns(): void + { + $djot = "::: |\nflush\n\ttabbed\n:::"; + + $html = $this->converter()->convert($djot); + + $this->assertStringContainsString("flush
\n" . str_repeat(' ', 4) . 'tabbed', $html); } public function testBlankLineSeparatesStanzas(): void @@ -135,7 +204,7 @@ public function testWorksInsideBlockquote(): void $this->assertStringContainsString('', $html); $this->assertStringContainsString('', $html); // Indentation relative to the line block survives the blockquote dedent. - $this->assertStringContainsString("Roses are red
\n Violets are blue", $html); + $this->assertStringContainsString("Roses are red
\n Violets are blue", $html); } public function testWorksInsideListItem(): void @@ -146,7 +215,7 @@ public function testWorksInsideListItem(): void $this->assertStringContainsString('', $html); $this->assertStringContainsString(' ', $html); - $this->assertStringContainsString("Line one
\n Indented two", $html); + $this->assertStringContainsString("Line one
\n Indented two", $html); } public function testWorksInsideBlockquotedList(): void @@ -158,7 +227,7 @@ public function testWorksInsideBlockquotedList(): void $this->assertStringContainsString('', $html); $this->assertStringContainsString('', $html); $this->assertStringContainsString(' ', $html); - $this->assertStringContainsString("alpha
\n beta", $html); + $this->assertStringContainsString("alpha
\n beta", $html); } public function testPlainDivWithRealClassIsUntouched(): void From 7f6989024848573d18493035d4c3b214f3fd2876 Mon Sep 17 00:00:00 2001 From: Mark SchererDate: Fri, 12 Jun 2026 16:09:36 +0200 Subject: [PATCH 3/3] Simplify line-block indentation handling Drop the `cssIndent` option: the default placeholder now preserves indentation faithfully in every format, so the raw-spaces-for-CSS mode only added a caveat (and a non-HTML-only variant would need a fragile render hook). A custom extension can cover that niche if anyone needs it. Markdown now keeps the indent as a real non-breaking space (U+00A0) rather than an ordinary space. Markdown is a re-parseable round-trip format, so the nbsp survives a re-render as ` ` and is never mistaken for an indented code block. Plain-text and ANSI stay on an ordinary space. --- docs/extensions/index.md | 8 +--- src/Extension/LineBlockDivExtension.php | 38 +++++-------------- src/Renderer/MarkdownRenderer.php | 12 ++++-- .../Extension/LineBlockDivExtensionTest.php | 25 ++++-------- 4 files changed, 27 insertions(+), 56 deletions(-) diff --git a/docs/extensions/index.md b/docs/extensions/index.md index 12c500fb..40214460 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -617,13 +617,7 @@ use Djot\Extension\LineBlockDivExtension; $converter->addExtension(new LineBlockDivExtension()); ``` -By default, leading whitespace on each line is emitted as non-breaking spaces - ` ` in HTML (so the indentation survives the browser's whitespace collapsing without any CSS) and ordinary spaces in the Markdown / plain-text / ANSI renderers. Tabs expand to four-column stops. Pass `cssIndent: true` to keep raw spaces instead and style `.line-block` with `white-space: pre-wrap` yourself: - -```php -$converter->addExtension(new LineBlockDivExtension(cssIndent: true)); -``` - -`cssIndent` targets HTML + CSS: the Markdown, plain-text, and ANSI renderers trim leading whitespace, so a first line's indentation is not preserved there. Use the default if you render to those formats. +Leading whitespace on each line is preserved as a non-breaking space, so the indentation survives without any CSS: ` ` in HTML, a real non-breaking space (`U+00A0`) in Markdown - which keeps it through a round-trip re-render and never trips Markdown's indented-code-block rule - and an ordinary space in the plain-text and ANSI renderers. Tabs expand to four-column stops. **Input:** diff --git a/src/Extension/LineBlockDivExtension.php b/src/Extension/LineBlockDivExtension.php index 6fa4fd3c..ac3157cb 100644 --- a/src/Extension/LineBlockDivExtension.php +++ b/src/Extension/LineBlockDivExtension.php @@ -58,21 +58,6 @@ class LineBlockDivExtension implements ExtensionInterface */ protected const OPENER = '/^(:{3,})[ \t]*\|[ \t]*$/'; - /** - * @param bool $cssIndent By default leading whitespace on each line is - * emitted as non-breaking spaces (` ` in HTML, ordinary spaces in the - * other formats), so the indentation survives the browser's whitespace - * collapsing without any CSS, and renders faithfully in every format. Set - * this to true to keep raw spaces instead and rely on a - * `white-space: pre-wrap` rule on the `.line-block` div. This mode targets - * HTML + CSS: the Markdown / plain-text / ANSI renderers trim leading - * whitespace, so a first line's indentation is not preserved there - prefer - * the default if you render to those formats. - */ - public function __construct(protected bool $cssIndent = false) - { - } - public function register(DjotConverter $converter): void { $converter->getParser()->addBlockPattern(self::OPENER, $this->parseLineBlockDiv(...)); @@ -227,19 +212,16 @@ protected function buildStanza(BlockParser $blockParser, array $stanza, int $bas $last = count($stanza) - 1; $index = 0; foreach ($stanza as $line) { - $content = $line; - if (!$this->cssIndent) { - // Emit leading whitespace via the internal non-breaking-space - // placeholder (U+E000, the same sentinel the parser uses for an - // escaped space). The HTML renderer turns it into a entity - // so the indent survives whitespace collapsing; the other - // renderers turn it back into a normal space. A private-use - // character is used so it never collides with a literal U+00A0 in - // the author's own text. Tabs expand to four-column tab stops. - [$columns, $content] = $this->splitLeadingWhitespace($line); - if ($columns > 0) { - $paragraph->appendChild(new Text(str_repeat("\u{E000}", $columns))); - } + // Emit leading whitespace via the internal non-breaking-space + // placeholder (U+E000, the same sentinel the parser uses for an + // escaped space). The HTML renderer turns it into a entity so + // the indent survives whitespace collapsing; Markdown keeps a real + // non-breaking space (U+00A0); plain text and ANSI use a normal space. + // A private-use character is used so it never collides with a literal + // U+00A0 in the author's own text. Tabs expand to four-column stops. + [$columns, $content] = $this->splitLeadingWhitespace($line); + if ($columns > 0) { + $paragraph->appendChild(new Text(str_repeat("\u{E000}", $columns))); } $inlineParser->parse($paragraph, $content, $baseLine + $index); diff --git a/src/Renderer/MarkdownRenderer.php b/src/Renderer/MarkdownRenderer.php index 9dfd4609..8c7cd940 100644 --- a/src/Renderer/MarkdownRenderer.php +++ b/src/Renderer/MarkdownRenderer.php @@ -99,10 +99,14 @@ public function render(Document $document): string $markdown = trim($markdown) . "\n"; - // The internal non-breaking-space placeholder (U+E000) collapses to an - // ordinary space in Markdown. Done as the final step, after trimming, so - // placeholder-derived leading indentation (e.g. in a line block) survives. - return str_replace("\u{E000}", ' ', $markdown); + // The internal non-breaking-space placeholder (U+E000) becomes a literal + // non-breaking space (U+00A0). Markdown is a re-parseable round-trip + // format, so unlike the display renderers it keeps the real nbsp: that + // survives a re-render as ` ` and is never mistaken for a Markdown + // indented-code-block prefix the way ordinary leading spaces would be. + // Done as the final step, after trimming, so placeholder-derived leading + // indentation (e.g. in a line block) survives. + return str_replace("\u{E000}", "\u{00A0}", $markdown); } protected function renderNode(Node $node): string diff --git a/tests/TestCase/Extension/LineBlockDivExtensionTest.php b/tests/TestCase/Extension/LineBlockDivExtensionTest.php index 048c1e16..dcb04d66 100644 --- a/tests/TestCase/Extension/LineBlockDivExtensionTest.php +++ b/tests/TestCase/Extension/LineBlockDivExtensionTest.php @@ -24,10 +24,10 @@ class LineBlockDivExtensionTest extends TestCase */ private const NBSP_PLACEHOLDER = "\u{E000}"; - private function converter(bool $cssIndent = false): DjotConverter + private function converter(): DjotConverter { $converter = new DjotConverter(); - $converter->addExtension(new LineBlockDivExtension($cssIndent)); + $converter->addExtension(new LineBlockDivExtension()); return $converter; } @@ -63,17 +63,6 @@ public function testLeadingWhitespaceIsPreservedAsNonBreakingSpaces(): void $this->assertStringContainsString("Flush left
\n Indented two", $html); } - public function testCssIndentOptionKeepsRawSpaces(): void - { - $djot = "::: |\nFlush left\n Indented two\n:::"; - - $html = $this->converter(cssIndent: true)->convert($djot); - - $this->assertStringContainsString("Flush left
\n Indented two", $html); - $this->assertStringNotContainsString(' ', $html); - $this->assertStringNotContainsString(self::NBSP_PLACEHOLDER, $html); - } - public function testNonHtmlOutputUsesRegularSpaces(): void { // The nbsp placeholder is an HTML-only concern; plain text gets ordinary @@ -86,15 +75,17 @@ public function testNonHtmlOutputUsesRegularSpaces(): void $this->assertStringNotContainsString(self::NBSP_PLACEHOLDER, $text); } - public function testMarkdownPreservesIndentedFirstLine(): void + public function testMarkdownPreservesIndentedFirstLineAsNonBreakingSpaces(): void { - // The placeholder must survive the renderer's trimming so an indented - // first line keeps its indentation in Markdown too. + // Markdown is a re-parseable round-trip format, so the indent is kept as + // real non-breaking spaces (U+00A0): they survive trimming, survive a + // re-render as , and are never mistaken for an indented code block. $document = $this->converter()->parse("::: |\n first\n second\n:::"); $markdown = (new MarkdownRenderer())->render($document); $firstLine = explode("\n", $markdown)[0]; - $this->assertSame(' first', rtrim($firstLine)); + $this->assertSame(self::NBSP . self::NBSP . 'first', rtrim($firstLine)); + $this->assertStringNotContainsString(self::NBSP_PLACEHOLDER, $markdown); } public function testLiteralNonBreakingSpaceInContentIsPreserved(): void