Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions packages/comark/src/plugins/syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -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))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
62 changes: 62 additions & 0 deletions packages/comark/test/component-name.test.ts
Original file line number Diff line number Diff line change
@@ -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']])
})
})
})
Loading