Skip to content
Merged
2 changes: 1 addition & 1 deletion packages/comark-vue/test/sink-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { MarkdownDocument } from '../src/components/MarkdownDocument.ts'
function renderDocument(document: unknown) {
const app = createSSRApp({
setup() {
return () => h(MarkdownDocument, { value: document })
return () => h(MarkdownDocument, { value: document as any })
},
})
return renderToString(app as any)
Expand Down
34 changes: 17 additions & 17 deletions packages/comark/src/internal/stringify/attributes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { stringifyYaml } from '../yaml.ts'
import { get } from '../../utils/index.ts'
import { escapeHtml, get } from '../../utils/index.ts'
import { isUnsafeUrlValue } from '../props-validation.ts'
import { pickFence } from './fence.ts'
import type { NodeRenderData } from '../../types.ts'

export interface ResolveAttributesOptions {
Expand Down Expand Up @@ -210,23 +211,22 @@ export function comarkAttributes(attributes: Record<string, unknown>) {
return `${key}="${JSON.stringify(value).replace(/"/g, '\\"')}"`
}

return `${key}="${value}"`
const str = String(value)
// A double quote inside a double-quoted value would terminate it early,
// letting the remainder become new attributes on re-parse. Single
// quotes round-trip cleanly when the value has no single quote;
// otherwise backslash-escape (the parser skips \" without terminating —
// safe, though it keeps the backslash in the value).
if (str.includes('"') && !str.includes("'")) {
return `${key}='${str}'`
}
return `${key}="${str.replace(/"/g, '\\"')}"`
})
.join(' ')

return attrs.length > 0 ? `{${attrs}}` : ''
}

/**
* Escape a value for interpolation into a double-quoted HTML attribute.
* Prevents attribute breakout (`"` terminating the value early) and markup
* injection (`<`/`>` closing the tag), which would otherwise bypass
* AST-level sanitization such as `comark/plugins/security`.
*/
function escapeHtmlAttribute(value: unknown): string {
return String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

// HTML attribute names must start with a letter/underscore/colon and may only
// contain alphanumerics plus `_ : . -`. Anything else (quotes, spaces, …)
// could break out of the attribute list, so such keys are dropped entirely.
Expand All @@ -250,10 +250,10 @@ export function htmlAttributes(attributes: Record<string, unknown>) {
continue
}
if (typeof value === 'object' && value !== null) {
parts.push(`${key}="${escapeHtmlAttribute(JSON.stringify(value))}"`)
parts.push(`${key}="${escapeHtml(JSON.stringify(value))}"`)
continue
}
parts.push(`${key}="${escapeHtmlAttribute(value)}"`)
parts.push(`${key}="${escapeHtml(String(value))}"`)
continue
}

Expand All @@ -264,11 +264,11 @@ export function htmlAttributes(attributes: Record<string, unknown>) {
if (value === false || value === null || value === undefined) continue

if (typeof value === 'object') {
parts.push(`${key}="${escapeHtmlAttribute(JSON.stringify(value))}"`)
parts.push(`${key}="${escapeHtml(JSON.stringify(value))}"`)
continue
}

parts.push(`${key}="${escapeHtmlAttribute(value)}"`)
parts.push(`${key}="${escapeHtml(String(value))}"`)
}
return parts.join(' ')
}
Expand Down Expand Up @@ -322,6 +322,6 @@ export function comarkYamlAttributes(
return `---\n${yamlContent}\n---`
}

const fence = yamlContent.includes('```') ? '~~~' : '```'
const fence = pickFence(yamlContent)
return `${fence}yaml [props]\n${yamlContent}\n${fence}`
}
24 changes: 24 additions & 0 deletions packages/comark/src/internal/stringify/fence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Choose a code fence for `content` that the content itself cannot close.
*
* A fence closes on any line with up to 3 leading spaces followed by a run of
* the same fence character at least as long as the opening fence. Scan for
* the longest such runs of both characters, then emit the character with the
* shorter maximum run, one character longer than that run (minimum 3).
*/
export function pickFence(content: string): string {
let maxBackticks = 0
let maxTildes = 0
for (const line of content.split('\n')) {
const match = /^ {0,3}(`+|~+)/.exec(line)
if (!match) continue
const run = match[1]
if (run[0] === '`') {
if (run.length > maxBackticks) maxBackticks = run.length
} else if (run.length > maxTildes) {
maxTildes = run.length
}
}
const char = maxBackticks <= maxTildes ? '`' : '~'
return char.repeat(Math.max(3, (char === '`' ? maxBackticks : maxTildes) + 1))
}
8 changes: 6 additions & 2 deletions packages/comark/src/internal/stringify/handlers/mermaid.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type { State } from 'comark/render'
import type { ElementNode } from 'comark'
import { comarkAttributes } from '../attributes.ts'
import { pickFence } from '../fence.ts'

