diff --git a/packages/comark/src/plugins/syntax.ts b/packages/comark/src/plugins/syntax.ts index f11fc2d5..742ff224 100644 --- a/packages/comark/src/plugins/syntax.ts +++ b/packages/comark/src/plugins/syntax.ts @@ -53,6 +53,24 @@ export interface SyntaxOptions { bindingTag?: string } +/** + * A component name must start with a letter or `$`, followed by word chars, + * `$` or `-`. Mirrors the block name grammar (`RE_BLOCK_NAME = /^[a-z$]/i`). + */ +const RE_COMPONENT_NAME = /^[a-z$][\w$-]*/i + +/** + * Whether `name` begins with a syntactically valid component name. + * + * This prevents sequences such as `:8100` or `::30` from being treated as + * components — a purely numeric name is not a valid component and would + * otherwise produce invalid output like `createElement('8100')` (inline) or + * throw `Invalid block params` (block). + */ +function isValidComponentName(name: string): boolean { + return RE_COMPONENT_NAME.test(name) +} + // #region Block component plugin (`::name` and `::name ... ::`) const blockYamlLines: Record = { @@ -74,7 +92,7 @@ const markdownItComarkBlock: PluginSimple = (md) => { function comark_block_shorthand(state, startLine, _endLine, silent) { const line = state.src.slice(state.bMarks[startLine] + state.tShift[startLine], state.eMarks[startLine]) - if (!/^:\w/.test(line)) return false + if (line[0] !== ':' || !isValidComponentName(line.slice(1))) return false const { name, content, props, remaining } = parseBlockParams(line.slice(1)) @@ -142,6 +160,12 @@ const markdownItComarkBlock: PluginSimple = (md) => { if (marker_count < min_markers) return false const markup = state.src.slice(start, pos) + + // Bail out (plain text) on an invalid name instead of letting + // parseBlockParams throw on e.g. `::8100`. + const nameStart = state.skipSpaces(pos) + if (nameStart < max && !isValidComponentName(state.src.slice(nameStart, max))) return false + const params = parseBlockParams(state.src.slice(pos, max)) if (!params.name) return false @@ -485,12 +509,13 @@ const markdownItInlineComponent: PluginSimple = (md) => { // Empty name if (nameEnd <= start + 1) return false + const name = state.src.slice(start + 1, nameEnd) + if (!isValidComponentName(name)) return false + state.pos = index if (silent) return true - const name = state.src.slice(start + 1, nameEnd) - if (contentStart !== -1) { state.push('mdc_inline_component', name, 1) diff --git a/packages/comark/test/component-name.test.ts b/packages/comark/test/component-name.test.ts new file mode 100644 index 00000000..3ddf5243 --- /dev/null +++ b/packages/comark/test/component-name.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { parse } from '../src/parse' + +// Regression tests for component-name validation. +// +// A component name must start with a letter or `$`. Before the fix, a colon +// followed by digits was captured as a component name: +// - inline `:8100` produced `['8100', {}]`, making renderers call +// `createElement('8100')` and crash the app; +// - block `:8100` / `::8100` made `parseBlockParams` throw `Invalid block +// params` during parsing. +// In all of these cases the colon sequence should stay plain text. +describe('component name validation', () => { + describe('inline components', () => { + it('keeps `:8100` as plain text (does not parse digits as a component)', async () => { + const tree = await parse('The server is running on :8100') + expect(tree.nodes).toEqual([['p', {}, 'The server is running on :8100']]) + }) + + it('keeps a colon followed by digits as plain text mid-sentence', async () => { + const tree = await parse('Meet me at :30 past the hour') + expect(tree.nodes).toEqual([['p', {}, 'Meet me at :30 past the hour']]) + }) + + it('still parses a valid letter-led inline component', async () => { + const tree = await parse('an :inline-component here') + expect(tree.nodes).toEqual([['p', {}, 'an ', ['inline-component', {}], ' here']]) + }) + + it('still parses an inline component with bracket content', async () => { + const tree = await parse('a :badge[New] tag') + expect(tree.nodes).toEqual([['p', {}, 'a ', ['badge', {}, 'New'], ' tag']]) + }) + + it('still allows digits after the leading letter (`:h2`)', async () => { + const tree = await parse('see :h2 below') + expect(tree.nodes).toEqual([['p', {}, 'see ', ['h2', {}], ' below']]) + }) + }) + + describe('block components', () => { + it('does not throw on a numeric `:name` shorthand', async () => { + const tree = await parse(':8100') + expect(tree.nodes).toEqual([['p', {}, ':8100']]) + }) + + it('does not throw on a numeric `:name` shorthand with following content', async () => { + const tree = await parse(':8100\nhello') + expect(tree.nodes).toEqual([['p', {}, ':8100\nhello']]) + }) + + it('does not throw on a numeric `::name` block', async () => { + const tree = await parse('::8100') + expect(tree.nodes).toEqual([['p', {}, '::8100']]) + }) + + it('still parses a valid `::name` block component', async () => { + const tree = await parse('::alert\nHello\n::') + expect(tree.nodes).toEqual([['alert', {}, 'Hello']]) + }) + }) +})