diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index f51177d..798e676 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -712,6 +712,41 @@ protected function processAttributeEscapes(string $value): string return preg_replace('/\\\\(.)/', '$1', $value) ?? $value; } + /** + * Parse attribute string and return as array (without affecting pendingAttributes) + * + * @return array + */ + protected function parseAttributeStringToArray(string $attrStr): array + { + $attributes = []; + + // Parse .class + if (preg_match_all('/\.([^\s.#=}]+)/', $attrStr, $classMatches)) { + $attributes['class'] = implode(' ', $classMatches[1]); + } + + // Parse #id + if (preg_match('/#([^\s.#=}]+)/', $attrStr, $idMatch)) { + $attributes['id'] = $idMatch[1]; + } + + // Parse key="double quoted value", key='single quoted value', or key=unquoted + 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)) { + foreach ($kvMatches as $match) { + if (($match[1] ?? '') !== '') { + $attributes[$match[1]] = $this->processAttributeEscapes($match[2] ?? ''); + } elseif (($match[3] ?? '') !== '') { + $attributes[$match[3]] = $this->processAttributeEscapes($match[4] ?? ''); + } elseif (($match[5] ?? '') !== '') { + $attributes[$match[5]] = $match[6] ?? ''; + } + } + } + + return $attributes; + } + /** * Apply pending attributes to a node and clear them */ @@ -1508,6 +1543,15 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int } } + // Check for list item attributes (must be at content indent, be a standalone attribute block) + if ( + $nextIndent >= $contentIndent && + preg_match('/^\{([^{}]+)\}\s*$/', $nextTrimmed, $attrMatch) + ) { + // This is a list item attribute line - don't add to content + break; + } + // Content at content indent or more is continuation (even if it looks like a list marker) // In djot, " - b" after "- a" (no blank line) is literal text, not a nested list if ($nextIndent >= $contentIndent) { @@ -1521,6 +1565,21 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int $i++; } + // Check for list item attributes on the next line + $itemAttributes = []; + 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) && + $this->getLeadingSpaces($potentialAttrLine) >= $contentIndent + ) { + $itemAttributes = $this->parseAttributeStringToArray($attrMatch[1]); + $i++; + } + } + // For tight lists with continuation lines, parse as plain text // This prevents "-like" lines from being parsed as nested lists if ($hasNonMarkerContinuation) { @@ -1530,6 +1589,13 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int } else { $this->parseBlocks($listItem, $itemLines, 0); } + + // Apply attributes to list item + if ($itemAttributes !== []) { + foreach ($itemAttributes as $key => $value) { + $listItem->setAttribute($key, $value); + } + } $list->appendChild($listItem); } diff --git a/tests/TestCase/DjotConverterTest.php b/tests/TestCase/DjotConverterTest.php index b7a1b6d..e534674 100644 --- a/tests/TestCase/DjotConverterTest.php +++ b/tests/TestCase/DjotConverterTest.php @@ -758,6 +758,54 @@ public function testOrderedListStartNumber(): void $this->assertStringContainsString('First', $result); } + public function testListItemAttributes(): void + { + $djot = "- item 1\n {.highlight}\n- item 2"; + + $result = $this->converter->convert($djot); + + $this->assertStringContainsString('
  • ', $result); + $this->assertStringContainsString('item 1', $result); + $this->assertStringContainsString('item 2', $result); + } + + public function testListItemAttributesWithId(): void + { + $djot = "- first item\n {#first .important}\n- second item"; + + $result = $this->converter->convert($djot); + + $this->assertStringContainsString('
  • ', $result); + } + + public function testListItemAttributesWithCustomAttribute(): void + { + $djot = "- item\n {data-value=\"test\"}"; + + $result = $this->converter->convert($djot); + + $this->assertStringContainsString('data-value="test"', $result); + } + + public function testOrderedListItemAttributes(): void + { + $djot = "1. first\n {.step-one}\n2. second"; + + $result = $this->converter->convert($djot); + + $this->assertStringContainsString('
  • ', $result); + } + + public function testTaskListItemAttributes(): void + { + $djot = "- [x] done\n {.completed}\n- [ ] pending"; + + $result = $this->converter->convert($djot); + + $this->assertStringContainsString('
  • ', $result); + $this->assertStringContainsString('checked=""', $result); + } + public function testRomanNumeralList(): void { // x. is parsed as Roman numeral 10