Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/guide/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<li>` **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
<ul>
<li>
item
<blockquote class="note">
<p>a quoted aside inside the item</p>
</blockquote>
</li>
</ul>
```

#### Tight vs Loose Lists

Lists in Djot can be *tight* or *loose*, which affects how list items are rendered.
Expand Down
5 changes: 5 additions & 0 deletions docs/reference/enhancements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<li>` **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.

---

Expand Down
70 changes: 64 additions & 6 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <li> 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++;
}
}
}

Expand All @@ -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();
Expand Down
74 changes: 74 additions & 0 deletions tests/TestCase/DjotConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<ul>', $result);
$this->assertStringContainsString('<li>', $result);
$this->assertStringContainsString('<blockquote class="x">', $result);
$this->assertStringNotContainsString('&gt; 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('<ul>', $result);
$this->assertStringContainsString('item one', $result);
$this->assertStringContainsString('more text', $result);
// "more text" must be INSIDE the <ul>, not a sibling.
$ulClose = strpos($result, '</ul>');
$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 <ul>.
$djot = "- item\n {.x}\n - nested\n";

$result = $this->converter->convert($djot);

// Expect two nested <ul> opens (the outer <ul> and the inner
// one that picks up {.x}), NOT a "<p>- nested</p>" escape.
$this->assertSame(2, substr_count($result, '<ul'));
$this->assertStringNotContainsString('<p>- nested</p>', $result);
$this->assertStringContainsString('<ul class="x">', $result);
}

public function testListItemAttributesLastLineUnchanged(): void
{
// G2 regression guard: when {.x} IS the last line of the item it
// still attaches to the <li> exactly as before.
$djot = "- item\n {.x}\n- second\n";

$result = $this->converter->convert($djot);

$this->assertStringContainsString('<li class="x">', $result);
}

public function testListItemAttributesLooseListUnchanged(): void
{
// G2 regression guard: blank-line separator (loose item) keeps
// the previously-working behavior — {.x} attaches to the <li>,
// and the following blockquote sits inside the item.
$djot = "- item\n {.x}\n\n > quote\n";

$result = $this->converter->convert($djot);

$this->assertStringContainsString('<li class="x">', $result);
$this->assertStringContainsString('<blockquote>', $result);
}

public function testRomanNumeralList(): void
{
// x. is parsed as Roman numeral 10
Expand Down
Loading