Skip to content

Commit 0ef5f00

Browse files
dereuromarkclaude
andauthored
Add list item attribute support (#5)
List items can now have attributes on the following line at content indent: ```djot - item 1 {.highlight #id1} - item 2 {data-value="test"} ``` Renders as: ```html <li class="highlight" id="id1">item 1</li> <li data-value="test">item 2</li> ``` Works with: - Unordered lists - Ordered lists - Task lists Related: jgm/djot#262 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent a371cbb commit 0ef5f00

File tree

2 files changed

+114
-0
lines changed

2 files changed

+114
-0
lines changed

src/Parser/BlockParser.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,41 @@ protected function processAttributeEscapes(string $value): string
712712
return preg_replace('/\\\\(.)/', '$1', $value) ?? $value;
713713
}
714714

715+
/**
716+
* Parse attribute string and return as array (without affecting pendingAttributes)
717+
*
718+
* @return array<string, string>
719+
*/
720+
protected function parseAttributeStringToArray(string $attrStr): array
721+
{
722+
$attributes = [];
723+
724+
// Parse .class
725+
if (preg_match_all('/\.([^\s.#=}]+)/', $attrStr, $classMatches)) {
726+
$attributes['class'] = implode(' ', $classMatches[1]);
727+
}
728+
729+
// Parse #id
730+
if (preg_match('/#([^\s.#=}]+)/', $attrStr, $idMatch)) {
731+
$attributes['id'] = $idMatch[1];
732+
}
733+
734+
// Parse key="double quoted value", key='single quoted value', or key=unquoted
735+
if (preg_match_all('/([a-zA-Z_][a-zA-Z0-9_-]*)="((?:[^"\\\\]|\\\\.)*)"|([a-zA-Z_][a-zA-Z0-9_-]*)=\'((?:[^\'\\\\]|\\\\.)*)\'|([a-zA-Z_][a-zA-Z0-9_-]*)=([^\s}"\']+)/', $attrStr, $kvMatches, PREG_SET_ORDER)) {
736+
foreach ($kvMatches as $match) {
737+
if (($match[1] ?? '') !== '') {
738+
$attributes[$match[1]] = $this->processAttributeEscapes($match[2] ?? '');
739+
} elseif (($match[3] ?? '') !== '') {
740+
$attributes[$match[3]] = $this->processAttributeEscapes($match[4] ?? '');
741+
} elseif (($match[5] ?? '') !== '') {
742+
$attributes[$match[5]] = $match[6] ?? '';
743+
}
744+
}
745+
}
746+
747+
return $attributes;
748+
}
749+
715750
/**
716751
* Apply pending attributes to a node and clear them
717752
*/
@@ -1508,6 +1543,15 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
15081543
}
15091544
}
15101545

1546+
// Check for list item attributes (must be at content indent, be a standalone attribute block)
1547+
if (
1548+
$nextIndent >= $contentIndent &&
1549+
preg_match('/^\{([^{}]+)\}\s*$/', $nextTrimmed, $attrMatch)
1550+
) {
1551+
// This is a list item attribute line - don't add to content
1552+
break;
1553+
}
1554+
15111555
// Content at content indent or more is continuation (even if it looks like a list marker)
15121556
// In djot, " - b" after "- a" (no blank line) is literal text, not a nested list
15131557
if ($nextIndent >= $contentIndent) {
@@ -1521,6 +1565,21 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
15211565
$i++;
15221566
}
15231567

1568+
// Check for list item attributes on the next line
1569+
$itemAttributes = [];
1570+
if ($i < $count) {
1571+
$potentialAttrLine = $lines[$i];
1572+
$trimmedAttrLine = ltrim($potentialAttrLine);
1573+
// Check if it's an attribute block at content indent level
1574+
if (
1575+
preg_match('/^\{([^{}]+)\}\s*$/', $trimmedAttrLine, $attrMatch) &&
1576+
$this->getLeadingSpaces($potentialAttrLine) >= $contentIndent
1577+
) {
1578+
$itemAttributes = $this->parseAttributeStringToArray($attrMatch[1]);
1579+
$i++;
1580+
}
1581+
}
1582+
15241583
// For tight lists with continuation lines, parse as plain text
15251584
// This prevents "-like" lines from being parsed as nested lists
15261585
if ($hasNonMarkerContinuation) {
@@ -1530,6 +1589,13 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
15301589
} else {
15311590
$this->parseBlocks($listItem, $itemLines, 0);
15321591
}
1592+
1593+
// Apply attributes to list item
1594+
if ($itemAttributes !== []) {
1595+
foreach ($itemAttributes as $key => $value) {
1596+
$listItem->setAttribute($key, $value);
1597+
}
1598+
}
15331599
$list->appendChild($listItem);
15341600
}
15351601

tests/TestCase/DjotConverterTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,54 @@ public function testOrderedListStartNumber(): void
758758
$this->assertStringContainsString('First', $result);
759759
}
760760

761+
public function testListItemAttributes(): void
762+
{
763+
$djot = "- item 1\n {.highlight}\n- item 2";
764+
765+
$result = $this->converter->convert($djot);
766+
767+
$this->assertStringContainsString('<li class="highlight">', $result);
768+
$this->assertStringContainsString('item 1', $result);
769+
$this->assertStringContainsString('item 2', $result);
770+
}
771+
772+
public function testListItemAttributesWithId(): void
773+
{
774+
$djot = "- first item\n {#first .important}\n- second item";
775+
776+
$result = $this->converter->convert($djot);
777+
778+
$this->assertStringContainsString('<li id="first" class="important">', $result);
779+
}
780+
781+
public function testListItemAttributesWithCustomAttribute(): void
782+
{
783+
$djot = "- item\n {data-value=\"test\"}";
784+
785+
$result = $this->converter->convert($djot);
786+
787+
$this->assertStringContainsString('data-value="test"', $result);
788+
}
789+
790+
public function testOrderedListItemAttributes(): void
791+
{
792+
$djot = "1. first\n {.step-one}\n2. second";
793+
794+
$result = $this->converter->convert($djot);
795+
796+
$this->assertStringContainsString('<li class="step-one">', $result);
797+
}
798+
799+
public function testTaskListItemAttributes(): void
800+
{
801+
$djot = "- [x] done\n {.completed}\n- [ ] pending";
802+
803+
$result = $this->converter->convert($djot);
804+
805+
$this->assertStringContainsString('<li class="completed">', $result);
806+
$this->assertStringContainsString('checked=""', $result);
807+
}
808+
761809
public function testRomanNumeralList(): void
762810
{
763811
// x. is parsed as Roman numeral 10

0 commit comments

Comments
 (0)