diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md
index 90a938d7..a83ef5c0 100644
--- a/docs/guide/syntax.md
+++ b/docs/guide/syntax.md
@@ -428,6 +428,30 @@ Attributes can be added to list items on the following indented line:
Works with all list types (unordered, ordered, and task lists). The attribute line must be indented to the content indentation level.
+The `{...}` line attaches to the `
` **only when it is the last
+content line of the item**. If another block follows it within the
+same item, the `{...}` behaves as a standard djot block attribute for
+that following block, and the list / item are not terminated:
+
+```djot
+- item
+ {.note}
+ > a quoted aside inside the item
+```
+
+renders as:
+
+```html
+
+-
+item
+
+a quoted aside inside the item
+
+
+
+```
+
#### Tight vs Loose Lists
Lists in Djot can be *tight* or *loose*, which affects how list items are rendered.
diff --git a/docs/reference/enhancements.md b/docs/reference/enhancements.md
index 09d36829..6156312e 100644
--- a/docs/reference/enhancements.md
+++ b/docs/reference/enhancements.md
@@ -435,6 +435,11 @@ Works with all list types:
- Attributes on next line at content indentation level
- Uses standard `{.class #id key=value}` syntax
- Works with unordered, ordered, and task lists
+- The `{...}` line attaches to the `` **only when it is the last
+ content line of the item**. If another block follows the `{...}`
+ line within the same item, the `{...}` reverts to a standard djot
+ block attribute for that following block; the list and item are
+ not terminated.
---
diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php
index e05df42e..d0b88359 100644
--- a/src/Parser/BlockParser.php
+++ b/src/Parser/BlockParser.php
@@ -1653,18 +1653,74 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
$i++;
}
- // Check for list item attributes on the next line
+ // Check for list item attributes on the next line.
+ //
+ // Rule: a standalone {...} line attaches to the ONLY when it
+ // is the last content line of the item. If another block follows
+ // within the same item, push the {...} back into itemLines so it
+ // is parsed as a standard djot block attribute for the following
+ // block inside the item. This keeps the list / item intact instead
+ // of terminating it on a mid-item {...} line.
$itemAttributes = [];
+ $attrPushedBack = false;
if ($i < $count) {
$potentialAttrLine = $lines[$i];
$trimmedAttrLine = ltrim($potentialAttrLine);
- // Check if it's an attribute block at content indent level
if (
preg_match('/^\{([^{}]+)\}\s*$/', $trimmedAttrLine, $attrMatch) &&
IndentationHelper::getLeadingSpaces($potentialAttrLine) >= $contentIndent
) {
- $itemAttributes = AttributeParser::parseOrdered($attrMatch[1]);
- $i++;
+ // Peek ahead: is there more item content at content indent
+ // (non-blank, non-sibling-marker)?
+ $hasMoreItemContent = false;
+ if ($i + 1 < $count) {
+ $peekLine = $lines[$i + 1];
+ if (!IndentationHelper::isBlankLine($peekLine)) {
+ $peekIndent = IndentationHelper::getLeadingSpaces($peekLine);
+ if ($peekIndent >= $contentIndent) {
+ $hasMoreItemContent = true;
+ }
+ }
+ }
+
+ if ($hasMoreItemContent) {
+ // {...} is not the item's attribute — it is a block
+ // attribute for the next block in the item. Push it back
+ // into itemLines (stripped of the item's content indent)
+ // and keep consuming further indented continuation lines.
+ // Insert a blank-line separator first so parseBlocks
+ // recognizes the previously-consumed paragraph as closed
+ // and reads {...} as a real block attribute for the
+ // following block, instead of folding everything into
+ // one paragraph (standard djot: paragraphs cannot be
+ // interrupted without a blank line).
+ if ($itemLines !== [] && $itemLines[array_key_last($itemLines)] !== '') {
+ $itemLines[] = '';
+ }
+ $itemLines[] = IndentationHelper::stripLeadingIndent($potentialAttrLine, $contentIndent);
+ $hasNonMarkerContinuation = true;
+ $attrPushedBack = true;
+ $i++;
+ while ($i < $count) {
+ $contLine = $lines[$i];
+ if (IndentationHelper::isBlankLine($contLine)) {
+ // A blank line ends the tight continuation here;
+ // any further indented content will be picked up
+ // by the existing loose-list path below.
+ break;
+ }
+ $contIndent = IndentationHelper::getLeadingSpaces($contLine);
+ if ($contIndent >= $contentIndent) {
+ $itemLines[] = IndentationHelper::stripLeadingIndent($contLine, $contentIndent);
+ $i++;
+ } else {
+ break;
+ }
+ }
+ } else {
+ $itemAttributes = AttributeParser::parseOrdered($attrMatch[1]);
+ $i++;
+ }
}
}
@@ -1674,8 +1730,10 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
// still allowing blockquotes, code blocks, etc. to be properly recognized.
if ($hasNonMarkerContinuation) {
$firstLine = $itemLines[0];
- if ($this->isBlockElementStart($firstLine)) {
- // Content starts with a block element (blockquote, code fence, etc.)
+ if ($attrPushedBack || $this->isBlockElementStart($firstLine)) {
+ // Content starts with a block element (blockquote, code fence,
+ // etc.) or we pushed a {...} back into itemLines that must be
+ // recognized as a block attribute for the next block.
$this->parseBlocks($listItem, $itemLines, 0);
} else {
$paragraph = new Paragraph();
diff --git a/tests/TestCase/DjotConverterTest.php b/tests/TestCase/DjotConverterTest.php
index 20d75fae..ba3caa59 100644
--- a/tests/TestCase/DjotConverterTest.php
+++ b/tests/TestCase/DjotConverterTest.php
@@ -1030,6 +1030,80 @@ public function testTaskListItemAttributes(): void
$this->assertStringContainsString('checked=""', $result);
}
+ public function testListItemAttributesFollowedByBlockquoteStaysInItem(): void
+ {
+ // G2: a {...} line followed by more content within the same item
+ // must NOT terminate the list. The {...} reverts to a normal
+ // block-attribute for the following blockquote inside the item.
+ $djot = "- item\n {.x}\n > quote\n";
+
+ $result = $this->converter->convert($djot);
+
+ $this->assertStringContainsString('', $result);
+ $this->assertStringContainsString('- ', $result);
+ $this->assertStringContainsString('
', $result);
+ $this->assertStringNotContainsString('> quote', $result);
+ }
+
+ public function testListItemAttributesFollowedByParagraphStaysInItem(): void
+ {
+ // G2: continuation text after a {...} line must remain in the item,
+ // not escape to a sibling paragraph outside the list.
+ $djot = "- item one\n {.x}\n more text\n";
+
+ $result = $this->converter->convert($djot);
+
+ $this->assertStringContainsString('', $result);
+ $this->assertStringContainsString('item one', $result);
+ $this->assertStringContainsString('more text', $result);
+ // "more text" must be INSIDE the , not a sibling.
+ $ulClose = strpos($result, '
');
+ $moreTextPos = strpos($result, 'more text');
+ $this->assertNotFalse($ulClose);
+ $this->assertNotFalse($moreTextPos);
+ $this->assertLessThan($ulClose, $moreTextPos);
+ }
+
+ public function testListItemAttributesFollowedByNestedListStaysInItem(): void
+ {
+ // G2: a nested list after a {...} line must stay nested, not
+ // escape to literal text outside the parent list. The {.x}
+ // reverts to a block attribute attached to the nested .
+ $djot = "- item\n {.x}\n - nested\n";
+
+ $result = $this->converter->convert($djot);
+
+ // Expect two nested opens (the outer and the inner
+ // one that picks up {.x}), NOT a "- nested
" escape.
+ $this->assertSame(2, substr_count($result, 'assertStringNotContainsString('- nested
', $result);
+ $this->assertStringContainsString('', $result);
+ }
+
+ public function testListItemAttributesLastLineUnchanged(): void
+ {
+ // G2 regression guard: when {.x} IS the last line of the item it
+ // still attaches to the - exactly as before.
+ $djot = "- item\n {.x}\n- second\n";
+
+ $result = $this->converter->convert($djot);
+
+ $this->assertStringContainsString('
- ', $result);
+ }
+
+ public function testListItemAttributesLooseListUnchanged(): void
+ {
+ // G2 regression guard: blank-line separator (loose item) keeps
+ // the previously-working behavior — {.x} attaches to the
- ,
+ // and the following blockquote sits inside the item.
+ $djot = "- item\n {.x}\n\n > quote\n";
+
+ $result = $this->converter->convert($djot);
+
+ $this->assertStringContainsString('
- ', $result);
+ $this->assertStringContainsString('
', $result);
+ }
+
public function testRomanNumeralList(): void
{
// x. is parsed as Roman numeral 10