Skip to content
Draft
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
53 changes: 50 additions & 3 deletions src/Node/Block/ListItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
];
}

Expand Down
15 changes: 14 additions & 1 deletion src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<input disabled="" type="checkbox"' . $checked . "/>\n";
$checkbox = '<input disabled="" type="checkbox"' . $checked . $dataState . "/>\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);
}
Expand Down
40 changes: 40 additions & 0 deletions tests/TestCase/Parser/BlockParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
43 changes: 43 additions & 0 deletions tests/TestCase/Renderer/HtmlRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -247,4 +249,45 @@ public function testNestedInlineElements(): void

$this->assertSame("<p><strong><em>bold and italic</em></strong></p>\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);
}
}