Skip to content

Commit 83f67ae

Browse files
fix(syntax): stop 2+ blank lines from closing nested components early (#323)
Co-authored-by: Farnabaz <farnabaz@gmail.com>
1 parent 71fbe96 commit 83f67ae

3 files changed

Lines changed: 312 additions & 6 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
## Input
2+
3+
```md
4+
::page-section
5+
---
6+
full-width: true
7+
---
8+
::multi-column
9+
---
10+
columns: 3
11+
---
12+
13+
::container
14+
---
15+
display: "flex"
16+
---
17+
<svg width="10"></svg>
18+
::
19+
20+
21+
22+
23+
::container
24+
---
25+
display: "flex"
26+
---
27+
<svg width="10"></svg>
28+
::
29+
30+
31+
32+
33+
::container
34+
---
35+
display: "flex"
36+
---
37+
<svg width="10"></svg>
38+
::
39+
40+
::container
41+
---
42+
display: "flex"
43+
---
44+
<svg width="10"></svg>
45+
::
46+
47+
48+
49+
50+
::container
51+
---
52+
display: "flex"
53+
---
54+
<svg width="10"></svg>
55+
::
56+
::
57+
::
58+
```
59+
60+
## AST
61+
62+
```json
63+
{
64+
"frontmatter": {},
65+
"meta": {},
66+
"nodes": [
67+
[
68+
"page-section",
69+
{
70+
"full-width": "true"
71+
},
72+
[
73+
"multi-column",
74+
{
75+
"columns": "3"
76+
},
77+
[
78+
"container",
79+
{
80+
"display": "flex"
81+
},
82+
[
83+
"svg",
84+
{
85+
"$": {
86+
"block": 0,
87+
"html": 1
88+
},
89+
"width": "10"
90+
}
91+
]
92+
],
93+
[
94+
"container",
95+
{
96+
"display": "flex"
97+
},
98+
[
99+
"svg",
100+
{
101+
"$": {
102+
"block": 0,
103+
"html": 1
104+
},
105+
"width": "10"
106+
}
107+
]
108+
],
109+
[
110+
"container",
111+
{
112+
"display": "flex"
113+
},
114+
[
115+
"svg",
116+
{
117+
"$": {
118+
"block": 0,
119+
"html": 1
120+
},
121+
"width": "10"
122+
}
123+
]
124+
],
125+
[
126+
"container",
127+
{
128+
"display": "flex"
129+
},
130+
[
131+
"svg",
132+
{
133+
"$": {
134+
"block": 0,
135+
"html": 1
136+
},
137+
"width": "10"
138+
}
139+
]
140+
],
141+
[
142+
"container",
143+
{
144+
"display": "flex"
145+
},
146+
[
147+
"svg",
148+
{
149+
"$": {
150+
"block": 0,
151+
"html": 1
152+
},
153+
"width": "10"
154+
}
155+
]
156+
]
157+
]
158+
]
159+
]
160+
}
161+
```
162+
163+
## HTML
164+
165+
```html
166+
<page-section full-width>
167+
<multi-column columns="3">
168+
<container display="flex">
169+
<svg width="10"></svg>
170+
</container>
171+
<container display="flex">
172+
<svg width="10"></svg>
173+
</container>
174+
<container display="flex">
175+
<svg width="10"></svg>
176+
</container>
177+
<container display="flex">
178+
<svg width="10"></svg>
179+
</container>
180+
<container display="flex">
181+
<svg width="10"></svg>
182+
</container>
183+
</multi-column>
184+
</page-section>
185+
```
186+
187+
## Markdown
188+
189+
```md
190+
::page-section{full-width="true"}
191+
:::multi-column{columns="3"}
192+
::::container{display="flex"}
193+
<svg width="10"></svg>
194+
::::
195+
196+
::::container{display="flex"}
197+
<svg width="10"></svg>
198+
::::
199+
200+
::::container{display="flex"}
201+
<svg width="10"></svg>
202+
::::
203+
204+
::::container{display="flex"}
205+
<svg width="10"></svg>
206+
::::
207+
208+
::::container{display="flex"}
209+
<svg width="10"></svg>
210+
::::
211+
:::
212+
::
213+
```

packages/comark/src/plugins/syntax.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,6 @@ const markdownItComarkBlock: PluginSimple = (md) => {
9999
// If there's unparsed remaining content, treat it as inline component in a paragraph
100100
if (remaining) return false
101101

102-
state.lineMax = startLine + 1
103-
104102
if (!silent) {
105103
if (content !== undefined) {
106104
const tokenOpen = state.push('mdc_block_shorthand', name, 1)
@@ -337,7 +335,6 @@ const markdownItComarkBlock: PluginSimple = (md) => {
337335
}
338336

339337
state.line = lineEnd + 1
340-
state.lineMax = lineEnd + 1
341338
return true
342339
})
343340

@@ -392,11 +389,12 @@ const markdownItComarkBlock: PluginSimple = (md) => {
392389

393390
if (silent) {
394391
state.line = lineEnd
395-
state.lineMax = lineEnd
396392
return true
397393
}
398394

399-
state.lineMax = startLine + 1
395+
// Restore lineMax after tokenizing so it doesn't leak a narrower bound to
396+
// whatever comes after this slot (see `comark_block`'s save/restore above).
397+
const oldLineMax = state.lineMax
400398
const slot = state.push('mdc_block_slot', 'template', 1)
401399
slot.attrSet(`#${name}`, '')
402400
props?.forEach(([key, value]) => {
@@ -412,7 +410,7 @@ const markdownItComarkBlock: PluginSimple = (md) => {
412410
state.push('mdc_block_slot', 'template', -1)
413411

414412
state.line = lineEnd
415-
state.lineMax = lineEnd
413+
state.lineMax = oldLineMax
416414

417415
return true
418416
})
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parse } from '../src/parse'
3+
4+
// Regression tests for https://github.com/comarkdown/comark/issues/322
5+
//
6+
// `comark_block_yaml`, `comark_block_shorthand`, and `comark_block_slots` each
7+
// narrowed `state.lineMax` after matching and never restored it, unlike
8+
// `comark_block`. Since `skipEmptyLines` is bounded by `state.lineMax`, a
9+
// *second* consecutive blank line inside a nested component fell through it
10+
// unskipped, then failed the tokenizer's indent check: `comark_block_yaml`/
11+
// `comark_block_shorthand` broke out of the loop and dropped every later
12+
// sibling; `comark_block_slots` emitted a spurious empty paragraph instead.
13+
//
14+
// The fixes restore (or stop touching) `state.lineMax` so a blank-line run of
15+
// any length is treated as a single separator, as in CommonMark.
16+
describe('nested component with a run of blank lines between siblings', () => {
17+
describe('YAML props fence (`---`/`---`)', () => {
18+
const build = (blanks: number) =>
19+
`::outer\n ::mid\n ---\n columns: 3\n ---\n${['A', 'B', 'C']
20+
.map((name) => ` ::${name}\n ---\n display: "flex"\n ---\n ::`)
21+
.join('\n' + '\n'.repeat(blanks))}\n ::\n::`
22+
23+
const expected = [
24+
[
25+
'outer',
26+
{},
27+
['mid', { columns: '3' }, ['a', { display: 'flex' }], ['b', { display: 'flex' }], ['c', { display: 'flex' }]],
28+
],
29+
]
30+
31+
it.each([1, 2, 3, 7])('keeps every sibling with %i consecutive blank line(s) between them', async (blanks) => {
32+
const tree = await parse(build(blanks))
33+
expect(tree.nodes).toEqual(expected)
34+
})
35+
36+
it('still closes the enclosing component at its own marker after a blank run (no over-absorption)', async () => {
37+
// `mid` must still close at its own `::`, not swallow `after` too.
38+
const src = '::outer\n ::mid\n ---\n columns: 3\n ---\n ::A\n ::\n\n\n ::\nafter\n::'
39+
const tree = await parse(src)
40+
expect(tree.nodes).toEqual([['outer', {}, ['mid', { columns: '3' }, ['a', {}]], ['p', {}, 'after']]])
41+
})
42+
})
43+
44+
describe('shorthand block (`:name[content]`)', () => {
45+
const build = (blanks: number) =>
46+
`::outer\n :leaf1[hi]\n${'\n'.repeat(blanks)} ::leaf2\n ::\n${'\n'.repeat(blanks)} ::leaf3\n ::\n::`
47+
48+
const expected = [['outer', {}, ['leaf1', {}, 'hi'], ['leaf2', {}], ['leaf3', {}]]]
49+
50+
it.each([1, 2, 3, 7])('keeps every sibling with %i consecutive blank line(s) after it', async (blanks) => {
51+
const tree = await parse(build(blanks))
52+
expect(tree.nodes).toEqual(expected)
53+
})
54+
55+
it('still closes the enclosing component at its own marker after a blank run (no over-absorption)', async () => {
56+
const src = '::outer\n :leaf1[hi]\n\n\nafter\n::'
57+
const tree = await parse(src)
58+
expect(tree.nodes).toEqual([['outer', {}, ['leaf1', {}, 'hi'], ['p', {}, 'after']]])
59+
})
60+
})
61+
62+
describe('template slot (`#slotname`)', () => {
63+
const build = (blanks: number) =>
64+
`::outer\n #title\n Hello\n ::childA\n ::\n${'\n'.repeat(blanks)} ::childB\n ::\n::`
65+
66+
const expected = [
67+
['outer', {}, ['template', { name: 'title' }, ['p', {}, 'Hello'], ['child-a', {}], ['child-b', {}]]],
68+
]
69+
70+
it.each([1, 2, 3, 7])(
71+
'keeps every sibling with %i consecutive blank line(s) between them, with no spurious paragraph token',
72+
async (blanks) => {
73+
const tree = await parse(build(blanks))
74+
expect(tree.nodes).toEqual(expected)
75+
}
76+
)
77+
78+
it('still terminates at the next `#slot` marker regardless of a preceding blank run', async () => {
79+
const src = '::outer\n #title\n Hello\n\n\n #footer\n Bye\n ::\n::'
80+
const tree = await parse(src)
81+
expect(tree.nodes).toEqual([
82+
['outer', {}, ['template', { name: 'title' }, 'Hello'], ['template', { name: 'footer' }, 'Bye']],
83+
])
84+
})
85+
86+
it('still terminates at the parent close after a blank run (no over-absorption)', async () => {
87+
const src = '::outer\n #title\n Hello\n\n\n ::\nafter\n::'
88+
const tree = await parse(src)
89+
expect(tree.nodes).toEqual([
90+
['outer', {}, ['template', { name: 'title' }, 'Hello']],
91+
['p', {}, 'after'],
92+
])
93+
})
94+
})
95+
})

0 commit comments

Comments
 (0)