Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ $html = $parser->parse('<div class="note">text</div>', allowRawHtml: true);
| Paragraph | plain text | `<p>` |
| Blockquote (nested) | `> text` | `<blockquote>` |
| Unordered list | `- item` / `* item` / `+ item` | `<ul><li>` |
| Ordered list | `1. item` | `<ol><li>` |
| Ordered list | `1. item` / `1) item` (start number kept: `3. item`) | `<ol><li>` / `<ol start="3">` |
| Nested lists | indented `- item` inside list item | `<ul>` inside `<li>` |
| Task list | `- [x] done` / `- [ ] todo` | `<li><input type="checkbox" …>` |
| Fenced code block | ```` ```lang … ``` ```` | `<pre><code class="language-*">` |
Expand Down
135 changes: 123 additions & 12 deletions src/Lexer/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ final class Lexer
private const PATTERN_HEADING = '/^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/';
private const PATTERN_FENCED_OPEN = '/^ {0,3}([`~]{3,})\s*(\S*)\s*$/';
private const PATTERN_BLOCKQUOTE = '/^ {0,3}((?:>[ \t]*)++)(.*)/';
private const PATTERN_UNORDERED_LIST = '/^( *)[-*+]\s+(.+)/';
private const PATTERN_ORDERED_LIST = '/^( *)\d+\.\s+(.+)/';
/** Bullet list item: [1]=indent, [2]=marker, [3]=spaces after marker, [4]=content. */
private const PATTERN_UNORDERED_LIST = '/^( *)([-*+])([ \t]+)(\S.*)/';
/** Ordered list item (CommonMark §5.2: 1–9 digits, '.' or ')'): [1]=indent, [2]=number, [3]=delimiter, [4]=spaces, [5]=content. */
private const PATTERN_ORDERED_LIST = '/^( *)(\d{1,9})([.)])([ \t]+)(\S.*)/';
private const PATTERN_HORIZONTAL_RULE = '/^[ \t]{0,3}([-*_])([ \t]*\1){2,}[ \t]*$/';
private const PATTERN_LINK_DEFINITION = '/^\[([^\]\[]+)\]:\s+(?:<((?:[^<>\\\\\n]|\\\\.)*)>|(\S+))(?:\s+(?:"((?:[^"\\\\]|\\\\.)*)"|\'((?:[^\'\\\\]|\\\\.)*)\'|\(((?:[^()\\\\]|\\\\.)*)\)))?$/';
/** Matches a standalone title line (CommonMark §4.7 multiline link ref definition). */
Expand Down Expand Up @@ -103,6 +105,13 @@ public function tokenize(string $markdown): array
$bqParagraphOpen = false;
$bqFenceOpen = false;

// Content columns of the open list items, outermost first (CommonMark §5.2):
// an item is nested in the last open item whose content column it reaches.
$listColumns = [];
$listOpenCache = [0, false];
/** @var array<int, list<string>> $itemContinuations continuation lines, by LIST_ITEM token index */
$itemContinuations = [];

$inFootnoteBody = false;
$footnoteBodyLabel = '';
$footnoteBodyLines = [];
Expand Down Expand Up @@ -366,13 +375,28 @@ public function tokenize(string $markdown): array
continue;
}

// Start new indented code block (only when no paragraph was active,
// and only when the line is not a list item — list items with leading spaces
// are handled by matchLine() via PATTERN_UNORDERED_LIST / PATTERN_ORDERED_LIST).
$listOpen = $this->lastNonBlankIsListItem($tokens, $listOpenCache);

// List item continuation: paragraph text right after an item (no blank line in
// between) continues that item's text, whether indented or lazy (§5.2, §5.1).
$lastToken = end($tokens);
if ($lastToken instanceof Token
&& $lastToken->type === TokenType::LIST_ITEM
&& $pendingLines === []
&& $this->isParagraphContinuation($line)
&& !preg_match(self::PATTERN_FOOTNOTE_DEF, $line)
) {
// Joined once at the end: rebuilding the token per line would be quadratic.
$itemContinuations[array_key_last($tokens)][] = ltrim($line, " \t");
continue;
}

