From b545495c7f67182cebdfbee4075340ad698ce9d7 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 6 Jun 2026 23:52:46 +0200 Subject: [PATCH 1/7] List continuation marker: attach a block to a list item with `+` A lone `+` at the list marker column attaches the following block to the current item with no blank line, keeping the list tight, without indenting the block body (AsciiDoc-style continuation). Useful for code blocks, tables, admonitions and quotes inside tight items -- especially deeply nested or wide-marker lists where indentation is large or paste-hostile. A bare `+` is never a bullet (a bullet needs `+ ` + content), so it does not collide with `+`-bulleted lists; outside a list it stays literal paragraph text. - BlockParser: the item-content collector stops at a bare `+`, and the list loop then attaches the following flush-left block(s) to the last item and keeps the list tight. - Docs: new "List Continuation Marker" section in guide/syntax.md, flagged as a djot-php addition (not canonical djot). - Tests: ListContinuationMarkerTest (attach code/quote, no-loosen, bullet/outside non-collision). Full suite + official suite green. This is authoring sugar, not new structure -- the same result is reachable by indenting the block under the item. --- docs/guide/syntax.md | 57 +++++++++++++++++ src/Parser/BlockParser.php | 51 +++++++++++++++ tests/TestCase/ListContinuationMarkerTest.php | 62 +++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 tests/TestCase/ListContinuationMarkerTest.php diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index bc22957b..384f5173 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -575,6 +575,63 @@ With `nestedListsWithoutBlankLine` mode enabled, nested lists can appear immedia See the [Parser Options guide](/guide/parser-options#nested-lists-without-blank-line-mode) for more on `nestedListsWithoutBlankLine` mode. +#### List Continuation Marker + +::: warning djot-php addition +The `+` continuation marker is a djot-php extension, **not** part of canonical +djot — djot.js does not recognize it. Documents that rely on it are not portable +to other djot implementations. +::: + +A lone `+` on its own line, at the list marker's column, attaches the following +block to the current list item **without a blank line and without making the +list loose**. It lets you keep a tight item that carries a code block, table, +admonition or quote, without indenting the block's body — useful for deeply +nested or wide-marker lists, and for pasting code (no re-indenting every line). + +````djot +- Build the image ++ +```sh +docker build -t app . +``` +- Push it +```` + +renders a **tight** list whose first item carries the code block flush-left: + +```html + +``` + +A blockquote, table or `:::` admonition attaches the same way: + +```djot +- item ++ +> a note attached to the item +- next +``` + +::: tip Notes +- A bare `+` is **never** a bullet (a bullet needs `+ ` plus content), so this + does not collide with `+`-bulleted lists. +- The marker attaches one block, up to the next blank line, sibling item, or a + further `+`. +- Outside a list, a lone `+` is ordinary paragraph text. +- This is sugar, not new structure: the same result is also reachable by + indenting the block under the item (which djot already supports). +::: + ### Definition Lists Terms are prefixed with `: ` and definitions are indented below. diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index d3b83a7f..445717cb 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -1898,6 +1898,51 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int break; } + // List-continuation marker (AsciiDoc-style `+`): a lone `+` at the + // marker column attaches the FOLLOWING flush-left block(s) to the + // current item, with no blank line, keeping the list tight. A bare + // `+` is never a bullet (a bullet needs `+ ` + content), so this does + // not collide with `+`-bulleted lists. Lets you attach a code block, + // table or quote to an item without indenting its body. + if ($currentIndent === $baseIndent && trim($currentLine) === '+') { + $lastItem = $this->listParser->getLastListItem($list); + if ($lastItem !== null) { + $i++; // consume the `+` marker line + /** @var array $attached */ + $attached = []; + while ($i < $count) { + $line = $lines[$i]; + if (IndentationHelper::isBlankLine($line)) { + break; // a blank ends the attachment (single-block demo scope) + } + $lineIndent = IndentationHelper::getLeadingSpaces($line); + if ($lineIndent < $baseIndent) { + break; + } + $trimmed = ltrim($line); + if ($lineIndent === $baseIndent) { + // Stop at a sibling item marker or a further `+` marker. + $marker = $this->listParser->parseListItemMarker($trimmed); + if ($marker !== null && $this->listParser->itemMatchesList($listInfo, $marker)) { + break; + } + if ($trimmed === '+') { + break; + } + } + $attached[] = IndentationHelper::stripLeadingIndent($line, $baseIndent); + $i++; + } + if ($attached !== []) { + $this->parseBlocks($lastItem, $attached, 0); + } + // The continuation attaches content but does not loosen the list. + $lastItemHadBlankAfter = false; + + continue; + } + } + // Check for indented continuation (after blank line = nested content) if ($lastItemHadBlankAfter && $currentIndent > $baseIndent) { // Content after blank line with indentation belongs to previous item @@ -2076,6 +2121,12 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int if ($nextInfo !== null) { break; } + // List-continuation marker: stop collecting lead text so the + // main loop's `+` handler attaches the following block to this + // item (instead of swallowing `+` as lazy continuation text). + if ($nextTrimmed === '+') { + break; + } // Non-list content at base indent - check if it starts another block if ($this->startsNewBlock($nextTrimmed)) { break; diff --git a/tests/TestCase/ListContinuationMarkerTest.php b/tests/TestCase/ListContinuationMarkerTest.php new file mode 100644 index 00000000..6d356b30 --- /dev/null +++ b/tests/TestCase/ListContinuationMarkerTest.php @@ -0,0 +1,62 @@ +converter = new DjotConverter(); + $this->converter->getHtmlRenderer()->setSoftBreakMode(SoftBreakMode::Newline); + } + + public function testAttachesCodeBlockFlushLeftAndTight(): void + { + $html = $this->converter->convert("- Build\n+\n```sh\ndocker build .\n```\n- Push"); + $this->assertStringContainsString("
  • \nBuild\n
    docker build .\n
    ", $html); + $this->assertStringNotContainsString('

    Build

    ', $html); + $this->assertStringContainsString("
  • \nPush\n
  • ", $html); + } + + public function testAttachesBlockquoteTight(): void + { + $html = $this->converter->convert("- item\n+\n> note\n- next"); + $this->assertStringContainsString("
  • \nitem\n
    ", $html); + $this->assertStringNotContainsString('

    item

    ', $html); + } + + public function testBareMarkerIsNotABulletInsideOrOutsideList(): void + { + // Outside a list: a lone `+` is ordinary paragraph text. + $this->assertStringContainsString('

    +

    ', $this->converter->convert("para\n\n+\n\nnext")); + // Real `+` bullets (marker + space + content) are unaffected. + $bullets = $this->converter->convert("+ one\n+ two"); + $this->assertStringContainsString("
  • \none\n
  • ", $bullets); + $this->assertStringContainsString("
  • \ntwo\n
  • ", $bullets); + } + + public function testContinuationDoesNotLoosenList(): void + { + $html = $this->converter->convert("- a\n+\n> q\n- b"); + // No item is

    -wrapped: the list stayed tight. + $this->assertStringNotContainsString("

  • \n

    ", $html); + } +} From 5c4b1142b6398a81027e13e25caca313b7e2e094 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 7 Jun 2026 03:18:05 +0200 Subject: [PATCH 2/7] Restrict + continuation to tight form and container/verbatim blocks The `+` list-continuation marker was a generic "attach the next block to the current item" sigil: it consumed the `+` for any following block (including headings and thematic breaks) and even when a blank line sat around it, where a detached block could then swallow the next sibling. Recognize `+` only in the sole valid shape `x` / `+` / `y`: a lone `+` at the marker column with content immediately before and after it (no blank line on either side), and only when the following block is a container or verbatim block (blockquote, fenced code/raw, table, div/admonition). Anything else leaves the `+` as ordinary text, matching plain parsing. Add tests for table/div attachment, leaf-block rejection, and the blank-line-around cases. Document the tight form, the strict attach set, and the portable canonical alternative (indent the block, use a `:::` div for callouts). --- docs/guide/syntax.md | 28 ++++++- src/Parser/BlockParser.php | 80 +++++++++++++++++-- tests/TestCase/ListContinuationMarkerTest.php | 36 +++++++++ 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index 384f5173..6e5c3738 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -586,9 +586,12 @@ to other djot implementations. A lone `+` on its own line, at the list marker's column, attaches the following block to the current list item **without a blank line and without making the list loose**. It lets you keep a tight item that carries a code block, table, -admonition or quote, without indenting the block's body — useful for deeply +div/admonition or quote, without indenting the block's body. Useful for deeply nested or wide-marker lists, and for pasting code (no re-indenting every line). +The marker is recognized **only** in the tight form `x` / `+` / `y`: content, +then a lone `+`, then the block, with **no blank line before or after** the `+`. + ````djot - Build the image + @@ -613,6 +616,13 @@ Push it ``` +Only **container and verbatim** blocks attach. A leaf block, or a `+` with a +blank line around it, is left as ordinary text: + +| `+` attaches | `+` stays literal text | +|---|---| +| blockquote `>`, fenced code / raw ` ``` ` or `~~~`, table `\|`, div / admonition `:::` | paragraph, heading `##`, thematic break `---`, a sibling list item, or any blank line around the `+` | + A blockquote, table or `:::` admonition attaches the same way: ```djot @@ -632,6 +642,22 @@ A blockquote, table or `:::` admonition attaches the same way: indenting the block under the item (which djot already supports). ::: +::: info Portable alternative +`+` is a djot-php extension and is not portable. The canonical djot way to attach +a block to a list item is to **indent it under the item** after a blank line, +which every djot implementation accepts. For callouts specifically, use a +canonical [`:::` div](#divs) inside the indented item rather than relying on `+`: + +```djot +- item + + ::: note + a note attached to the item + ::: +- next +``` +::: + ### Definition Lists Terms are prefixed with `: ` and definitions are indented below. diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index 445717cb..ff538954 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -1899,12 +1899,15 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int } // List-continuation marker (AsciiDoc-style `+`): a lone `+` at the - // marker column attaches the FOLLOWING flush-left block(s) to the - // current item, with no blank line, keeping the list tight. A bare - // `+` is never a bullet (a bullet needs `+ ` + content), so this does - // not collide with `+`-bulleted lists. Lets you attach a code block, - // table or quote to an item without indenting its body. - if ($currentIndent === $baseIndent && trim($currentLine) === '+') { + // marker column, in the tight form `x` / `+` / `y` (no blank line + // before or after it), attaches the FOLLOWING flush-left block to the + // current item and keeps the list tight. Only container/verbatim + // blocks attach (see isTightListContinuation()); any other shape + // leaves the `+` as ordinary text. A bare `+` is never a bullet + // (a bullet needs `+ ` + content), so it does not collide with + // `+`-bulleted lists. Lets you attach a code block, table or quote to + // an item without indenting its body. + if ($this->isTightListContinuation($lines, $i, $baseIndent)) { $lastItem = $this->listParser->getLastListItem($list); if ($lastItem !== null) { $i++; // consume the `+` marker line @@ -2123,8 +2126,9 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int } // List-continuation marker: stop collecting lead text so the // main loop's `+` handler attaches the following block to this - // item (instead of swallowing `+` as lazy continuation text). - if ($nextTrimmed === '+') { + // item. Only the tight `x` / `+` / `y` form qualifies; an + // otherwise-shaped `+` stays lazy continuation text. + if ($nextTrimmed === '+' && $this->isTightListContinuation($lines, $i, $baseIndent)) { break; } // Non-list content at base indent - check if it starts another block @@ -3523,6 +3527,66 @@ protected function appendToLastParagraph(Node $parent, string $content, int $lin } } + /** + * Whether the line at $i is a list-continuation marker in the only valid form: + * + * x + * + + * y + * + * A lone `+` at the marker column with content (no blank line) immediately + * before and after it, where `y` opens a container or verbatim block. Any + * other shape (blank line around the `+`, a leaf block such as a paragraph, + * heading or thematic break, or a sibling list item) leaves the `+` as + * ordinary text. + * + * @param array $lines + * @param int $baseIndent + * @param int $i + */ + private function isTightListContinuation(array $lines, int $i, int $baseIndent): bool + { + $line = $lines[$i] ?? ''; + if (trim($line) !== '+' || IndentationHelper::getLeadingSpaces($line) !== $baseIndent) { + return false; + } + + // No blank line (and not the very first line) immediately before the marker. + if (!isset($lines[$i - 1]) || IndentationHelper::isBlankLine($lines[$i - 1])) { + return false; + } + + // A non-blank block must follow immediately at the marker column. + $next = $lines[$i + 1] ?? null; + if ($next === null || IndentationHelper::isBlankLine($next)) { + return false; + } + if (IndentationHelper::getLeadingSpaces($next) !== $baseIndent) { + return false; + } + + return $this->opensAttachableContinuationBlock(ltrim($next)); + } + + /** + * Whether a line opens a block that a `+` continuation may attach to a list + * item: a container block (blockquote `>`, div/admonition `:::`, table `|`) + * or a verbatim block (fenced code/raw ``` or ~~~). + * + * Leaf blocks (paragraph, heading, thematic break) and lists (a `-`/`1.` at + * the marker column is a sibling item, not nested content) are excluded. + */ + private function opensAttachableContinuationBlock(string $trimmed): bool + { + if ($trimmed === '') { + return false; + } + + return $trimmed[0] === '>' + || $trimmed[0] === '|' + || preg_match('/^(`{3,}|~{3,}|:{3,})/', $trimmed) === 1; + } + /** * Determine whether a continuation line should interrupt the current block (paragraph etc.). * diff --git a/tests/TestCase/ListContinuationMarkerTest.php b/tests/TestCase/ListContinuationMarkerTest.php index 6d356b30..9f221798 100644 --- a/tests/TestCase/ListContinuationMarkerTest.php +++ b/tests/TestCase/ListContinuationMarkerTest.php @@ -59,4 +59,40 @@ public function testContinuationDoesNotLoosenList(): void // No item is

    -wrapped: the list stayed tight. $this->assertStringNotContainsString("

  • \n

    ", $html); } + + public function testAttachesTableAndDiv(): void + { + $table = $this->converter->convert("- item\n+\n| a | b |\n- next"); + $this->assertStringContainsString("

  • \nitem\n", $table); + + $div = $this->converter->convert("- item\n+\n::: note\nhi\n:::\n- next"); + $this->assertStringContainsString("
  • \nitem\n
    ", $div); + } + + /** + * Strict scope: a `+` only attaches container/verbatim blocks. A leaf block + * (heading, thematic break, plain paragraph) is not attached; the `+` stays + * literal continuation text on the item. + */ + public function testDoesNotAttachLeafBlocks(): void + { + foreach (['## Heading', '---', 'plain paragraph'] as $leaf) { + $html = $this->converter->convert("- item\n+\n{$leaf}\n- next"); + $this->assertStringContainsString("item\n+\n", $html, "+ should stay literal before: {$leaf}"); + $this->assertStringNotContainsString('
    ', $html); + } + } + + /** + * Only the tight `x` / `+` / `y` form is a continuation marker; a blank line + * before or after the `+` leaves it as ordinary text. + */ + public function testBlankLineAroundMarkerIsNotContinuation(): void + { + $blankAfter = $this->converter->convert("- item\n+\n\n> note"); + $this->assertStringNotContainsString("
  • \nitem\n
    ", $blankAfter); + + $blankBefore = $this->converter->convert("- item\n\n+\n> note"); + $this->assertStringNotContainsString("
  • \nitem\n
    ", $blankBefore); + } } From 2507263e1c6f31b4ce5658e9fa359c7d7b3ebb28 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 7 Jun 2026 03:21:17 +0200 Subject: [PATCH 3/7] Cover continuation edge cases; drop unreachable empty-line guard Add a test for a trailing + with no following block, and simplify opensAttachableContinuationBlock to a single expression so codecov sees every new line exercised. --- src/Parser/BlockParser.php | 8 +++----- tests/TestCase/ListContinuationMarkerTest.php | 8 ++++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index ff538954..781e1b31 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -3578,12 +3578,10 @@ private function isTightListContinuation(array $lines, int $i, int $baseIndent): */ private function opensAttachableContinuationBlock(string $trimmed): bool { - if ($trimmed === '') { - return false; - } + $first = $trimmed[0] ?? ''; - return $trimmed[0] === '>' - || $trimmed[0] === '|' + return $first === '>' + || $first === '|' || preg_match('/^(`{3,}|~{3,}|:{3,})/', $trimmed) === 1; } diff --git a/tests/TestCase/ListContinuationMarkerTest.php b/tests/TestCase/ListContinuationMarkerTest.php index 9f221798..39ab618c 100644 --- a/tests/TestCase/ListContinuationMarkerTest.php +++ b/tests/TestCase/ListContinuationMarkerTest.php @@ -95,4 +95,12 @@ public function testBlankLineAroundMarkerIsNotContinuation(): void $blankBefore = $this->converter->convert("- item\n\n+\n> note"); $this->assertStringNotContainsString("
  • \nitem\n
    ", $blankBefore); } + + public function testTrailingMarkerWithNoFollowingBlockIsLiteral(): void + { + // A `+` with nothing after it is not a continuation marker. + $html = $this->converter->convert("- item\n+"); + $this->assertStringNotContainsString('
    ', $html); + $this->assertStringContainsString('+', $html); + } } From 1d22607e90386d90fb36d425791f1df6aa73af29 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 7 Jun 2026 03:23:37 +0200 Subject: [PATCH 4/7] Cover flush-column rejection branch for + continuation Add a test where the block after + is indented (not flush at the marker column), exercising the remaining new patch line for codecov. --- tests/TestCase/ListContinuationMarkerTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/TestCase/ListContinuationMarkerTest.php b/tests/TestCase/ListContinuationMarkerTest.php index 39ab618c..eae2bd18 100644 --- a/tests/TestCase/ListContinuationMarkerTest.php +++ b/tests/TestCase/ListContinuationMarkerTest.php @@ -103,4 +103,13 @@ public function testTrailingMarkerWithNoFollowingBlockIsLiteral(): void $this->assertStringNotContainsString('
    ', $html); $this->assertStringContainsString('+', $html); } + + public function testIndentedBlockAfterMarkerIsNotFlushAttachment(): void + { + // The attached block must sit flush at the marker column; an indented + // block after `+` is not a tight continuation, so the `+` stays literal. + $html = $this->converter->convert("- item\n+\n > note\n- next"); + $this->assertStringContainsString("item\n+\n", $html); + $this->assertStringNotContainsString('
    ', $html); + } } From 7f0f9b37da2b274819457c930a89e03aa85bd44e Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 7 Jun 2026 03:26:09 +0200 Subject: [PATCH 5/7] Add regression test for + continuation in a list nested inside a blockquote Locks the list-item scoping: + attaches inside a quote only when the quote contains a list, and stays literal text when it does not. --- tests/TestCase/ListContinuationMarkerTest.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/TestCase/ListContinuationMarkerTest.php b/tests/TestCase/ListContinuationMarkerTest.php index eae2bd18..fb95f24a 100644 --- a/tests/TestCase/ListContinuationMarkerTest.php +++ b/tests/TestCase/ListContinuationMarkerTest.php @@ -112,4 +112,24 @@ public function testIndentedBlockAfterMarkerIsNotFlushAttachment(): void $this->assertStringContainsString("item\n+\n", $html); $this->assertStringNotContainsString('
    ', $html); } + + /** + * The marker is list-item-scoped, not quote-scoped: it works for a list + * nested inside a blockquote (attaching to the list item, inside the quote), + * but a `+` in a quote with no list stays literal text. + */ + public function testWorksForListNestedInsideBlockquote(): void + { + $attached = $this->converter->convert("> - item\n> +\n> > note\n> - next"); + // The quote-in-item is attached to the first list item, inside the outer quote. + $this->assertStringContainsString( + "
    \n
      \n
    • \nitem\n
      \n

      note

      ", + $attached, + ); + $this->assertStringContainsString("
    • \nnext\n
    • ", $attached); + + // No list to attach to: the `+` is ordinary text. + $noList = $this->converter->convert("> para\n> +\n> > note"); + $this->assertStringContainsString("para\n+\n", $noList); + } } From 7024eb5f952a1c9fa9e55bada82fdf799fab5740 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 7 Jun 2026 03:27:36 +0200 Subject: [PATCH 6/7] docs: spell out the flush-left rationale for the + continuation marker --- docs/guide/syntax.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index 6e5c3738..a5fc6531 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -586,8 +586,21 @@ to other djot implementations. A lone `+` on its own line, at the list marker's column, attaches the following block to the current list item **without a blank line and without making the list loose**. It lets you keep a tight item that carries a code block, table, -div/admonition or quote, without indenting the block's body. Useful for deeply -nested or wide-marker lists, and for pasting code (no re-indenting every line). +div/admonition or quote, without indenting the block's body. + +::: tip Why, where it beats plain indentation +The canonical way to attach a block is to indent its body under the item. `+` +adds the **flush-left** case, which matters when that indentation is painful: + +- **Pasting code** keeps its own indentation; you do not have to re-indent every + line by the marker width. +- **Deep or wide markers** make the required indent large: under `10. ` or three + levels deep the body would need 4 to 8 leading spaces on every line. `+` keeps + the block flush at column 0. + +It adds no new structure (the same tree is reachable by indenting); it is +authoring sugar for the flush-left case. +::: The marker is recognized **only** in the tight form `x` / `+` / `y`: content, then a lone `+`, then the block, with **no blank line before or after** the `+`. From d72b4ce52479f26bdd67d6b5b6cee3bfbd137ab1 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 7 Jun 2026 04:18:16 +0200 Subject: [PATCH 7/7] Add `- +` empty-item form and share the attach helper `+` could only attach a block after item text (`x` / `+` / `y`). Starting an item *with* a block needed an empty item written as `- ` with a trailing space, which lint rules forbid. Add the trailing-whitespace-free form `- +` (marker + bare `+`): when the item's only content is `+` and an attachable container/verbatim block follows flush-left, attach it as the item's sole content. The lone-`+` and `- +` paths now share attachContinuationBlock()/nextLineOpensAttachableBlock(). Drop an unreachable dedent guard in the collector (nested lists parse on sliced arrays, so a line below the marker column never reaches it). Cover table/quote/code attachment, ordered lists, leaf-block rejection, chained markers, blank-line termination, and nested-list cases. --- docs/guide/syntax.md | 28 +++++ src/Parser/BlockParser.php | 103 +++++++++++++----- tests/TestCase/ListContinuationMarkerTest.php | 61 +++++++++++ 3 files changed, 162 insertions(+), 30 deletions(-) diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index a5fc6531..76b34795 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -645,6 +645,34 @@ A blockquote, table or `:::` admonition attaches the same way: - next ``` +When the block is the **first content** of the item (no text before it), put the +`+` right after the marker as `- +` (a space between marker and `+`, never a +trailing space). This is the lint-safe way to start an item with a block: + +```djot +- + +| a | b | +- next +``` + +renders the table as the first item's only content: + +```html +
        +
      • +
  • + + + + +
    ab
    +
  • +
  • +next +
  • + +``` + ::: tip Notes - A bare `+` is **never** a bullet (a bullet needs `+ ` plus content), so this does not collide with `+`-bulleted lists. diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index 781e1b31..09a6ce17 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -1910,35 +1910,8 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int if ($this->isTightListContinuation($lines, $i, $baseIndent)) { $lastItem = $this->listParser->getLastListItem($list); if ($lastItem !== null) { - $i++; // consume the `+` marker line - /** @var array $attached */ - $attached = []; - while ($i < $count) { - $line = $lines[$i]; - if (IndentationHelper::isBlankLine($line)) { - break; // a blank ends the attachment (single-block demo scope) - } - $lineIndent = IndentationHelper::getLeadingSpaces($line); - if ($lineIndent < $baseIndent) { - break; - } - $trimmed = ltrim($line); - if ($lineIndent === $baseIndent) { - // Stop at a sibling item marker or a further `+` marker. - $marker = $this->listParser->parseListItemMarker($trimmed); - if ($marker !== null && $this->listParser->itemMatchesList($listInfo, $marker)) { - break; - } - if ($trimmed === '+') { - break; - } - } - $attached[] = IndentationHelper::stripLeadingIndent($line, $baseIndent); - $i++; - } - if ($attached !== []) { - $this->parseBlocks($lastItem, $attached, 0); - } + // Attach the following block to the current item; skip the `+`. + $i = $this->attachContinuationBlock($lastItem, $lines, $i + 1, $count, $baseIndent, $listInfo); // The continuation attaches content but does not loosen the list. $lastItemHadBlankAfter = false; @@ -2087,6 +2060,19 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int /** @var string $itemContent */ $itemContent = $itemInfo['content']; + // Empty-item continuation: a marker whose only content is a bare `+` + // (e.g. `- +`) with an attachable container/verbatim block on the next + // flush-left line attaches that block as the item's sole content. This + // is the trailing-whitespace-free form of `x` / `+` / `y` for the case + // where the block is the first thing in the item. + if ($itemContent === '+' && $this->nextLineOpensAttachableBlock($lines, $i, $baseIndent)) { + $list->appendChild($listItem); + $i = $this->attachContinuationBlock($listItem, $lines, $i + 1, $count, $baseIndent, $listInfo); + $lastItemHadBlankAfter = false; + + continue; + } + // Collect item content lines (without blank line = tight continuation) /** @var array $itemLines */ $itemLines = [$itemContent]; @@ -3556,7 +3542,20 @@ private function isTightListContinuation(array $lines, int $i, int $baseIndent): return false; } - // A non-blank block must follow immediately at the marker column. + return $this->nextLineOpensAttachableBlock($lines, $i, $baseIndent); + } + + /** + * Whether the line after index $i is a non-blank attachable block, flush at + * the marker column. Shared by the lone-`+` marker (`x` / `+` / `y`) and the + * empty-item marker (`- +` / `y`). + * + * @param array $lines + * @param int $baseIndent + * @param int $i + */ + private function nextLineOpensAttachableBlock(array $lines, int $i, int $baseIndent): bool + { $next = $lines[$i + 1] ?? null; if ($next === null || IndentationHelper::isBlankLine($next)) { return false; @@ -3568,6 +3567,50 @@ private function isTightListContinuation(array $lines, int $i, int $baseIndent): return $this->opensAttachableContinuationBlock(ltrim($next)); } + /** + * Collect and attach a single flush-left block to $item, starting at line + * index $i (the first line of the block). The block runs to the next blank + * line, sibling item marker, or further `+`. Returns the index just past the + * attached block. + * + * @param \Djot\Node\Block\ListItem $item + * @param array $lines + * @param int $baseIndent + * @param int $count + * @param int $i + * @param array $listInfo + */ + private function attachContinuationBlock(ListItem $item, array $lines, int $i, int $count, int $baseIndent, array $listInfo): int + { + /** @var array $attached */ + $attached = []; + while ($i < $count) { + $line = $lines[$i]; + if (IndentationHelper::isBlankLine($line)) { + break; // a blank ends the attachment (single block) + } + $lineIndent = IndentationHelper::getLeadingSpaces($line); + $trimmed = ltrim($line); + if ($lineIndent === $baseIndent) { + // Stop at a sibling item marker or a further `+` marker. + $marker = $this->listParser->parseListItemMarker($trimmed); + if ($marker !== null && $this->listParser->itemMatchesList($listInfo, $marker)) { + break; + } + if ($trimmed === '+') { + break; + } + } + $attached[] = IndentationHelper::stripLeadingIndent($line, $baseIndent); + $i++; + } + if ($attached !== []) { + $this->parseBlocks($item, $attached, 0); + } + + return $i; + } + /** * Whether a line opens a block that a `+` continuation may attach to a list * item: a container block (blockquote `>`, div/admonition `:::`, table `|`) diff --git a/tests/TestCase/ListContinuationMarkerTest.php b/tests/TestCase/ListContinuationMarkerTest.php index fb95f24a..a614a523 100644 --- a/tests/TestCase/ListContinuationMarkerTest.php +++ b/tests/TestCase/ListContinuationMarkerTest.php @@ -5,6 +5,7 @@ namespace Djot\Test\TestCase; use Djot\DjotConverter; +use Djot\Parser\BlockParser; use Djot\Renderer\SoftBreakMode; use PHPUnit\Framework\TestCase; @@ -132,4 +133,64 @@ public function testWorksForListNestedInsideBlockquote(): void $noList = $this->converter->convert("> para\n> +\n> > note"); $this->assertStringContainsString("para\n+\n", $noList); } + + /** + * `- +` (marker + bare `+`, no trailing whitespace) attaches the following + * block as the item's first and only content. + */ + public function testEmptyItemMarkerAttachesFirstBlock(): void + { + $table = $this->converter->convert("- +\n| a | b |\n- next"); + $this->assertStringContainsString("
  • \n", $table); + $this->assertStringContainsString("
  • \nnext\n
  • ", $table); + + $ordered = $this->converter->convert("1. +\n> note\n2. next"); + $this->assertStringContainsString("
  • \n
    ", $ordered); + } + + public function testEmptyItemMarkerRejectsLeafBlocks(): void + { + // `- +` only attaches container/verbatim blocks; a leaf block leaves the + // `+` as literal item text. + $html = $this->converter->convert("- +\n## H\n- next"); + $this->assertStringContainsString("+\n## H", $html); + $this->assertStringNotContainsString('
  • ', $html); + } + + public function testChainedMarkersAttachMultipleBlocks(): void + { + $html = $this->converter->convert("- item\n+\n> a\n+\n> b\n- next"); + // Two blockquotes attached to the first item, then the sibling. + $this->assertSame(2, substr_count($html, '
    ')); + $this->assertStringContainsString("
  • \nnext\n
  • ", $html); + } + + public function testBlankLineEndsAttachedBlock(): void + { + // The attachment is a single block: a blank line after it ends it, and + // following content is a normal top-level block. + $html = $this->converter->convert("- item\n+\n> note\n\nafter"); + $this->assertStringContainsString('
    ', $html); + $this->assertStringContainsString('

    after

    ', $html); + } + + public function testContinuationInsideNestedListDedents(): void + { + // Inside a nested list, the attached block ends when a line dedents below + // the nested list's marker column. + $html = $this->converter->convert("- outer\n\n - inner\n +\n > note\n- after"); + $this->assertStringContainsString("inner\n
    ", $html); + $this->assertStringContainsString("
  • \nafter\n
  • ", $html); + } + + public function testContinuationInInlineNestedListDedents(): void + { + // With inline nesting, the nested list and the dedent to the parent share + // one line array, so the attachment must stop at the dedented line. + $converter = new DjotConverter(parser: new BlockParser(nestedListsWithoutBlankLine: true)); + $converter->getHtmlRenderer()->setSoftBreakMode(SoftBreakMode::Newline); + $html = $converter->convert("- outer\n - inner\n +\n > note\n- after"); + $this->assertStringContainsString("inner\n
    ", $html); + $this->assertStringContainsString("
  • \nafter\n
  • ", $html); + } }