Skip to content

Commit c4646e3

Browse files
authored
fix: parse nested list blocks with deeper indentation and tabs (#192)
* fix: parse nested list blocks with deeper indentation and tabs * feat: add nestedBlocksInLists parser option (#191) * fix: address review feedback for nested blocks in lists - recognize all list-marker forms (parenthesized, alpha, roman numerals) when detecting immediate nested blocks, consistent with the list-marker parser - base nested-content membership on the item content indent so an over-indented first nested line keeps trailing item content instead of detaching it into a top-level block and ending the list early - correct the contradictory docblock in the tab-indentation nesting test - document the nestedBlocksInLists option in the parser-options guide and the API reference
1 parent 3d57651 commit c4646e3

8 files changed

Lines changed: 460 additions & 69 deletions

File tree

docs/guide/parser-options.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,79 @@ $converter = new DjotConverter(
268268
softBreakMode: SoftBreakMode::Break, // Optional: visible line breaks
269269
);
270270
```
271+
272+
## Nested Blocks in Lists Mode
273+
274+
`nestedBlocksInLists` is a focused subset of [Significant Newlines](#significant-newlines-mode) mode. It lets indentation alone introduce nested blocks - sublists, blockquotes, and fenced code - inside an already-open list item, **without** enabling paragraph interruption anywhere else.
275+
276+
Use it when you want markdown-like nested lists but otherwise spec-compliant djot: top-level paragraphs still require a blank line before any block.
277+
278+
### Enabling Nested Blocks in Lists Mode
279+
280+
```php
281+
use Djot\DjotConverter;
282+
use Djot\Parser\BlockParser;
283+
284+
// Method 1: Factory method
285+
$converter = DjotConverter::withNestedBlocksInLists();
286+
287+
// Method 2: Constructor parameter
288+
$converter = new DjotConverter(nestedBlocksInLists: true);
289+
290+
// Method 3: Directly on the parser
291+
$parser = new BlockParser(nestedBlocksInLists: true);
292+
$parser->setNestedBlocksInLists(true);
293+
```
294+
295+
### Behavior
296+
297+
Indented content nests inside the open list item, even without a blank line:
298+
299+
```php
300+
$converter = DjotConverter::withNestedBlocksInLists();
301+
$result = $converter->convert("- Item
302+
- Nested one
303+
- Nested two");
304+
```
305+
306+
Output:
307+
```html
308+
<ul>
309+
<li>
310+
Item
311+
<ul>
312+
<li>
313+
Nested one
314+
</li>
315+
<li>
316+
Nested two
317+
</li>
318+
</ul>
319+
</li>
320+
</ul>
321+
```
322+
323+
But a top-level block still does **not** interrupt a paragraph (this is what differs from significant newlines mode):
324+
325+
```php
326+
$converter = DjotConverter::withNestedBlocksInLists();
327+
$result = $converter->convert("Here is a list:
328+
- one
329+
- two");
330+
```
331+
332+
Output:
333+
```html
334+
<p>Here is a list:
335+
- one
336+
- two</p>
337+
```
338+
339+
### nestedBlocksInLists vs significantNewlines
340+
341+
| Behavior | Default | `nestedBlocksInLists` | `significantNewlines` |
342+
|------------------------------------------------------|---------|-----------------------|-----------------------|
343+
| Nested blocks in list items without a blank line | No | **Yes** | Yes |
344+
| Lists/blockquotes/headings interrupt top-level paragraphs | No | No | Yes |
345+
346+
Note: `significantNewlines` implies `nestedBlocksInLists` - enabling the former turns on the latter automatically.

docs/reference/api.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public function __construct(
2525
bool $roundTripMode = false,
2626
?BlockParser $parser = null,
2727
?RendererInterface $renderer = null,
28+
bool $nestedBlocksInLists = false,
2829
)
2930
```
3031

@@ -38,6 +39,7 @@ public function __construct(
3839
- `$roundTripMode`: When `true`, adds round-trip metadata for Djot→HTML→Djot workflows (HTML renderer only).
3940
- `$parser`: Optional pre-configured parser. When provided, inline parser constructor flags such as `warnings`, `strict`, and `significantNewlines` are ignored.
4041
- `$renderer`: Optional pre-configured renderer. When provided, inline renderer constructor flags such as `xhtml`, `safeMode`, `softBreakMode`, and `roundTripMode` are ignored.
42+
- `$nestedBlocksInLists`: When `true`, indentation alone introduces nested blocks inside list items without a blank line, while top-level paragraph interruption stays spec-compliant (see [Nested Blocks in Lists Mode](/guide/parser-options#nested-blocks-in-lists-mode)). Implied by `$significantNewlines`.
4143

4244
### Factory Methods
4345

@@ -57,6 +59,22 @@ public static function withSignificantNewlines(
5759

5860
Creates a converter with significant newlines mode enabled. See [Significant Newlines Mode](#significant-newlines-mode).
5961

62+
#### withNestedBlocksInLists()
63+
64+
```php
65+
public static function withNestedBlocksInLists(
66+
bool $xhtml = false,
67+
bool $warnings = false,
68+
bool $strict = false,
69+
bool|SafeMode|null $safeMode = null,
70+
?Profile $profile = null,
71+
?SoftBreakMode $softBreakMode = null,
72+
bool $roundTripMode = false,
73+
): self
74+
```
75+
76+
Creates a converter that enables nested blocks in list items without requiring blank lines, while leaving top-level paragraph interruption at the spec default. See [Nested Blocks in Lists Mode](/guide/parser-options#nested-blocks-in-lists-mode).
77+
6078
### Methods
6179

6280
#### convert()
@@ -500,6 +518,7 @@ $parser = new BlockParser(
500518
collectWarnings: false,
501519
strictMode: false,
502520
significantNewlines: false,
521+
nestedBlocksInLists: false,
503522
);
504523
$document = $parser->parse($djotString);
505524

@@ -510,6 +529,11 @@ $warnings = $parser->getWarnings();
510529
// Enable/disable significant newlines mode
511530
$parser->setSignificantNewlines(true);
512531
$isEnabled = $parser->getSignificantNewlines();
532+
533+
// Enable/disable nested blocks in list items only
534+
// (significant newlines mode enables this implicitly)
535+
$parser->setNestedBlocksInLists(true);
536+
$isEnabled = $parser->getNestedBlocksInLists();
513537
```
514538

515539
#### Custom Block Patterns

src/DjotConverter.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ public static function ansi(?BlockParser $parser = null): self
128128
* @param bool $roundTripMode Add data attributes for Djot→HTML→Djot round-trips (HTML renderer only)
129129
* @param \Djot\Parser\BlockParser|null $parser Pre-configured parser (ignores warnings/strict/significantNewlines if set)
130130
* @param \Djot\Renderer\RendererInterface|null $renderer Pre-configured renderer (ignores xhtml/safeMode/softBreakMode/roundTripMode if set)
131+
* @param bool $nestedBlocksInLists Allow nested blocks in list items without blank lines
131132
*/
132133
public function __construct(
133134
bool $xhtml = false,
@@ -140,6 +141,7 @@ public function __construct(
140141
bool $roundTripMode = false,
141142
?BlockParser $parser = null,
142143
?RendererInterface $renderer = null,
144+
bool $nestedBlocksInLists = false,
143145
) {
144146
$this->collectWarnings = $warnings;
145147
$this->strictMode = $strict;
@@ -148,7 +150,7 @@ public function __construct(
148150
if ($parser !== null) {
149151
$this->parser = $parser;
150152
} else {
151-
$this->parser = new BlockParser($warnings, $strict, $significantNewlines);
153+
$this->parser = new BlockParser($warnings, $strict, $significantNewlines, $nestedBlocksInLists);
152154
}
153155

154156
// Use provided renderer or create one from parameters
@@ -210,6 +212,42 @@ public static function withSignificantNewlines(
210212
return new self($xhtml, $warnings, $strict, $safeMode, $profile, true, $softBreakMode, $roundTripMode);
211213
}
212214

215+
/**
216+
* Create a converter that only enables nested blocks in list items.
217+
*
218+
* Paragraph interruption remains standard djot behavior, but indentation
219+
* alone can introduce nested sublists, blockquotes, and fenced blocks
220+
* inside an already-open list item.
221+
*
222+
* @param bool $xhtml Whether to use XHTML-compatible output
223+
* @param bool $warnings Whether to collect warnings during parsing
224+
* @param bool $strict Whether to throw exceptions on parse errors
225+
* @param \Djot\SafeMode|bool|null $safeMode Enable safe mode
226+
* @param \Djot\Profile|null $profile Profile for feature restriction
227+
* @param \Djot\Renderer\SoftBreakMode|null $softBreakMode How to render soft breaks (HTML renderer only)
228+
* @param bool $roundTripMode Add data attributes for round-trips (HTML renderer only)
229+
*/
230+
public static function withNestedBlocksInLists(
231+
bool $xhtml = false,
232+
bool $warnings = false,
233+
bool $strict = false,
234+
bool|SafeMode|null $safeMode = null,
235+
?Profile $profile = null,
236+
?SoftBreakMode $softBreakMode = null,
237+
bool $roundTripMode = false,
238+
): self {
239+
return new self(
240+
xhtml: $xhtml,
241+
warnings: $warnings,
242+
strict: $strict,
243+
safeMode: $safeMode,
244+
profile: $profile,
245+
softBreakMode: $softBreakMode,
246+
roundTripMode: $roundTripMode,
247+
nestedBlocksInLists: true,
248+
);
249+
}
250+
213251
/**
214252
* Enable or disable safe mode (HtmlRenderer only)
215253
*

src/Parser/BlockParser.php

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -152,14 +152,22 @@ class BlockParser
152152
*/
153153
protected bool $significantNewlines = false;
154154

155+
/**
156+
* When true, indentation alone can introduce nested blocks inside list items
157+
* without enabling global paragraph interruption.
158+
*/
159+
protected bool $nestedBlocksInLists = false;
160+
155161
public function __construct(
156162
bool $collectWarnings = false,
157163
bool $strictMode = false,
158164
bool $significantNewlines = false,
165+
bool $nestedBlocksInLists = false,
159166
) {
160167
$this->collectWarnings = $collectWarnings;
161168
$this->strictMode = $strictMode;
162169
$this->significantNewlines = $significantNewlines;
170+
$this->nestedBlocksInLists = $nestedBlocksInLists || $significantNewlines;
163171
$this->inlineParser = new InlineParser($this);
164172
$this->listParser = new ListParser();
165173
$this->tableParser = new TableParser();
@@ -179,6 +187,9 @@ public function __construct(
179187
public function setSignificantNewlines(bool $value): self
180188
{
181189
$this->significantNewlines = $value;
190+
if ($value) {
191+
$this->nestedBlocksInLists = true;
192+
}
182193

183194
return $this;
184195
}
@@ -191,6 +202,24 @@ public function getSignificantNewlines(): bool
191202
return $this->significantNewlines;
192203
}
193204

205+
/**
206+
* Enable or disable nested blocks in list items without blank lines.
207+
*/
208+
public function setNestedBlocksInLists(bool $value): self
209+
{
210+
$this->nestedBlocksInLists = $value;
211+
212+
return $this;
213+
}
214+
215+
/**
216+
* Check if nested blocks in list items are enabled.
217+
*/
218+
public function getNestedBlocksInLists(): bool
219+
{
220+
return $this->nestedBlocksInLists;
221+
}
222+
194223
/**
195224
* Register a custom block pattern
196225
*
@@ -1763,12 +1792,13 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
17631792

17641793
// Content at content indent or more is continuation (even if it looks like a list marker)
17651794
// In djot, " - b" after "- a" (no blank line) is literal text, not a nested list
1766-
// Unless significantNewlines is enabled, then indented block markers start nested blocks
1795+
// Unless nested blocks in lists are enabled, indented block markers
1796+
// are treated as plain continuation text here.
17671797
if ($nextIndent >= $contentIndent) {
1768-
// Check if significantNewlines mode allows immediate nested blocks
1769-
if ($this->significantNewlines) {
1798+
// Check if list nesting mode allows immediate nested blocks
1799+
if ($this->nestedBlocksInLists) {
17701800
// Check for any block starter (list, blockquote, code fence, div)
1771-
if ($this->startsNewBlock($nextTrimmed) || $this->listParser->parseListItemMarker($nextTrimmed) !== null) {
1801+
if ($this->isBlockElementStart($nextTrimmed)) {
17721802
// This is a nested block - break out to let normal nesting handle it
17731803
break;
17741804
}
@@ -1874,14 +1904,16 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
18741904
$this->parseBlocks($listItem, $itemLines, 0);
18751905
}
18761906

1877-
// In significantNewlines mode, check for immediate nested content (any block type)
1878-
if ($this->significantNewlines && $i < $count) {
1907+
// When nested blocks in lists are enabled, check for immediate
1908+
// nested content after the initial item paragraph.
1909+
if ($this->nestedBlocksInLists && $i < $count) {
18791910
$nextLine = $lines[$i];
18801911
$nextIndent = IndentationHelper::getLeadingSpaces($nextLine);
18811912

18821913
// If there's indented content that could be a nested block
18831914
if ($nextIndent >= $contentIndent) {
18841915
$subLines = [];
1916+
$nestedIndent = $nextIndent;
18851917
while ($i < $count) {
18861918
$subLine = $lines[$i];
18871919
if (IndentationHelper::isBlankLine($subLine)) {
@@ -1892,15 +1924,24 @@ protected function tryParseList(Node $parent, array $lines, int $start): ?int
18921924
continue;
18931925
}
18941926
$lineIndent = IndentationHelper::getLeadingSpaces($subLine);
1895-
if ($lineIndent >= $contentIndent) {
1896-
$subLines[] = IndentationHelper::stripLeadingIndent($subLine, $contentIndent);
1897-
$i++;
1898-
} elseif ($lineIndent === $baseIndent) {
1899-
// Back to parent level - check if it's a sibling item
1900-
break;
1901-
} else {
1927+
// Membership is the item's content indent, not the first
1928+
// nested line's (possibly deeper) indent: a line that drops
1929+
// back to the content indent is still item content and must
1930+
// be kept here, otherwise an over-indented first nested line
1931+
// would detach it into a top-level block and end the list early.
1932+
if ($lineIndent < $contentIndent) {
1933+
// Back to the parent (or a shallower) level: this item's
1934+
// nested content is done.
19021935
break;
19031936
}
1937+
// Normalize the over-indented nested block by its own indent
1938+
// so it starts at column 0 (required for the sub-parse to
1939+
// recognize it as a block); lines that fall back to the item
1940+
// content indent are stripped by that instead, keeping them
1941+
// in the item rather than letting them escape.
1942+
$strip = $lineIndent >= $nestedIndent ? $nestedIndent : $contentIndent;
1943+
$subLines[] = IndentationHelper::stripLeadingIndent($subLine, $strip);
1944+
$i++;
19041945
}
19051946
if ($subLines !== []) {
19061947
$this->parseBlocks($listItem, $subLines, 0);
@@ -3270,19 +3311,12 @@ protected function isBlockElementStart(string $line): bool
32703311
return true;
32713312
}
32723313

3273-
// List markers - these indicate a new list at this level
3274-
// Bullet lists: -, *, + followed by space
3275-
if (preg_match('/^[-*+] /', $line)) {
3276-
return true;
3277-
}
3278-
3279-
// Ordered lists: digit(s) or letter followed by . or ) and space
3280-
if (preg_match('/^(\d+|[a-zA-Z])[.)] /', $line)) {
3281-
return true;
3282-
}
3283-
3284-
// Task lists: - [ ] or - [x]
3285-
if (preg_match('/^- \[[xX ]\] /', $line)) {
3314+
// List markers (bullet, ordered, and task). Delegate to the canonical
3315+
// list-marker parser so nested-block detection recognizes every marker
3316+
// form it supports - including "(1)", "(a)", and roman numerals such as
3317+
// "iv." - instead of a narrower subset that silently degraded those into
3318+
// plain paragraph text.
3319+
if ($this->listParser->parseListItemMarker($line) !== null) {
32863320
return true;
32873321
}
32883322

0 commit comments

Comments
 (0)