const fence = '```'
export function mermaid(node: ElementNode, state: State) {
const [_, attributes] = node

const { content, ...rest } = attributes

const attrs = comarkAttributes(rest)
// Parsed fence bodies keep one trailing newline — drop it so serialization
// doesn't grow a blank line on every round trip.
const body = String(content ?? '').replace(/\n$/, '')
const fence = pickFence(body)

return `${fence}mermaid${attrs ? ` ${attrs}` : ''}\n${content}\n${fence}${state.context.blockSeparator}`
return `${fence}mermaid${attrs ? ` ${attrs}` : ''}\n${body}\n${fence}${state.context.blockSeparator}`
}
3 changes: 2 additions & 1 deletion packages/comark/src/internal/stringify/handlers/pre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { State } from 'comark/render'
import type { ElementNode } from 'comark'
import { textContent } from '../../../utils/index.ts'
import { comarkAttributes, userBlockAttrs } from '../attributes.ts'
import { pickFence } from '../fence.ts'

export function pre(node: ElementNode, state: State) {
const [_, attributes, ...children] = node
Expand All @@ -25,7 +26,7 @@ export function pre(node: ElementNode, state: State) {
const meta = attributes.meta ? ' ' + attributes.meta : ''

const code = String(node[1]?.code || textContent(node)).trim()
const fence = code.includes('```') ? '~~~' : '```'
const fence = pickFence(code)

const fenceBlock = fence + language + filename + highlights + meta + '\n' + code + '\n' + fence
// Extra user attrs that can't ride on the fence info string round-trip via
Expand Down
39 changes: 35 additions & 4 deletions packages/comark/src/internal/stringify/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,15 @@ export const state: State = {

// Characters that can start an inline markdown construct anywhere on a line:
// `\` (escape), `` ` `` (code span), `*`/`_` (emphasis), `<` (raw HTML /
// autolink), `&` (character reference), `~` (strikethrough) and `[`/`]`
// (link/image). `\` is included so a literal backslash is preserved instead of
// merging with a following escape.
const inlineSyntax = /[\\`*_<&~[\]]/g
// autolink), `&` (character reference), `~` (strikethrough), `[`/`]`
// (link/image), plus the Comark markers `:` (inline component) and `{`
// (attribute block). `\` is included so a literal backslash is preserved
// instead of merging with a following escape.
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', '*', '_', '['])

/**
* Escape characters in a markdown text node that would otherwise be
Expand Down Expand Up @@ -296,6 +301,29 @@ function escapeInline(text: string): string {
if (char === '&' && !/^&#?[a-zA-Z0-9]+;/.test(source.slice(offset))) {
return char
}
// `:` only starts an inline component in allowed positions, followed by a
// component-name character (`:name`, `:name[...]`, `:name{...}`).
if (char === ':') {
const prev = source[offset - 1]
const prevAllowed = prev === undefined || COLON_PREV_CHARS.has(prev)
const next = source[offset + 1]
if (prevAllowed && next !== undefined && /[a-zA-Z$]/.test(next)) {
return `\\${char}`
}
return char
}
// `{` only opens an attribute block when followed by a props-start
// character; `{{` (mustache) and `${` (template) never match.
if (char === '{') {
const prev = source[offset - 1]
if (prev === '{' || prev === '$') {
return char
}
if (/^\{[ \t]{0,3}[.#:a-zA-Z_]/.test(source.slice(offset, offset + 6))) {
return `\\${char}`
}
return char
}
return `\\${char}`
})
}
Expand All @@ -320,5 +348,8 @@ function escapeLeadingBlock(line: string): string {
if (/^\+([ \t]|$)/.test(line)) return `\\${line}`
// Setext underline made of `=`.
if (/^=+[ \t]*$/.test(line)) return `\\${line}`
// Comark block component / component fence: any run of leading colons
// (`:name`, `::name`, or a bare `::` fence close).
if (line[0] === ':') return `\\${line}`
return line
}
56 changes: 56 additions & 0 deletions packages/comark/test/fence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { pickFence } from '../src/internal/stringify/fence.ts'

describe('pickFence', () => {
it('defaults to a 3-backtick fence for empty content', () => {
expect(pickFence('')).toBe('```')
})

it('defaults to a 3-backtick fence when content has no fence runs', () => {
expect(pickFence('hello\nworld')).toBe('```')
})

it('prefers the fence character with the shorter maximum run', () => {
// Backticks present, no tildes → switch to tildes so content cannot close it
expect(pickFence('```')).toBe('~~~')
expect(pickFence('line\n````\nline')).toBe('~~~')
expect(pickFence('`````')).toBe('~~~')

// Tildes present, no backticks → stay on backticks
expect(pickFence('~~~')).toBe('```')
expect(pickFence('~~~~~')).toBe('```')
})

it('on a tie, prefers backticks one longer than the run', () => {
expect(pickFence('```\n~~~')).toBe('````')
expect(pickFence('````\n~~~~')).toBe('`````')
})

it('picks tildes when backticks need a longer fence', () => {
// maxB=4, maxT=3 → '~' length 4
expect(pickFence('````\n~~~')).toBe('~~~~')
// maxB=4, maxT=3 → same
expect(pickFence('~~~\n````')).toBe('~~~~')
})

it('picks backticks when tildes need a longer fence', () => {
// maxB=3, maxT=5 → '`' length 4
expect(pickFence('~~~~~\n```')).toBe('````')
})

it('only counts fence runs at the start of a line (up to 3 spaces)', () => {
// maxB=3, maxT=4 → prefer backticks length 4
expect(pickFence(' ```\n ~~~~')).toBe('````')
// 4+ leading spaces is indented code, not a fence closer candidate
expect(pickFence(' ```\nhello')).toBe('```')
// Mid-line runs must not influence the fence
expect(pickFence('code with ``` inside\nand ~~~ too')).toBe('```')
})

it('uses the longest run of the chosen character, minimum 3', () => {
// only short backtick runs; still length-3 tilde fence
expect(pickFence('`\n``')).toBe('~~~')
// maxB=4, maxT=0 → ~~~ (min 3, not maxT+1)
expect(pickFence('`\n``\n```\n````')).toBe('~~~')
})
})
95 changes: 95 additions & 0 deletions packages/comark/test/roundtrip-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { parseMarkdown } from '../src/parse'
import { renderMarkdown } from '../src/render'
import mermaid from '../src/plugins/mermaid'
import type { MarkdownDocument } from '../src/types'

async function roundTrip(md: string, options?: Parameters<typeof parseMarkdown>[1]) {
const t1 = await parseMarkdown(md, options)
const rendered = await renderMarkdown(t1)
const t2 = await parseMarkdown(rendered, options)
return { t1, t2, rendered }
}

describe('code fence selection', () => {
it('picks a fence that content with both ``` and ~~~ cannot close', async () => {
const code = 'let a = 1\n```\n~~~\n::alert\nowned\n::'
const doc = {
frontmatter: {},
meta: {},
nodes: [['pre', { language: 'js' }, ['code', { class: 'language-js' }, code]]],
} as unknown as MarkdownDocument
const rendered = await renderMarkdown(doc)
const t2 = await parseMarkdown(rendered)
expect(t2.nodes).toEqual(doc.nodes)
})

it('widens the fence past the longest backtick run in the content', async () => {
const code = 'const s = ""\n````\nend'
const doc = {
frontmatter: {},
meta: {},
nodes: [['pre', { language: 'js' }, ['code', { class: 'language-js' }, code]]],
} as unknown as MarkdownDocument
const rendered = await renderMarkdown(doc)
const t2 = await parseMarkdown(rendered)
expect(t2.nodes).toEqual(doc.nodes)
})
})

describe('mermaid fence selection', () => {
it('does not let mermaid content escape its fence', async () => {
// A mermaid body containing ``` must not terminate the serialized fence
const md = '````mermaid\ngraph TD\n```\nA --> B\n````'
const { t1, t2 } = await roundTrip(md, { plugins: [mermaid()] })
expect(t2.nodes).toEqual(t1.nodes)
})
})

describe('component marker escaping', () => {
it('keeps escaped :: markers as literal text through a round trip', async () => {
const { t2 } = await roundTrip('\\:\\:alert')
expect(t2.nodes).toEqual([['p', {}, '::alert']])
})

it('keeps entity-encoded :: markers as literal text through a round trip', async () => {
const { t2 } = await roundTrip('&#58;&#58;alert')
expect(t2.nodes).toEqual([['p', {}, '::alert']])
})

it('escapes a bare :: line inside block component content', async () => {
// The middle paragraph is the literal text `::` (escaped in the source),
// which must not become a fence close after serialization.
const md = '::card\nfirst\n\n\\::\n\nsecond\n::'
const { t1, t2 } = await roundTrip(md)
expect(t2.nodes).toEqual(t1.nodes)
})

it('escapes inline component markers in text', async () => {
const { t2 } = await roundTrip('type \\:alert to continue')
expect(t2.nodes).toEqual([['p', {}, 'type :alert to continue']])
})

it('escapes attribute block openers after inline elements', async () => {
const { t2 } = await roundTrip('**bold** \\{.red}')
expect(t2.nodes).toEqual([['p', {}, ['strong', {}, 'bold'], ' {.red}']])
})
})

describe('comarkAttributes quoting', () => {
it('round-trips attribute values containing double quotes', async () => {
// The text sibling keeps the span inline in both parses (a lone
// `:span[...]` line is a leaf block component — pre-existing asymmetry).
const md = `say :span[hi]{title='a"b'} now`
const { t1, t2 } = await roundTrip(md)
expect(t2.nodes).toEqual(t1.nodes)
})

it('does not let a quoted value inject a new attribute on re-parse', async () => {
const md = `:span[hi]{title='x" bad="1'}`
const { t2 } = await roundTrip(md)
const span = (t2.nodes[0] as any[])[2] // p > span
expect(span[1].title).toBe('x" bad="1')
expect(span[1].bad).toBeUndefined()
})
})
2 changes: 1 addition & 1 deletion test/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => {
"@comark/react": "43.6k (74 files)",
"@comark/svelte": "43.9k (82 files)",
"@comark/vue": "60.5k (78 files)",
"comark": "419k (154 files)",
"comark": "422k (156 files)",
}
`)
})
Expand Down
Loading