Skip to content
Merged
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ packages/comark/
│ │ ├── task-list.ts # GFM task lists
│ │ └── toc.ts # Table of contents
│ ├── utils/ # Shared utilities (comark/utils entry point)
│ │ ├── index.ts # textContent(), visit(), visitAsync(), string/object utils
│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), string/object utils
│ │ ├── helpers.ts # defineComarkPlugin(), dedupePlugins()
│ │ └── caret.ts # Caret utilities for streaming
│ └── internal/ # Internal implementation (not exported)
Expand Down Expand Up @@ -389,7 +389,7 @@ import { renderMarkdown } from 'comark/render'

// AST types and utilities
import type { MarkdownDocument, Node, ElementNode, TextNode } from 'comark'
import { textContent, visit } from 'comark/utils'
import { textContent, visit, escapeHtml } from 'comark/utils'

// Core plugins — use when calling parseMarkdown() directly (framework-agnostic)
import shiki from 'comark/plugins/shiki'
Expand Down
2 changes: 1 addition & 1 deletion packages/comark-html/src/plugins/binding.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { NodeHandler } from 'comark/render'
import { escapeHtml } from '../utils/index.ts'
import { escapeHtml } from 'comark/utils'

export * from 'comark/plugins/binding'
export { default } from 'comark/plugins/binding'
Expand Down
2 changes: 1 addition & 1 deletion packages/comark-html/src/plugins/math.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ElementNode } from 'comark'
import katex from 'katex'
import { escapeHtml } from '../utils/index.ts'
import { escapeHtml } from 'comark/utils'

export * from 'comark/plugins/math'
export { default } from 'comark/plugins/math'
Expand Down
2 changes: 1 addition & 1 deletion packages/comark-html/src/plugins/mermaid.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ElementNode } from 'comark'
import type { ThemeNames } from 'comark/plugins/mermaid'
import { renderMermaidSVG, THEMES } from 'beautiful-mermaid'
import { escapeHtml } from '../utils/index.ts'
import { escapeHtml } from 'comark/utils'

export * from 'comark/plugins/mermaid'
export { default } from 'comark/plugins/mermaid'
Expand Down
8 changes: 0 additions & 8 deletions packages/comark-html/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1 @@
export * from 'comark/utils'

/**
* Escape a string for safe interpolation into HTML markup. Used by plugin
* renderers whose fallback output includes author-controlled source.
*/
export function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
2 changes: 1 addition & 1 deletion packages/comark/SPEC/COMARK/codeblock-multiple-meta.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def greet(name):
## HTML

