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 + +``` + #### 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('