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 + +``` + +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\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); + } + + 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
      \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); + } + + /** + * `- +` (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); + } +}