diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md
index bc22957b..76b34795 100644
--- a/docs/guide/syntax.md
+++ b/docs/guide/syntax.md
@@ -575,6 +575,130 @@ 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,
+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 `+`.
+
+````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
+
+-
+Build the image
+
docker build -t app .
+
+
+-
+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
+- item
++
+> a note attached to the item
+- 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
+
+```
+
+::: 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).
+:::
+
+::: 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 d3b83a7f..09a6ce17 100644
--- a/src/Parser/BlockParser.php
+++ b/src/Parser/BlockParser.php
@@ -1898,6 +1898,27 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
break;
}
+ // List-continuation marker (AsciiDoc-style `+`): a lone `+` at the
+ // 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) {
+ // 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;
+
+ continue;
+ }
+ }
+
// Check for indented continuation (after blank line = nested content)
if ($lastItemHadBlankAfter && $currentIndent > $baseIndent) {
// Content after blank line with indentation belongs to previous item
@@ -2039,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];
@@ -2076,6 +2110,13 @@ 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. 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
if ($this->startsNewBlock($nextTrimmed)) {
break;
@@ -3472,6 +3513,121 @@ 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;
+ }
+
+ 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;
+ }
+ if (IndentationHelper::getLeadingSpaces($next) !== $baseIndent) {
+ return false;
+ }
+
+ 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 `|`)
+ * 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
+ {
+ $first = $trimmed[0] ?? '';
+
+ return $first === '>'
+ || $first === '|'
+ || 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
new file mode 100644
index 00000000..a614a523
--- /dev/null
+++ b/tests/TestCase/ListContinuationMarkerTest.php
@@ -0,0 +1,196 @@
+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\ndocker 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);
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ /**
+ * 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
\nnote
",
+ $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);
+ }
+
+ /**
+ * `- +` (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);
+ }
+}