diff --git a/packages/comark/src/internal/stringify/state.ts b/packages/comark/src/internal/stringify/state.ts index 5f56cc2c..a18612d4 100644 --- a/packages/comark/src/internal/stringify/state.ts +++ b/packages/comark/src/internal/stringify/state.ts @@ -215,7 +215,7 @@ const inlineSyntax = /[\\`*_<&~[\]{:]/g // Characters after which a `:` can start an inline component (`:name`). // Mirrors ALLOWED_PREV_CHARS in the components plugin. -const COLON_PREV_CHARS = new Set([' ', '\t', '\n', '*', '_', '[']) +const COLON_PREV_CHARS = new Set([' ', '\t', '\n', '*', '_', '[', ']', '}', ')', '>']) /** * Escape characters in a markdown text node that would otherwise be diff --git a/packages/comark/src/plugins/components.ts b/packages/comark/src/plugins/components.ts index 4e514c9a..21fc12b2 100644 --- a/packages/comark/src/plugins/components.ts +++ b/packages/comark/src/plugins/components.ts @@ -404,7 +404,7 @@ const markdownItComarkBlock: PluginSimple = (md) => { ) } -const ALLOWED_PREV_CHARS = new Set([' ', '\t', '\n', '*', '_', '[']) +const ALLOWED_PREV_CHARS = new Set([' ', '\t', '\n', '*', '_', '[', ']', '}', ')', '>']) const markdownItInlineComponent: PluginSimple = (md) => { md.inline.ruler.after('entity', 'comark_inline_component', (state, silent) => { diff --git a/packages/comark/test/component-name.test.ts b/packages/comark/test/component-name.test.ts index ed4a7cce..a30de34b 100644 --- a/packages/comark/test/component-name.test.ts +++ b/packages/comark/test/component-name.test.ts @@ -22,6 +22,16 @@ describe('component name validation', () => { expect(tree.nodes).toEqual([['p', {}, 'Meet me at :30 past the hour']]) }) + it('parses consecutive inline components without spaces', async () => { + const tree = await parseMarkdown(':b[text]:i[text]') + expect(tree.nodes).toEqual([['p', {}, ['b', {}, 'text'], ['i', {}, 'text']]]) + }) + + it('parses inline components after attributes', async () => { + const tree = await parseMarkdown(':b[text]{id="x"}:i[text]') + expect(tree.nodes).toEqual([['p', {}, ['b', { id: 'x' }, 'text'], ['i', {}, 'text']]]) + }) + it('still parses a valid letter-led inline component', async () => { const tree = await parseMarkdown('an :inline-component here') expect(tree.nodes).toEqual([['p', {}, 'an ', ['inline-component', {}], ' here']]) diff --git a/packages/comark/test/text-escape.test.ts b/packages/comark/test/text-escape.test.ts index 64c3dc76..0b65ac12 100644 --- a/packages/comark/test/text-escape.test.ts +++ b/packages/comark/test/text-escape.test.ts @@ -80,5 +80,19 @@ describe('text node escaping', () => { const md = await renderMarkdown(document) expect(md).toContain('Hello **World**') expect(md).not.toContain('\\*') + it('escapes literal text before colon-prefixed component names to prevent reparsing as an inline component', async () => { + // "word]:name" would reparse as a component if not escaped. + const text = 'word]:name' + const { node } = await roundTrip(text) + expect(node).toEqual(['p', {}, text]) + }) + }) + + it('escapes literal text before colon-prefixed component names to prevent reparsing as an inline component', async () => { + const text = 'word]:name' + const { md, node } = await roundTrip(text) + // The colon should be escaped to prevent re-parsing as an inline component. + expect(md).toContain('\:name') + expect(node).toEqual(['p', {}, text]) }) })