```html
<pre language="python" meta="showLineNumbers=true startLine=10 title="Example""><code class="language-python">def greet(name):
<pre language="python" meta="showLineNumbers=true startLine=10 title=&quot;Example&quot;"><code class="language-python">def greet(name):
return f"Hello, {name}!"</code></pre>
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Component content
## HTML

```html
<component title="Hello World" count="42" enabled hidden="false" tags="[\"markdown\",\"docs\"]" config="{\"theme\":\"dark\",\"debug\":false}">
<component title="Hello World" count="42" enabled hidden="false" tags="[&quot;markdown&quot;,&quot;docs&quot;]" config="{&quot;theme&quot;:&quot;dark&quot;,&quot;debug&quot;:false}">
Component content
</component>
```
Expand Down
2 changes: 1 addition & 1 deletion packages/comark/SPEC/COMARK/component-yaml-props.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Second Paragraph
## HTML

```html
<component attr="value" object-attr="{\"key1\":\"value1\",\"key2\":\"value2\"}" array="[\"item 1\",\"item 2\"]">
<component attr="value" object-attr="{&quot;key1&quot;:&quot;value1&quot;,&quot;key2&quot;:&quot;value2&quot;}" array="[&quot;item 1&quot;,&quot;item 2&quot;]">
<p>First Paragraph</p>
<p>Second Paragraph</p>
</component>
Expand Down
6 changes: 4 additions & 2 deletions packages/comark/src/internal/parse/token-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ function parseCodeblockInfo(info: string): {

let remaining = info.trim()

// Extract language (stops at [ or { or whitespace)
const languageMatch = remaining.match(/^([^\s[{]+)/)
// Extract language (stops at [ or { or whitespace).
// Quotes and angle brackets are excluded: the language lands in the
// `language` attr and `language-*` class of the rendered HTML.
const languageMatch = remaining.match(/^([^\s[{}"'<>`]+)/)
if (languageMatch) {
result.language = languageMatch[1]
remaining = remaining.slice(languageMatch[1].length).trim()
Expand Down
24 changes: 16 additions & 8 deletions packages/comark/src/internal/stringify/attributes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { stringifyYaml } from '../yaml.ts'
import { get } from '../../utils/index.ts'
import { escapeHtml, get } from '../../utils/index.ts'
import type { NodeRenderData } from '../../types.ts'

export interface ResolveAttributesOptions {
Expand Down Expand Up @@ -188,6 +188,11 @@ export function comarkAttributes(attributes: Record<string, unknown>) {
return attrs.length > 0 ? `{${attrs}}` : ''
}

// 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.
const SAFE_ATTR_NAME = /^[a-zA-Z_:][a-zA-Z0-9_:.-]*$/

/**
* Convert attributes to a string of HTML attributes
*
Expand All @@ -196,17 +201,20 @@ export function comarkAttributes(attributes: Record<string, unknown>) {
*/
export function htmlAttributes(attributes: Record<string, unknown>) {
const parts: string[] = []
for (const [key, value] of Object.entries(attributes)) {
if (key.startsWith(':')) {
for (const [rawKey, value] of Object.entries(attributes)) {
const key = rawKey.startsWith(':') ? rawKey.slice(1) : rawKey
if (!SAFE_ATTR_NAME.test(key)) continue

if (rawKey.startsWith(':')) {
if (value === 'true') {
parts.push(key.slice(1))
parts.push(key)
continue
}
if (typeof value === 'object' && value !== null) {
parts.push(`${key.slice(1)}="${JSON.stringify(value).replace(/"/g, '\\"')}"`)
parts.push(`${key}="${escapeHtml(JSON.stringify(value))}"`)
continue
}
parts.push(`${key.slice(1)}="${value}"`)
parts.push(`${key}="${escapeHtml(String(value))}"`)
continue
}

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

if (typeof value === 'object') {
parts.push(`${key}="${JSON.stringify(value).replace(/"/g, '\\"')}"`)
parts.push(`${key}="${escapeHtml(JSON.stringify(value))}"`)
continue
}

parts.push(`${key}="${value}"`)
parts.push(`${key}="${escapeHtml(String(value))}"`)
}
return parts.join(' ')
}
Expand Down
30 changes: 13 additions & 17 deletions packages/comark/src/internal/stringify/state.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { handlers as defaultHandlers } from './handlers/index.ts'
import type { NodeRenderData, State, Context } from 'comark/render'
import type { ElementNode, Node, MarkdownDocument, ConditionalNodeHandler, CreateContext, NodeHandler } from 'comark'
import { pascalCase } from '../../utils/index.ts'
import { escapeHtml, pascalCase } from '../../utils/index.ts'
import { resolveAttributes } from './attributes.ts'

function findHandler(ctx: Context, node: ElementNode): NodeHandler | undefined {
const userHandler = ctx.handlers[node[0] as string] || ctx.handlers[pascalCase(node[0] as string)]
const name = node[0] as string
// Own-property lookups only — the handler maps are plain objects, so a node
// named `constructor`/`__proto__`/… would otherwise resolve through the
// prototype chain (XSS / render crash from untrusted markdown).
const userHandler =
(Object.hasOwn(ctx.handlers, name) ? ctx.handlers[name] : undefined) ||
(Object.hasOwn(ctx.handlers, pascalCase(name)) ? ctx.handlers[pascalCase(name)] : undefined)

if (typeof userHandler === 'function') {
return userHandler
Expand All @@ -31,7 +37,8 @@ function findHandler(ctx: Context, node: ElementNode): NodeHandler | undefined {
export async function one(node: Node, state: State, parent?: ElementNode, atLineStart = false): Promise<string> {
if (typeof node === 'string') {
if (state.context.html) {
return escapeHtml(node)
// Do not convert ampersands to entities in raw HTML blocks
return escapeHtml(node, { '&': undefined, '"': undefined })
}
// The content of a raw HTML block is copied verbatim on parse, so markdown
// syntax inside it must not be escaped (inline HTML, `$.block === 0`, has
Expand Down Expand Up @@ -70,8 +77,9 @@ export async function one(node: Node, state: State, parent?: ElementNode, atLine
return await state.handlers.html(node, state, parent)
}

// fallback to default handlers
const nodeHandler = state.handlers[node[0] as string]
// fallback to default handlers (own-property lookup — see findHandler)
const nodeName = node[0] as string
const nodeHandler = Object.hasOwn(state.handlers, nodeName) ? state.handlers[nodeName] : undefined
if (nodeHandler) {
return await nodeHandler(node, state, parent)
}
Expand Down Expand Up @@ -197,18 +205,6 @@ export const state: State = {
},
}

/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'<': '&lt;',
'>': '&gt;',
'&amp;': '&',
}
return text.replace(/[<>]/g, (char) => map[char])
}

// Characters that can start an inline markdown construct anywhere on a line:
// `\` (escape), `` ` `` (code span), `*`/`_` (emphasis), `<` (raw HTML /
// autolink), `&` (character reference), `~` (strikethrough) and `[`/`]`
Expand Down
20 changes: 16 additions & 4 deletions packages/comark/src/plugins/footnotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ export interface FootnotesConfig {
// [^label]: content
const FOOTNOTE_DEF_RE = /^\[\^([^\s\]]+)\]:[ \t]?(.*)$/gm

/**
* Labels are author-controlled, so they must not leak raw characters into the
* `id`/`href` fragment values built below. Encode anything outside a safe
* charset as `-<hex>-` — deterministic, so references and definitions still
* match after sanitization.
*/
function sanitizeLabel(label: string): string {
return label.replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.charCodeAt(0).toString(16)}-`)
}

/**
* Quick structural check: is this a ['span', {…}, string] tuple?
* Used as the visit() checker to avoid running the full extraction
Expand Down Expand Up @@ -114,15 +124,16 @@ export default defineComarkPlugin((config: FootnotesConfig = {}) => {
refIndexMap.set(refLabel, refIndexMap.size + 1)
}
const refIndex = refIndexMap.get(refLabel)!
const safeLabel = sanitizeLabel(refLabel)

return [
'sup',
{ class: 'footnote-ref' },
[
'a',
{
href: `#fn-${refLabel}`,
id: `fnref-${refLabel}`,
href: `#fn-${safeLabel}`,
id: `fnref-${safeLabel}`,
},
`[${refIndex}]`,
],
Expand Down Expand Up @@ -156,13 +167,14 @@ export default defineComarkPlugin((config: FootnotesConfig = {}) => {

for (const [refLabel] of refIndexMap) {
const content = definitions.get(refLabel)!
const safeLabel = sanitizeLabel(refLabel)

footnoteItems.push([
'li',
{ id: `fn-${refLabel}` },
{ id: `fn-${safeLabel}` },
content,
' ',
['a', { href: `#fnref-${refLabel}`, class: 'footnote-backref' }, backRef],
['a', { href: `#fnref-${safeLabel}`, class: 'footnote-backref' }, backRef],
])
}

Expand Down
26 changes: 26 additions & 0 deletions packages/comark/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,32 @@ export async function visitAsync(

// #region String Utils

const HTML_ESCAPE_RE = /[&<>"]/g
const HTML_ESCAPED_RE = /^&[a-zA-Z][a-zA-Z0-9]*;|#[0-9]+;|#x[0-9a-fA-F]+;/
export function escapeHtml(value: string, replace?: Record<string, string | undefined>): string {
const escapeMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
}
if (replace) {
Object.assign(escapeMap, replace)
}
return value.replace(HTML_ESCAPE_RE, (char, index) => {
switch (char) {
case '&': {
if (escapeMap[char] === '&' || value.slice(index).match(HTML_ESCAPED_RE)) {
return char
}
return escapeMap[char] ?? char
}
default:
return escapeMap[char] ?? char
}
})
}

export function indent(
text: string,
{ ignoreFirstLine = false, level = 1, width }: { ignoreFirstLine?: boolean; level?: number; width?: number } = {}
Expand Down
Loading
Loading