Skip to content

Commit 56e398e

Browse files
authored
fix(comark): consume nested brackets in inline component content (#306)
1 parent 849c73d commit 56e398e

4 files changed

Lines changed: 109 additions & 27 deletions

File tree

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
11
/**
2-
* Parse content within square brackets `[content]`.
3-
* Returns the content (without the brackets) and the index just past the closing `]`.
2+
* Find the index of the `]` closing the `[` at `openIndex`, honouring
3+
* backslash escapes and nested `[...]` pairs. Returns -1 when unclosed.
44
*/
5-
export function parseBracketContent(str: string, startIndex: number): { content: string; endIndex: number } | null {
6-
if (str[startIndex] !== '[') return null
5+
export function findClosingBracket(str: string, openIndex: number): number {
6+
if (str[openIndex] !== '[') return -1
77

8-
let index = startIndex + 1
8+
let index = openIndex + 1
9+
let depth = 0
910

1011
while (index < str.length) {
1112
if (str[index] === '\\' && index + 1 < str.length) {
1213
index += 2
1314
continue
1415
}
15-
if (str[index] === ']') {
16-
return { content: str.slice(startIndex + 1, index), endIndex: index + 1 }
16+
if (str[index] === '[') {
17+
depth++
18+
} else if (str[index] === ']') {
19+
if (depth === 0) return index
20+
depth--
1721
}
1822
index += 1
1923
}
2024

21-
return null
25+
return -1
26+
}
27+
28+
/**
29+
* Parse content within square brackets `[content]`.
30+
* Returns the content (without the brackets) and the index just past the closing `]`.
31+
*/
32+
export function parseBracketContent(str: string, startIndex: number): { content: string; endIndex: number } | null {
33+
const close = findClosingBracket(str, startIndex)
34+
if (close === -1) return null
35+
return { content: str.slice(startIndex + 1, close), endIndex: close + 1 }
2236
}

packages/comark/src/plugins/syntax.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { MarkdownExit, PluginSimple, Renderer } from 'markdown-exit'
22
import { Token } from 'markdown-exit'
33
import type { MarkdownItPlugin, MarkdownItPluginWithOptions } from '../types.ts'
44
import { defineComarkPlugin } from '../utils/helpers.ts'
5-
import { parseBracketContent } from '../internal/parse/syntax/brackets.ts'
5+
import { findClosingBracket, parseBracketContent } from '../internal/parse/syntax/brackets.ts'
66
import { searchProps } from '../internal/parse/syntax/props.ts'
77
import { parseBlockParams } from '../internal/parse/syntax/block-params.ts'
88
import { parseYaml } from '../internal/yaml.ts'
@@ -427,23 +427,9 @@ const markdownItInlineSpan: PluginSimple = (md) => {
427427
const start = state.pos
428428
if (state.src[start] !== '[') return false
429429

430-
let index = start + 1
431-
let depth = 0
432-
while (index < state.src.length) {
433-
if (state.src[index] === '\\') {
434-
index += 2
435-
continue
436-
}
437-
if (state.src[index] === '[') {
438-
depth++
439-
} else if (state.src[index] === ']') {
440-
if (depth === 0) break
441-
depth--
442-
}
443-
index += 1
444-
}
445-
446-
if (index === start) return false
430+
// An unclosed span consumes to the end of input (streaming auto-close)
431+
const close = findClosingBracket(state.src, start)
432+
const index = close === -1 ? state.src.length : close
447433

448434
// Don't match `[text](url)` or `[text][ref]` — let the link parser handle those
449435
const nextChar = state.src[index + 1]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parse } from '../src/index'
3+
4+
/**
5+
* The bracket content of `:name[…]` must consume nested `[…]` pairs — images,
6+
* links and nested components all contain `]` — instead of terminating at the
7+
* first `]` and spilling the remainder into the paragraph.
8+
*/
9+
10+
describe('inline component bracket content', () => {
11+
it('parses an image inside a component', async () => {
12+
const result = await parse('See :badge[![icon](i.png)] here')
13+
14+
expect(result.nodes).toEqual([['p', {}, 'See ', ['badge', {}, ['img', { src: 'i.png', alt: 'icon' }]], ' here']])
15+
})
16+
17+
it('parses a link inside a component', async () => {
18+
const result = await parse('See :badge[[docs](https://x.dev)] here')
19+
20+
expect(result.nodes).toEqual([['p', {}, 'See ', ['badge', {}, ['a', { href: 'https://x.dev' }, 'docs']], ' here']])
21+
})
22+
23+
it('keeps escaped brackets literal', async () => {
24+
const result = await parse('See :badge[\\[docs\\](x)] here')
25+
26+
expect(result.nodes).toEqual([['p', {}, 'See ', ['badge', {}, '[docs](x)'], ' here']])
27+
})
28+
29+
it('parses nested inline components', async () => {
30+
const result = await parse('a :alert[:inner[:leaf]] b')
31+
32+
expect(result.nodes).toEqual([['p', {}, 'a ', ['alert', {}, ['inner', {}, ['leaf', {}]]], ' b']])
33+
})
34+
35+
it('parses an image in a block directive header', async () => {
36+
const result = await parse('::card[![alt](i.png)]\n::')
37+
38+
expect(result.nodes).toEqual([['card', {}, ['img', { src: 'i.png', alt: 'alt' }]]])
39+
})
40+
})

packages/comark/test/syntax/brackets.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
import { describe, expect, it } from 'vitest'
2-
import { parseBracketContent } from '../../src/internal/parse/syntax/brackets'
2+
import { findClosingBracket, parseBracketContent } from '../../src/internal/parse/syntax/brackets'
3+
4+
describe('findClosingBracket', () => {
5+
it('should find the closing bracket', () => {
6+
expect(findClosingBracket('[hello]', 0)).toBe(6)
7+
})
8+
9+
it('should skip nested bracket pairs', () => {
10+
expect(findClosingBracket('[a [b] c]', 0)).toBe(8)
11+
})
12+
13+
it('should skip escaped brackets', () => {
14+
expect(findClosingBracket('[a \\] b]', 0)).toBe(7)
15+
})
16+
17+
it('should return -1 when unclosed', () => {
18+
expect(findClosingBracket('[a [b]', 0)).toBe(-1)
19+
})
20+
21+
it('should return -1 when not at an opening bracket', () => {
22+
expect(findClosingBracket('a]', 0)).toBe(-1)
23+
})
24+
25+
it('should work with non-zero start index', () => {
26+
expect(findClosingBracket('prefix[content]', 6)).toBe(14)
27+
})
28+
})
329

430
describe('parseBracketContent', () => {
531
it('should parse simple bracket content', () => {
@@ -29,4 +55,20 @@ describe('parseBracketContent', () => {
2955
it('should work with non-zero start index', () => {
3056
expect(parseBracketContent('prefix[content]suffix', 6)).toEqual({ content: 'content', endIndex: 15 })
3157
})
58+
59+
it('should include nested bracket pairs in the content', () => {
60+
expect(parseBracketContent('[a [b] c]', 0)).toEqual({ content: 'a [b] c', endIndex: 9 })
61+
})
62+
63+
it('should include an image in the content', () => {
64+
expect(parseBracketContent('[![alt](i.png)]', 0)).toEqual({ content: '![alt](i.png)', endIndex: 15 })
65+
})
66+
67+
it('should include multiple nested pairs in the content', () => {
68+
expect(parseBracketContent('[[a] and [b]]', 0)).toEqual({ content: '[a] and [b]', endIndex: 13 })
69+
})
70+
71+
it('should return null when a nested pair is left unclosed', () => {
72+
expect(parseBracketContent('[a [b]', 0)).toBeNull()
73+
})
3274
})

0 commit comments

Comments
 (0)