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
66 changes: 66 additions & 0 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
*/
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
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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);
}

Expand Down
48 changes: 48 additions & 0 deletions tests/TestCase/DjotConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<li class="highlight">', $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('<li id="first" class="important">', $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('<li class="step-one">', $result);
}

public function testTaskListItemAttributes(): void
{
$djot = "- [x] done\n {.completed}\n- [ ] pending";

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

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

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