// Start new indented code block (only when no paragraph was active). A line
// indented 4+ columns is a list item only inside an open list.
if (!$hadPendingLines
&& preg_match('/^ (.*)$/s', $expanded, $m)
&& !preg_match(self::PATTERN_UNORDERED_LIST, $line)
&& !preg_match(self::PATTERN_ORDERED_LIST, $line)
&& (!$listOpen
|| (!preg_match(self::PATTERN_UNORDERED_LIST, $line) && !preg_match(self::PATTERN_ORDERED_LIST, $line)))
) {
$inIndentedBlock = true;
$indentedLines = [$this->stripLeadingColumns($line, 4)];
Expand All @@ -382,6 +406,10 @@ public function tokenize(string $markdown): array

$token = $this->matchLine($line);

if ($token->type === TokenType::LIST_ITEM) {
$token = $this->placeListItem($token, $line, $listOpen, $hadPendingLines, $listColumns);
}

// GFM tables: a header row is only a table when the next line is a delimiter
// row with the same number of cells; rows then continue until a blank line
// or the start of another block.
Expand Down Expand Up @@ -468,6 +496,11 @@ public function tokenize(string $markdown): array
$tokens[] = $token;
}

foreach ($itemContinuations as $idx => $continuation) {
$item = $tokens[$idx];
$tokens[$idx] = new Token(TokenType::LIST_ITEM, $item->content . "\n" . implode("\n", $continuation), $item->meta);
}

// Flush any remaining pending link def (no continuation title followed)
if ($pendingLinkDef !== null) {
$tokens[] = $pendingLinkDef;
Expand Down Expand Up @@ -524,6 +557,67 @@ public function tokenize(string $markdown): array
return $tokens;
}

/**
* Column where an item's content starts: after the marker and 1–4 spaces
* (5+ spaces count as one: the rest is indented code, §5.2 rule 2).
*/
private function listContentColumn(int $markerEnd, string $spacing): int
{
$width = strlen($this->expandTabs($spacing, $markerEnd));
return $markerEnd + ($width >= 5 ? 1 : $width);
}

/**
* Decide whether a matched list line really is a list item and at which depth.
*
* - Outside a list, a line indented 4+ columns is not a list item (it is code or
* paragraph text), and an ordered item can only interrupt a paragraph when it
* starts at 1 (§5.2).
* - Depth is the number of open items whose content column the marker reaches.
*
* @param list<int> $listColumns open items' content columns (updated in place)
*/
private function placeListItem(Token $token, string $line, bool $listOpen, bool $inParagraph, array &$listColumns): Token
{
if (!$listOpen) {
$listColumns = [];
if ($token->meta['indent'] >= 4 || ($inParagraph && $token->meta['ordered'] && $token->meta['start'] !== 1)) {
return new Token(TokenType::PARAGRAPH, $line);
}
}

while ($listColumns !== [] && end($listColumns) > $token->meta['indent']) {
array_pop($listColumns);
}
$meta = $token->meta;
$meta['depth'] = count($listColumns);
$listColumns[] = (int) $meta['contentCol'];

return new Token(TokenType::LIST_ITEM, $token->content, $meta);
}

/**
* Whether the last non-blank token is a list item (blank lines may separate items
* of the same list). Only tokens added since the previous call are examined, so a
* long run of blank lines stays linear.
*
* @param Token[] $tokens
* @param array{int, bool} $cache [tokens examined so far, result]
*/
private function lastNonBlankIsListItem(array $tokens, array &$cache): bool
{
[$seen, $result] = $cache;
$count = count($tokens);
for ($i = $count - 1; $i >= $seen; $i--) {
if ($tokens[$i]->type !== TokenType::BLANK) {
$result = $tokens[$i]->type === TokenType::LIST_ITEM;
break;
}
}
$cache = [$count, $result];
return $result;
}

/** @return array{string, ?bool} */
private function extractTaskChecked(string $content): array
{
Expand Down Expand Up @@ -651,7 +745,7 @@ private function quoteLineState(string $content, bool $paragraphOpen, bool $fenc
}
$isText = $this->isParagraphContinuation($content)
|| ($paragraphOpen && $content !== '' && !ctype_space($content) && !preg_match(self::PATTERN_HEADING, $content));
return [$isText && !preg_match(self::PATTERN_TABLE_SEPARATOR, $content), false];
return [$isText, false];
}

