Skip to content
Open
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
14 changes: 12 additions & 2 deletions packages/comark/src/internal/stringify/attributes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { stringifyYaml } from '../yaml.ts'
import { get } from '../../utils/index.ts'
import { pickFence } from './fence.ts'
import type { NodeRenderData } from '../../types.ts'

export interface ResolveAttributesOptions {
Expand Down Expand Up @@ -176,7 +177,16 @@ 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(' ')

Expand Down Expand Up @@ -270,6 +280,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 @@ -211,10 +211,15 @@ function escapeHtml(text: string): string {

// 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 @@ -264,6 +269,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 @@ -288,5 +316,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
}
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": "405k (154 files)",
"comark": "408k (156 files)",
}
`)
})
Expand Down
Loading