diff --git a/src/Node/Block/ListItem.php b/src/Node/Block/ListItem.php index f6eea4e..3a6a412 100644 --- a/src/Node/Block/ListItem.php +++ b/src/Node/Block/ListItem.php @@ -9,21 +9,68 @@ */ class ListItem extends BlockNode { - public function __construct(protected ?bool $checked = null) - { + /** + * @var string + */ + public const STATE_PENDING = 'pending'; + + /** + * @var string + */ + public const STATE_DONE = 'done'; + + /** + * @var string + */ + public const STATE_CANCELLED = 'cancelled'; + + /** + * @var string + */ + public const STATE_DEFERRED = 'deferred'; + + /** + * @var string + */ + public const STATE_QUESTION = 'question'; + + public function __construct( + protected ?bool $checked = null, + protected ?string $taskState = null, + ) { + // BC: derive taskState from checked if not explicitly set + if ($this->taskState === null && $this->checked !== null) { + $this->taskState = $this->checked ? self::STATE_DONE : self::STATE_PENDING; + } + // Also derive checked from taskState for BC + if ($this->taskState !== null && $this->checked === null) { + $this->checked = $this->taskState === self::STATE_DONE; + } } /** * For task lists: null = not a task, true = checked, false = unchecked + * + * @deprecated Use getTaskState() for extended states */ public function getChecked(): ?bool { return $this->checked; } + /** + * Get the task state for extended task lists + * + * @return string|null One of STATE_* constants, or null if not a task + */ + public function getTaskState(): ?string + { + return $this->taskState; + } + public function isTask(): bool { - return $this->checked !== null; + return $this->taskState !== null; } public function getType(): string diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index a8c705b..7d7b652 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -1511,7 +1511,10 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int $list->setTight(false); } - $listItem = new ListItem($itemInfo['checked'] ?? null); + $listItem = new ListItem( + $itemInfo['checked'] ?? null, + $itemInfo['taskState'] ?? null, + ); $itemContent = $itemInfo['content']; // Collect item content lines (without blank line = tight continuation) @@ -1934,14 +1937,24 @@ protected function disambiguateListStyle(array $listInfo, array $lines, int $sta */ protected function parseListItemMarker(string $line): ?array { - // Task list: - [ ] or - [x] or - [X] + // Task list: - [ ] or - [x] or - [X] or extended states: [-] [>] [?] // Space after marker is syntax delimiter - must be space(s) per spec, not tab - if (preg_match('/^[-*+] +\[([ xX])\] +(.*)$/', $line, $matches)) { + if (preg_match('/^[-*+] +\[([ xX\->?])\] +(.*)$/', $line, $matches)) { + $marker = $matches[1]; + $taskState = match (strtolower($marker)) { + ' ' => ListItem::STATE_PENDING, + 'x' => ListItem::STATE_DONE, + '-' => ListItem::STATE_CANCELLED, + '>' => ListItem::STATE_DEFERRED, + default => ListItem::STATE_QUESTION, // '?' is the only remaining option from regex + }; + return [ 'type' => ListBlock::TYPE_TASK, 'marker' => '-', 'content' => $matches[2], - 'checked' => strtolower($matches[1]) === 'x', + 'checked' => $taskState === ListItem::STATE_DONE, + 'taskState' => $taskState, ]; } diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 91bc7ec..89a418d 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -558,10 +558,23 @@ protected function renderListItem(ListItem $node, bool $tight = true): string // Handle task list items if ($node->isTask()) { + $taskState = $node->getTaskState(); $checked = $node->getChecked() ? ' checked=""' : ''; + + // Add data-state for extended task states (cancelled, deferred, question) + $dataState = ''; + if ($taskState !== null && !in_array($taskState, [ListItem::STATE_PENDING, ListItem::STATE_DONE], true)) { + $dataState = ' data-state="' . $taskState . '"'; + } + // Always use xhtml-style format for task list checkboxes - $checkbox = '\n"; + $checkbox = '\n"; $content = $checkbox . rtrim($content); + + // Add class to li for extended states + if ($taskState !== null && $taskState !== ListItem::STATE_PENDING && $taskState !== ListItem::STATE_DONE) { + $attrs .= ' class="task-' . $taskState . '"'; + } } else { $content = rtrim($content); } diff --git a/tests/TestCase/Parser/BlockParserTest.php b/tests/TestCase/Parser/BlockParserTest.php index 20a1e38..3348b34 100644 --- a/tests/TestCase/Parser/BlockParserTest.php +++ b/tests/TestCase/Parser/BlockParserTest.php @@ -10,6 +10,7 @@ use Djot\Node\Block\Div; use Djot\Node\Block\Heading; use Djot\Node\Block\ListBlock; +use Djot\Node\Block\ListItem; use Djot\Node\Block\Paragraph; use Djot\Node\Block\Table; use Djot\Node\Block\ThematicBreak; @@ -147,6 +148,45 @@ public function testParseTaskList(): void $this->assertSame('list', $list->getType()); } + public function testParseExtendedTaskStates(): void + { + $doc = $this->parser->parse( + "- [ ] Pending\n- [x] Done\n- [-] Cancelled\n- [>] Deferred\n- [?] Question", + ); + + $list = $doc->getChildren()[0]; + $this->assertInstanceOf(ListBlock::class, $list); + $this->assertSame(ListBlock::TYPE_TASK, $list->getListType()); + + $items = $list->getChildren(); + $this->assertCount(5, $items); + + // Pending [ ] + $this->assertInstanceOf(ListItem::class, $items[0]); + $this->assertSame(ListItem::STATE_PENDING, $items[0]->getTaskState()); + $this->assertFalse($items[0]->getChecked()); + + // Done [x] + $this->assertInstanceOf(ListItem::class, $items[1]); + $this->assertSame(ListItem::STATE_DONE, $items[1]->getTaskState()); + $this->assertTrue($items[1]->getChecked()); + + // Cancelled [-] + $this->assertInstanceOf(ListItem::class, $items[2]); + $this->assertSame(ListItem::STATE_CANCELLED, $items[2]->getTaskState()); + $this->assertFalse($items[2]->getChecked()); + + // Deferred [>] + $this->assertInstanceOf(ListItem::class, $items[3]); + $this->assertSame(ListItem::STATE_DEFERRED, $items[3]->getTaskState()); + $this->assertFalse($items[3]->getChecked()); + + // Question [?] + $this->assertInstanceOf(ListItem::class, $items[4]); + $this->assertSame(ListItem::STATE_QUESTION, $items[4]->getTaskState()); + $this->assertFalse($items[4]->getChecked()); + } + public function testParseDefinitionList(): void { $doc = $this->parser->parse(": Term\n\n Definition"); diff --git a/tests/TestCase/Renderer/HtmlRendererTest.php b/tests/TestCase/Renderer/HtmlRendererTest.php index ab38544..6d0de5f 100644 --- a/tests/TestCase/Renderer/HtmlRendererTest.php +++ b/tests/TestCase/Renderer/HtmlRendererTest.php @@ -6,6 +6,8 @@ use Djot\Node\Block\CodeBlock; use Djot\Node\Block\Heading; +use Djot\Node\Block\ListBlock; +use Djot\Node\Block\ListItem; use Djot\Node\Block\Paragraph; use Djot\Node\Document; use Djot\Node\Inline\Code; @@ -247,4 +249,45 @@ public function testNestedInlineElements(): void $this->assertSame("

bold and italic

\n", $result); } + + public function testRenderExtendedTaskStates(): void + { + $doc = new Document(); + $list = new ListBlock(ListBlock::TYPE_TASK); + + // Cancelled task [-] + $item1 = new ListItem(null, ListItem::STATE_CANCELLED); + $para1 = new Paragraph(); + $para1->appendChild(new Text('Cancelled task')); + $item1->appendChild($para1); + $list->appendChild($item1); + + // Deferred task [>] + $item2 = new ListItem(null, ListItem::STATE_DEFERRED); + $para2 = new Paragraph(); + $para2->appendChild(new Text('Deferred task')); + $item2->appendChild($para2); + $list->appendChild($item2); + + // Question task [?] + $item3 = new ListItem(null, ListItem::STATE_QUESTION); + $para3 = new Paragraph(); + $para3->appendChild(new Text('Question task')); + $item3->appendChild($para3); + $list->appendChild($item3); + + $doc->appendChild($list); + + $result = $this->renderer->render($doc); + + // Check for data-state attributes + $this->assertStringContainsString('data-state="cancelled"', $result); + $this->assertStringContainsString('data-state="deferred"', $result); + $this->assertStringContainsString('data-state="question"', $result); + + // Check for task-* classes on li elements + $this->assertStringContainsString('class="task-cancelled"', $result); + $this->assertStringContainsString('class="task-deferred"', $result); + $this->assertStringContainsString('class="task-question"', $result); + } }