private function matchLine(string $line): Token
Expand Down Expand Up @@ -688,21 +782,38 @@ private function matchLine(string $line): Token
);
}

// 'depth' is a placeholder: tokenize() computes it from the open items' columns.
if (preg_match(self::PATTERN_UNORDERED_LIST, $line, $m)) {
[$content, $checked] = $this->extractTaskChecked(trim($m[2]));
[$content, $checked] = $this->extractTaskChecked(trim($m[4]));
return new Token(
TokenType::LIST_ITEM,
$content,
['ordered' => false, 'depth' => (int) floor(strlen($m[1]) / 2), 'checked' => $checked],
[
'ordered' => false,
'depth' => 0,
'checked' => $checked,
'marker' => $m[2],
'start' => null,
'indent' => strlen($m[1]),
'contentCol' => $this->listContentColumn(strlen($m[1]) + 1, $m[3]),
],
);
}

if (preg_match(self::PATTERN_ORDERED_LIST, $line, $m)) {
[$content, $checked] = $this->extractTaskChecked(trim($m[2]));
[$content, $checked] = $this->extractTaskChecked(trim($m[5]));
return new Token(
TokenType::LIST_ITEM,
$content,
['ordered' => true, 'depth' => (int) floor(strlen($m[1]) / 2), 'checked' => $checked],
[
'ordered' => true,
'depth' => 0,
'checked' => $checked,
'marker' => $m[3],
'start' => (int) $m[2],
'indent' => strlen($m[1]),
'contentCol' => $this->listContentColumn(strlen($m[1]) + strlen($m[2]) + 1, $m[4]),
],
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/Node/Block/ListNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public function __construct(
public bool $loose = false,
/** @var ListItemNode[] */
public array $children = [],
/** First number of an ordered list (null for bullet lists). */
public ?int $start = null,
) {
}
}
16 changes: 11 additions & 5 deletions src/Parser/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -251,27 +251,32 @@ private function buildList(array $tokens, int &$i, int $depth): ListNode
{
$count = count($tokens);
$ordered = $tokens[$i]->meta['ordered'];
// Changing the bullet char or the ordered delimiter starts a new list (§5.3).
$marker = $tokens[$i]->meta['marker'] ?? null;
$start = $tokens[$i]->meta['start'] ?? null;
$items = [];
$loose = false;

while ($i < $count
&& $tokens[$i]->type === TokenType::LIST_ITEM
&& $tokens[$i]->meta['depth'] === $depth
&& $tokens[$i]->meta['ordered'] === $ordered
&& ($tokens[$i]->meta['marker'] ?? null) === $marker
) {
$itemToken = $tokens[$i];
$inlineChildren = $this->inlineParser->parse($itemToken->content, $this->linkRefs, $this->footnoteDefs);
$i++;

// If the next token is a deeper-level list item, recurse.
// Deeper list items belong to this item. Several sub-lists may follow each
// other (e.g. an <ol> then a <ul>: changing type starts a new list), so keep
// recursing until the next token is no longer deeper.
$nodeChildren = $inlineChildren;
if ($i < $count
while ($i < $count
&& $tokens[$i]->type === TokenType::LIST_ITEM
&& $tokens[$i]->meta['depth'] > $depth
&& $depth < 32
) {
$subList = $this->buildList($tokens, $i, $tokens[$i]->meta['depth']);
$nodeChildren = [...$inlineChildren, $subList];
$nodeChildren[] = $this->buildList($tokens, $i, $tokens[$i]->meta['depth']);
}

// Peek ahead for blank tokens. Consume only when the token after the blank run
Expand All @@ -285,6 +290,7 @@ private function buildList(array $tokens, int &$i, int $depth): ListNode
&& $tokens[$j]->type === TokenType::LIST_ITEM
&& $tokens[$j]->meta['depth'] === $depth
&& $tokens[$j]->meta['ordered'] === $ordered
&& ($tokens[$j]->meta['marker'] ?? null) === $marker
) {
$i = $j;
$loose = true;
Expand All @@ -293,7 +299,7 @@ private function buildList(array $tokens, int &$i, int $depth): ListNode
$items[] = new ListItemNode(children: $nodeChildren, checked: $itemToken->meta['checked'] ?? null);
}

return new ListNode(ordered: $ordered, loose: $loose, children: $items);
return new ListNode(ordered: $ordered, loose: $loose, children: $items, start: $ordered ? ($start ?? 1) : null);
}

/**
Expand Down
5 changes: 4 additions & 1 deletion src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ private function renderList(ListNode $node): string
foreach ($node->children as $item) {
$inner .= $this->renderListItem($item, $node->loose);
}
return '<' . $tag . ">\n" . $inner . '</' . $tag . ">\n";
$startAttr = ($node->ordered && $node->start !== null && $node->start !== 1)
? ' start="' . $node->start . '"'
: '';
return '<' . $tag . $startAttr . ">\n" . $inner . '</' . $tag . ">\n";
}

private function renderListItem(ListItemNode $node, bool $loose = false): string
Expand Down
85 changes: 85 additions & 0 deletions tests/Integration/ListItemTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace PhpMarkdown\Tests\Integration;

use PhpMarkdown\MarkdownParser;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
* List items and lists (CommonMark §5.2–5.3): start number, delimiters, nesting by
* content column, continuation lines, 4-space indentation.
*/
final class ListItemTest extends TestCase
{
/** @return array<string, array{string, string}> */
public static function cases(): array
{
return [
'ordered list keeps its start number' => [
"3. three\n4. four",
"<ol start=\"3\">\n<li>three</li>\n<li>four</li>\n</ol>\n",
],
'closing parenthesis delimiter' => [
"1) a\n2) b",
"<ol>\n<li>a</li>\n<li>b</li>\n</ol>\n",
],
'indented and lazy continuation lines' => [
"- item\n continued\nlazy\n- b",
"<ul>\n<li>item\ncontinued\nlazy</li>\n<li>b</li>\n</ul>\n",
],
'changing bullet char starts a new list' => [
"- a\n+ b",
"<ul>\n<li>a</li>\n</ul>\n<ul>\n<li>b</li>\n</ul>\n",
],
'changing ordered delimiter starts a new list' => [
"1. a\n1) b",
"<ol>\n<li>a</li>\n</ol>\n<ol>\n<li>b</li>\n</ol>\n",
],
'ordered item not starting at 1 cannot interrupt a paragraph' => [
"para\n2. not a list",
"<p>para\n2. not a list</p>\n",
],
'four-space marker outside a list is code' => [
' - foo',
"<pre><code>- foo\n</code></pre>\n",
],
'nesting follows the content column of wide markers' => [
"10. a\n - b\n11. c",
"<ol start=\"10\">\n<li>a<ul>\n<li>b</li>\n</ul>\n</li>\n<li>c</li>\n</ol>\n",
],
'not enough indentation is a sibling, not a child' => [
"- a\n - b",
"<ul>\n<li>a</li>\n<li>b</li>\n</ul>\n",
],
'item with an ordered then a bullet sub-list' => [
"- a\n 1. b\n - c\n- d",
"<ul>\n<li>a<ol>\n<li>b</li>\n</ol>\n<ul>\n<li>c</li>\n</ul>\n</li>\n<li>d</li>\n</ul>\n",
],
'sub-lists with different bullets stay in the same item' => [
"1. a\n - b\n + c\n2. d",
"<ol>\n<li>a<ul>\n<li>b</li>\n</ul>\n<ul>\n<li>c</li>\n</ul>\n</li>\n<li>d</li>\n</ol>\n",
],
'heading after an item is not a continuation' => [
"- a\n# b",
"<ul>\n<li>a</li>\n</ul>\n<h1>b</h1>\n",
],
];
}

#[DataProvider('cases')]
public function testList(string $markdown, string $expected): void
{
$this->assertSame($expected, (new MarkdownParser())->parse($markdown));
}

public function testManyContinuationLinesStayLinear(): void
{
$start = microtime(true);
(new MarkdownParser())->parse("- a\n" . str_repeat("b\n", 200_000));
(new MarkdownParser())->parse(str_repeat("\n", 500_000));
$this->assertLessThan(5.0, microtime(true) - $start);
}
}
Loading
Loading