Skip to content
124 changes: 124 additions & 0 deletions docs/guide/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<ul>
<li>
Build the image
<pre><code class="language-sh">docker build -t app .
</code></pre>
</li>
<li>
Push it
</li>
</ul>
```

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
<ul>
<li>
<table>
<tr>
<td>a</td>
<td>b</td>
</tr>
</table>
</li>
<li>
next
</li>
</ul>
```

::: 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.
Expand Down
156 changes: 156 additions & 0 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string> $itemLines */
$itemLines = [$itemContent];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> $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<string> $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<string> $lines
* @param int $baseIndent
* @param int $count
* @param int $i
* @param array<string, mixed> $listInfo
*/
private function attachContinuationBlock(ListItem $item, array $lines, int $i, int $count, int $baseIndent, array $listInfo): int
{
/** @var array<string> $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.).
*
Expand Down
Loading
Loading