+ children: Node[]
+ /** True once this frame has survived past the paragraph that opened it. */
+ block: boolean
+ /** Bare-inline nodes waiting to be flushed as a single on a block frame. */
+ pendingInline: Node[]
+}
+
+/** Shared mutable context for a whole document walk. */
+interface ProcessState {
+ preservePositions: boolean
+ headingIds: boolean
+ startLine: number
+ headingSlugCounts: Map
+ headingStack: Array<{ level: number; id: string }>
+ /** Unclosed HTML open tags, outer → inner. Lives across blocks like headingStack. */
+ htmlStack: HtmlOpenFrame[]
+ /**
+ * > 0 while building a nested markdown container (list, blockquote, table…).
+ * Free paragraphs inside those containers keep ownership of their children
+ * instead of nesting into the HTML stack; the finished container lands on the
+ * stack as a whole unit via deliverBlock.
+ */
+ insideMarkdownContainer: number
+}
+
+type Processor = (tokens: Token[], start: number, state: ProcessState) => ProcessorResult
+
+function makeHtmlAttrs(attrs: Record, block: boolean): Record {
+ return {
+ $: block ? { html: 1, block: 1 } : { html: 1, block: 0 },
+ ...attrs,
+ }
+}
+
+function pushNode(nodes: Node[], node: Node) {
+ if (node[0] === 'fragment') nodes.push(...(node.slice(2) as Node[]))
+ else nodes.push(node)
+}
+
+function isInlineish(node: Node): boolean {
+ if (typeof node === 'string') return true
+ if (!Array.isArray(node)) return false
+ const tag = node[0]
+ return tag === null || inlineTags.has(tag as string)
+}
+
+function flushPendingInline(frame: HtmlOpenFrame) {
+ if (frame.pendingInline.length === 0) return
+ const pending = frame.pendingInline
+ frame.pendingInline = []
+ frame.children.push(['p', {}, ...pending] as ElementNode)
+}
+
+/** Flatten nested nodes to text — used when emitting comment frames. */
+function flattenText(nodes: Node[]): string {
+ let out = ''
+ for (const n of nodes) {
+ if (typeof n === 'string') out += n
+ else if (Array.isArray(n)) out += flattenText(n.slice(2) as Node[])
+ }
+ return out
+}
+
+function frameToNode(frame: HtmlOpenFrame): ElementNode | CommentNode {
+ flushPendingInline(frame)
+ // Comments (`tag === null`): flatten children to a body string.
+ // Blank lines between the source paragraphs become `\n\n` between chunks.
+ if (frame.tag === null) {
+ const chunks = frame.children.map((c) =>
+ typeof c === 'string' ? c : flattenText(Array.isArray(c) && c[0] === 'p' ? (c.slice(2) as Node[]) : [c])
+ )
+ // Spanned comments always had a blank line after ``
+ const body = chunks.length > 0 ? `\n\n${chunks.join('\n\n')}\n\n` : ''
+ return [null, {}, body] as CommentNode
+ }
+ return [frame.tag as string, makeHtmlAttrs(frame.attrs, frame.block), ...frame.children]
+}
+
+/** Nest a finished block node into the open HTML frame, or return it free. */
+function deliverBlock(state: ProcessState, node: Node | undefined): Node | undefined {
+ if (node === undefined) return undefined
+ if (state.htmlStack.length === 0) return node
+ const top = state.htmlStack[state.htmlStack.length - 1]
+ flushPendingInline(top)
+ top.children.push(node)
+ return undefined
+}
+
+/** Nest an inline/finished node into the open HTML frame during processInline. */
+function deliverInline(state: ProcessState, node: Node | undefined): Node | undefined {
+ if (node === undefined) return undefined
+ if (state.htmlStack.length === 0) return node
+ const top = state.htmlStack[state.htmlStack.length - 1]
+ if (top.block && isInlineish(node)) {
+ top.pendingInline.push(node)
+ } else {
+ flushPendingInline(top)
+ top.children.push(node)
+ }
+ return undefined
+}
+
+/**
+ * `` are just HTML open/close with `tag === null`.
+ * When blank lines split them into plain text tokens, map those tokens onto the
+ * same push/pop path html_inline uses for elements.
+ */
+function tryCommentTag(content: string, state: ProcessState): ProcessorResult | null {
+ const top = state.htmlStack[state.htmlStack.length - 1]
+
+ // Close comment
+ if (top?.tag === null && content.includes('-->')) {
+ const i = content.indexOf('-->')
+ if (i > 0) deliverInline(state, content.slice(0, i))
+ const frame = state.htmlStack.pop()!
+ return { nextIndex: -1, node: frameToNode(frame) } // nextIndex filled by caller
+ }
+
+ // Open comment (no close in this chunk)
+ if (content.startsWith('')) {
+ const body = content.slice(4)
+ state.htmlStack.push({ tag: null, attrs: {}, children: body ? [body] : [], block: true, pendingInline: [] })
+ return { nextIndex: -1, node: undefined }
+ }
+
+ // Complete comment in one chunk
+ if (content.startsWith('')) {
+ const end = content.indexOf('-->')
+ return { nextIndex: -1, node: [null, {}, content.slice(4, end)] as CommentNode }
+ }
+
+ return null
+}
+
+function processToken(tokens: Token[], i: number, state: ProcessState): ProcessorResult | undefined {
+ return processors[tokens[i].type]?.(tokens, i, state)
+}
+
+/**
+ * Walk inline tokens. HTML open/close tags drive `state.htmlStack`.
+ * @param nestHtml When true (free paragraph / top-level), free nodes nest into
+ * the open HTML stack. When false (nested markdown containers like strong/li),
+ * free nodes return to the parent container — the container itself later lands
+ * on the HTML stack as a whole unit via deliverBlock.
+ */
+function processInline(inlineTokens: Token[], state: ProcessState, nestHtml = true): Node[] {
+ const nodes: Node[] = []
+ let i = 0
+
+ while (i < inlineTokens.length) {
+ const result = processToken(inlineTokens, i, state)
+ if (!result) {
+ i += 1
+ continue
+ }
+ const node = nestHtml ? deliverInline(state, result.node) : result.node
+ if (node !== undefined) pushNode(nodes, node)
+ i = result.nextIndex
+ }
+
+ return nodes
+}
+
+function preserveLineNumber(tokens: Token[], node: Node, start: number, nextIndex: number, state: ProcessState) {
+ let endLine = state.startLine
+ if (!Array.isArray(node)) return
+
+ for (let j = start; j < nextIndex; j++) {
+ if (tokens[j].map && tokens[j].map?.[1]) {
+ endLine = (tokens[j].map?.[1] as number) + state.startLine + (tokens[j].type?.endsWith('_close') ? 1 : 0)
+ }
+ }
+ if (!(node[1] as Record).$) {
+ ;(node[1] as Record).$ = {}
+ }
+ ;((node[1] as Record).$ as Record).line = endLine
+}
+
+/** Walk children of an open/close pair until `closeType`. */
+function processChildren(
+ tokens: Token[],
+ start: number,
+ closeType: string,
+ state: ProcessState,
+ nestHtml = false
+): { children: Node[]; nextIndex: number } {
+ const children: Node[] = []
+ let i = start + 1
+
+ while (i < tokens.length) {
+ const token = tokens[i]
+ if (token.type === closeType) {
+ return { children, nextIndex: i + 1 }
+ }
+
+ if (token.type === 'inline') {
+ children.push(...processInline(token.children ?? [], state, nestHtml))
+ i += 1
+ continue
+ }
+
+ const result = processToken(tokens, i, state)
+ if (result) {
+ if (result.node !== undefined) pushNode(children, result.node)
+ i = result.nextIndex
+ } else {
+ i += 1
+ }
+ }
+
+ return { children, nextIndex: i }
+}
+
+function processPossibleAttributesSyntax(tokens: Token[], value: { nextIndex: number; node: Node }) {
+ const extractedAttributes = extractAttributes(tokens, value.nextIndex)
+ if (value.nextIndex < extractedAttributes.nextIndex) {
+ ;(value.node as ElementNode)[1] = Object.assign(value.node[1], extractedAttributes.attrs)
+ value.nextIndex = extractedAttributes.nextIndex
+ }
+
+ return value
+}
+
+function processBlockChildrenWithSlots(
+ tokens: Token[],
+ start: number,
+ closeType: string,
+ state: ProcessState
+): { children: Node[]; nextIndex: number } {
+ const nodes: Node[] = []
+ let i = start + 1
+ let currentSlot: { tag: string; attrs: Record; children: Node[] } | null = null
+
+ const flushSlot = () => {
+ if (!currentSlot) return
+ nodes.push([currentSlot.tag, currentSlot.attrs, ...mergeAdjacentTextNodes(currentSlot.children)] as ElementNode)
+ currentSlot = null
+ }
+
+ const pushChild = (node: Node | undefined) => {
+ if (node === undefined) return
+
+ pushNode(currentSlot ? currentSlot.children : nodes, node)
+ }
+
+ while (i < tokens.length) {
+ const token = tokens[i]
+
+ if (token.type === closeType) {
+ flushSlot()
+ return { children: nodes, nextIndex: i + 1 }
+ }
+
+ if (token.type === 'mdc_block_slot_open') {
+ flushSlot()
+ currentSlot = {
+ tag: token.tag || 'template',
+ attrs: processAttributes(token.attrs),
+ children: [],
+ }
+ i += 1
+ continue
+ }
+
+ if (token.type === 'mdc_block_slot_close') {
+ i += 1
+ continue
+ }
+
+ if (token.type === 'inline') {
+ const free = processInline(token.children ?? [], state)
+ if (currentSlot) currentSlot.children.push(...free)
+ else nodes.push(...free)
+ i += 1
+ continue
+ }
+
+ const result = processToken(tokens, i, state)
+ if (result) {
+ pushChild(result.node)
+ i = result.nextIndex
+ } else {
+ i += 1
+ }
+ }
+
+ flushSlot()
+ return { children: nodes, nextIndex: i }
+}
+
+function processMdcBlock(tokens: Token[], start: number, state: ProcessState): ProcessorResult {
+ const open = tokens[start]
+ // Own child paragraphs/inlines so an outer unclosed HTML tag (e.g. ``)
+ // doesn't steal `::component` body content off the stack.
+ state.insideMarkdownContainer += 1
+ const { children, nextIndex } = processBlockChildrenWithSlots(tokens, start, 'mdc_block_close', state)
+ state.insideMarkdownContainer -= 1
+ const attrs = processAttributes(open.attrs)
+
+ let node = [open.tag || 'div', attrs, ...mergeAdjacentTextNodes(children)] as ElementNode
+ if (WRAPPER_TAGS.has(node[0])) {
+ if (children.length === 1 && children[0][0] === node[0]) {
+ node = node[2] as ElementNode
+ node[1] = { ...node[1], ...attrs }
+ }
+ }
+ return processPossibleAttributesSyntax(tokens, {
+ nextIndex,
+ node,
+ })
+}
+
+function codeBlockProcessor(tokens: Token[], start: number, _state: ProcessState): ProcessorResult {
+ const token = tokens[start]
+ const content = token.content || ''
+ const info = token.info || (token as Token & { params?: string }).params || ''
+
+ const parsed = parseCodeblockInfo(info)
+
+ const preAttrs: Record = parsed
+ const codeAttrs: Record = {}
+ if (parsed.language && parsed.language.trim()) {
+ preAttrs.language = parsed.language
+ codeAttrs['class'] = `language-${parsed.language}`
+ }
+
+ const codeContentWithoutLastNewline = content.endsWith('\n') ? content.slice(0, -1) : content
+ return {
+ nextIndex: start + 1,
+ node: ['pre', preAttrs, ['code', codeAttrs, codeContentWithoutLastNewline]],
+ }
+}
+
+function uniqueSlug(slug: string, level: number, state: ProcessState): string {
+ while (state.headingStack.length > 0 && state.headingStack[state.headingStack.length - 1].level >= level) {
+ state.headingStack.pop()
+ }
+ if (state.headingStack.length > 0) {
+ const parent = state.headingStack[state.headingStack.length - 1]
+ if (parent.level >= 2) {
+ slug = parent.id + '-' + slug
+ }
+ }
+
+ state.headingStack.push({ level, id: slug })
+
+ const count = state.headingSlugCounts.get(slug) ?? 0
+ state.headingSlugCounts.set(slug, count + 1)
+ return count === 0 ? slug : `${slug}-${count}`
+}
+
+function singleToken(fn: (token: Token) => Node) {
+ return (tokens: Token[], start: number): ProcessorResult =>
+ processPossibleAttributesSyntax(tokens, { nextIndex: start + 1, node: fn(tokens[start]) })
+}
+
+function openCloseToken(closeType: string, tag: string = '', nest = false) {
+ return (tokens: Token[], start: number, state: ProcessState) => {
+ const open = tokens[start]
+ if (nest) state.insideMarkdownContainer += 1
+ const { children, nextIndex } = processChildren(tokens, start, closeType, state)
+ if (nest) state.insideMarkdownContainer -= 1
+ const attrs = processAttributes(open.attrs)
+ return processPossibleAttributesSyntax(tokens, {
+ nextIndex,
+ node: [tag || open.tag, attrs, ...mergeAdjacentTextNodes(children)],
+ })
+ }
+}
+
+const processors: Record = {
+ // nest=true: containers that own child paragraphs/inlines (so free HTML stack
+ // doesn't steal their content while a block-level HTML tag is open above them).
+ mdc_block_slot_open: openCloseToken('mdc_block_slot_close'),
+ mdc_inline_span_open: openCloseToken('mdc_inline_span_close'),
+ mdc_block_shorthand_open: openCloseToken('mdc_block_shorthand_close'),
+ mdc_inline_component_open: openCloseToken('mdc_inline_component_close'),
+ blockquote_open: openCloseToken('blockquote_close', '', true),
+ bullet_list_open: openCloseToken('bullet_list_close', '', true),
+ ordered_list_open: openCloseToken('ordered_list_close', '', true),
+ list_item_open: openCloseToken('list_item_close', '', true),
+ strong_open: openCloseToken('strong_close'),
+ link_open: openCloseToken('link_close'),
+ em_open: openCloseToken('em_close'),
+ table_open: openCloseToken('table_close', '', true),
+ thead_open: openCloseToken('thead_close', '', true),
+ tbody_open: openCloseToken('tbody_close', '', true),
+ tr_open: openCloseToken('tr_close', '', true),
+ th_open: openCloseToken('th_close', '', true),
+ td_open: openCloseToken('td_close', '', true),
+ sub_open: openCloseToken('sub_close'),
+ sup_open: openCloseToken('sup_close'),
+ s_open: openCloseToken('s_close', 'del'),
+ mdc_block_open: processMdcBlock,
+ code_inline: singleToken((t) => ['code', {}, t.content]),
+ math_inline: singleToken((t) => ['math', { class: 'math inline', content: t.content }, t.content]),
+ math_block: singleToken((t) => ['math', { class: 'math block', content: t.content }, t.content]),
+ emoji: singleToken((t) => t.content),
+ hr: singleToken(() => ['hr', {}]),
+ hardbreak: singleToken(() => ['br', {}]),
+ reference: (_, s) => ({ node: undefined, nextIndex: s + 1 }),
+ // Softbreaks inside open HTML are paragraph separators, not text content
+ softbreak: (tokens, start, state) => ({
+ nextIndex: start + 1,
+ node: state.htmlStack.length > 0 || tokens[start - 1].type === 'html_inline' ? undefined : '\n',
+ }),
+ code_block: codeBlockProcessor,
+ fenced_code_block: codeBlockProcessor,
+ fence: codeBlockProcessor,
+ mdc_block_shorthand: singleToken((t) => [t.tag, processAttributes(t.attrs)]),
+ heading_open(tokens, start, state) {
+ const { nextIndex, node } = openCloseToken('heading_close')(tokens, start, state)
+ if (node.length === 2) return { node: undefined, nextIndex }
+
+ if (state.headingIds) {
+ const level = Number.parseInt((tokens[start].tag || 'h1').replace('h', ''), 10) || 1
+ const _textContent = textContent(node)
+ const headingId = uniqueSlug(slugify(_textContent), level, state)
+ ;(node as ElementNode)[1] = { id: headingId, ...(node[1] as Record) }
+ }
+
+ return { nextIndex, node }
+ },
+
+ paragraph_open(tokens, start, state) {
+ const inline = tokens[start + 1]
+ const depthBefore = state.htmlStack.length
+ const nestHtml = state.insideMarkdownContainer === 0
+ const { children, nextIndex } = processChildren(tokens, start, 'paragraph_close', state, nestHtml)
+ const asParagraph = (): ProcessorResult =>
+ children.length === 0
+ ? { nextIndex, node: undefined }
+ : {
+ nextIndex,
+ node: ['p', processAttributes(tokens[start].attrs), ...mergeAdjacentTextNodes(children)],
+ }
+
+ // Closed HTML root that started before this paragraph.
+ if (depthBefore > 0 && state.htmlStack.length === 0) {
+ if (children.length === 1 && Array.isArray(children[0])) {
+ return { nextIndex, node: children[0] as ElementNode }
+ }
+ return asParagraph()
+ }
+
+ // Stack still open — free content already nested via deliverInline.
+ if (state.htmlStack.length > 0) {
+ if (nestHtml) {
+ for (const frame of state.htmlStack) flushPendingInline(frame)
+ return { nextIndex, node: undefined }
+ }
+ return asParagraph()
+ }
+
+ const empty = asParagraph()
+ if (empty.node === undefined) return empty
+
+ const result = processPossibleAttributesSyntax(tokens, { nextIndex, node: empty.node })
+ const final = result.node
+ const canUnwrap =
+ Array.isArray(final) &&
+ final.length === 3 &&
+ inline?.type === 'inline' &&
+ inline.children?.[0]?.type === 'html_inline' &&
+ (!inlineTags.has((final[2] as ElementNode)?.[0] as string) ||
+ Boolean((empty.node[2][1] as ElementNodeAttributes)?.$?.block))
+
+ if (canUnwrap) {
+ const unwrapped = final[2] as ElementNode
+ if ((unwrapped[1] as ElementNodeAttributes).$) {
+ ;(unwrapped[1] as ElementNodeAttributes).$!.block = 1
+ }
+ return { nextIndex: result.nextIndex, node: unwrapped }
+ }
+
+ if ((result.node as ElementNode).every((n, i) => i < 2 || (n?.[1] as ElementNodeAttributes)?.$?.html)) {
+ ;(result.node as ElementNode)[0] = 'fragment'
+ }
+
+ return result
+ },
+ mdc_inline_component(tokens, start) {
+ const token = tokens[start]
+ const tokenAttrs = processAttributes(token.attrs)
+ const { attrs, nextIndex } = extractAttributes(tokens, start + 1, false)
+ return { node: [token.tag, { ...tokenAttrs, ...attrs }] as Node, nextIndex }
+ },
+
+ image(tokens, start) {
+ const token = tokens[start]
+ const attrs = processAttributes(token.attrs, { handleJSON: false, filterEmpty: true })
+ if (token.content) {
+ attrs.alt = token.content
+ }
+ return processPossibleAttributesSyntax(tokens, { node: ['img', attrs] as Node, nextIndex: start + 1 })
+ },
+
+ text(tokens, start, state) {
+ const content = tokens[start].content
+ if (content === '') return { nextIndex: start + 1, node: undefined }
+
+ // `` are HTML open/close with tag null (may arrive as plain text
+ // when blank lines split a multi-line comment).
+ const comment = tryCommentTag(content, state)
+ if (comment) return { nextIndex: start + 1, node: comment.node }
+
+ return { nextIndex: start + 1, node: content }
+ },
+ // html_inline drives the document-wide HTML open stack.
+ // - self-closing / void → single element
+ // - open → push frame (siblings / later blocks fill children)
+ // - matching close → pop frame and emit completed element
+ html_inline(tokens, start, state) {
+ const raw = tokens[start].content || ''
+ const parsed = parseHtmlInline(raw)
+
+ if (parsed.kind === 'comment') {
+ return { nextIndex: start + 1, node: [null, {}, parsed.content] }
+ }
+ if (parsed.kind === 'other') {
+ // Bare `` fragments use the same stack as elements.
+ const comment = tryCommentTag(raw, state)
+ if (comment) return { nextIndex: start + 1, node: comment.node }
+ return { nextIndex: start + 1, node: [null, {}, raw] }
+ }
+
+ if (parsed.kind === 'close') {
+ const top = state.htmlStack[state.htmlStack.length - 1]
+ if (top && top.tag === parsed.tag) {
+ return { nextIndex: start + 1, node: frameToNode(state.htmlStack.pop()!) }
+ }
+ return { nextIndex: start + 1, node: undefined }
+ }
+
+ if (parsed.selfClosing) {
+ return {
+ nextIndex: start + 1,
+ node: [parsed.tag, makeHtmlAttrs(parsed.attrs, false)],
+ }
+ }
+
+ state.htmlStack.push({
+ tag: parsed.tag,
+ attrs: parsed.attrs,
+ children: [],
+ block: false,
+ pendingInline: [],
+ })
+ return { nextIndex: start + 1, node: undefined }
+ },
+}
+
+export function tokenListToTree(tokens: Token[], options: ProcessorOptions = {}): Node[] {
+ const state: ProcessState = {
+ preservePositions: options.preservePositions ?? false,
+ headingIds: options.headingIds ?? true,
+ startLine: options.startLine ?? 0,
+ headingSlugCounts: new Map(),
+ headingStack: [],
+ htmlStack: [],
+ insideMarkdownContainer: 0,
+ }
+ const nodes: Node[] = []
+ let i = 0
+
+ while (i < tokens.length) {
+ const token = tokens[i]
+
+ if (token.type === 'inline') {
+ const free = processInline(token.children ?? [], state)
+ // Free inline at block level (rare) — if stack open, nest; else emit
+ if (state.htmlStack.length > 0) {
+ for (const n of free) state.htmlStack[state.htmlStack.length - 1].children.push(n)
+ } else {
+ nodes.push(...free)
+ }
+ i += 1
+ continue
+ }
+
+ const result = processToken(tokens, i, state)
+ if (result) {
+ // Remaining open frames span past this block.
+ for (const frame of state.htmlStack) frame.block = true
+
+ if (result.node !== undefined) {
+ if (state.preservePositions) {
+ preserveLineNumber(tokens, result.node, i, result.nextIndex, state)
+ }
+ const free = deliverBlock(state, result.node)
+ if (free !== undefined) pushNode(nodes, free)
+ }
+ i = result.nextIndex
+ } else {
+ nodes.push([token.tag || 'component', processAttributes(token.attrs, { handleJSON: false })])
+ i += 1
+ }
+ }
+
+ // EOF — close remaining unclosed HTML tags (outermost last).
+ // Incomplete openers with only text leaves stay block: 0 (inline-like).
+ // Anything with element children (markdown, nested HTML, lists) is block: 1.
+ while (state.htmlStack.length > 0) {
+ const frame = state.htmlStack.pop()!
+ frame.block = true
+ const node = frameToNode(frame)
+ if (state.htmlStack.length > 0) {
+ state.htmlStack[state.htmlStack.length - 1].children.push(node)
+ } else {
+ nodes.push(node)
+ }
+ }
+
+ return nodes
+}
diff --git a/packages/comark/src/internal/parse/utils.ts b/packages/comark/src/internal/parse/utils.ts
new file mode 100644
index 00000000..a93f2206
--- /dev/null
+++ b/packages/comark/src/internal/parse/utils.ts
@@ -0,0 +1,205 @@
+import type { Node, ElementNode, ElementNodeAttributes, MarkdownDocument } from 'comark'
+import { decodeHTML, TREE_WALK_MAX_DEPTH } from 'comark/utils'
+
+/** HTML void elements — never have children / closing tags. */
+const HTML_VOID_ELEMENTS = new Set([
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'link',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+])
+
+export type ParsedHtmlTag =
+ | { kind: 'open'; tag: string; attrs: Record; selfClosing: boolean }
+ | { kind: 'close'; tag: string }
+ | { kind: 'comment'; content: string }
+ | { kind: 'other'; content: string }
+
+/**
+ * Applies automatic unwrapping to container components.
+ *
+ * This utility removes unnecessary paragraph wrappers from container component children.
+ * If a container has only a single paragraph child (and no other block elements),
+ * the paragraph is unwrapped and its children are hoisted up to be direct children
+ * of the container.
+ *
+ * Recursion is capped at {@link TREE_WALK_MAX_DEPTH} element levels; deeper subtrees are left as-is.
+ *
+ * @param node - The Comark element to process
+ * @returns The node with auto-unwrapped children (if applicable)
+ *
+ * @example
+ * // Before:
+ * { tag: 'alert', children: [{ type: 'element', tag: 'p', children: [{ type: 'text', value: 'Text' }] }] }
+ *
+ * // After:
+ * { tag: 'alert', children: [{ type: 'text', value: 'Text' }] }
+ */
+function isBlankText(value: string): boolean {
+ for (let i = 0; i < value.length; i++) {
+ const c = value.charCodeAt(i)
+ if (c !== 32 && c !== 10 && c !== 9 && c !== 13 && c !== 12) return false
+ }
+ return true
+}
+
+export function applyAutoUnwrap(node: Node): Node {
+ return applyAutoUnwrapAt(node, 0)
+}
+
+function applyAutoUnwrapAt(node: Node, depth: number): Node {
+ if (depth >= TREE_WALK_MAX_DEPTH || typeof node === 'string' || node[0] === 'p' || node[0] == null) {
+ return node
+ }
+
+ const length = node.length
+ if (length < 3) {
+ return node
+ }
+
+ let significant: Node | undefined
+ let significantCount = 0
+ for (let i = 2; i < length; i++) {
+ const child = node[i] as Node
+ if (typeof child === 'string' && isBlankText(child)) continue
+ significant = child
+ if (++significantCount > 1) break
+ }
+
+ if (significantCount === 0) {
+ return node
+ }
+
+ if (significantCount === 1 && Array.isArray(significant) && significant[0] === 'p') {
+ // Lift the paragraph's attrs onto the parent so trailing `{attr}` survives the unwrap.
+ // Parent attrs take precedence so explicit component props aren't overridden.
+ const paragraphAttrs = significant[1] as Record | undefined
+ let mergedProps = node[1]
+ if (paragraphAttrs) {
+ for (const key in paragraphAttrs) {
+ if (key !== undefined) {
+ mergedProps = { ...paragraphAttrs, ...node[1] }
+ break
+ }
+ }
+ }
+ const unwrapped = significant.slice() as unknown as ElementNode
+ unwrapped[0] = node[0] as string
+ unwrapped[1] = mergedProps
+ return unwrapped
+ }
+
+ let copy: Node[] | undefined
+ for (let i = 2; i < length; i++) {
+ const child = node[i] as Node
+ const next = applyAutoUnwrapAt(child, depth + 1)
+ if (copy !== undefined) {
+ copy.push(next)
+ } else if (next !== child) {
+ copy = node.slice(0, i) as unknown as Node[]
+ copy.push(next)
+ }
+ }
+ return copy !== undefined ? (copy as Node) : node
+}
+
+/**
+ * Extracts reusable nodes from the last output tree
+ * @param markdown - The markdown to parse
+ * @param lastOutput - The last output tree
+ * @returns The reusable nodes and the remaining markdown
+ */
+export function extractReusableNodes(markdown: string, lastOutput: MarkdownDocument) {
+ let lastValidNodeIndex = -1
+ let i = lastOutput.nodes.length - 1
+ let lastNodeIgnored = false
+ while (i >= 0) {
+ const node = lastOutput.nodes[i] as ElementNode
+ if (node[1] && node[1].$?.line) {
+ if (lastNodeIgnored) {
+ lastValidNodeIndex = i
+ break
+ } else {
+ lastNodeIgnored = true
+ }
+ }
+ i--
+ }
+ const lastNode = lastValidNodeIndex !== -1 ? lastOutput.nodes[lastValidNodeIndex] : null
+ if (lastNode) {
+ const remainingMarkdownStartLine = (lastNode[1] as ElementNodeAttributes).$?.line ?? 0
+ return {
+ remainingMarkdownStartLine,
+ reusedNodes: lastOutput.nodes.slice(0, lastValidNodeIndex + 1),
+ remainingMarkdown: markdown.split('\n').slice(remainingMarkdownStartLine).join('\n') || '',
+ }
+ }
+
+ return {
+ remainingMarkdownStartLine: 0,
+ remainingMarkdown: markdown,
+ reusedNodes: [],
+ }
+}
+
+/**
+ * Parse a single html_inline token content into a structured tag description.
+ * Handles open tags (with attrs), close tags, self-closing syntax, and comments.
+ */
+export function parseHtmlInline(content: string): ParsedHtmlTag {
+ const trimmed = content.trim()
+
+ if (trimmed.startsWith('')) {
+ return { kind: 'comment', content: trimmed.slice(4, -3) }
+ }
+
+ const closeMatch = trimmed.match(/^<\/\s*([A-Za-z][\w:-]*)\s*>$/)
+ if (closeMatch) {
+ return { kind: 'close', tag: closeMatch[1] }
+ }
+
+ const openMatch = trimmed.match(/^<\s*([A-Za-z][\w:-]*)((?:\s+[\s\S]*?)?)\s*(\/?)>$/)
+ if (openMatch) {
+ const tag = openMatch[1]
+ const attrsStr = openMatch[2] || ''
+ const slash = openMatch[3] === '/'
+ const selfClosing = slash || HTML_VOID_ELEMENTS.has(tag.toLowerCase())
+ return {
+ kind: 'open',
+ tag,
+ attrs: parseHtmlAttributes(attrsStr),
+ selfClosing,
+ }
+ }
+
+ return { kind: 'other', content: trimmed }
+}
+
+/**
+ * Parse HTML attribute string into a key/value map.
+ * Supports `key="value"`, `key='value'`, bare `key=value`, and boolean attributes.
+ */
+export function parseHtmlAttributes(attrsStr: string): Record {
+ const attrs: Record = {}
+ if (!attrsStr || !attrsStr.trim()) return attrs
+
+ const re = /([:\w.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
+ let match: RegExpExecArray | null
+ while ((match = re.exec(attrsStr)) !== null) {
+ const key = match[1]
+ const value = match[2] ?? match[3] ?? match[4]
+ attrs[key] = value === undefined ? true : decodeHTML(value)
+ }
+
+ return attrs
+}
diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts
index 7869b489..2b555566 100644
--- a/packages/comark/src/parse.ts
+++ b/packages/comark/src/parse.ts
@@ -11,20 +11,19 @@ import type {
MarkdownDocument,
Node,
} from './types.ts'
-import MarkdownExit from 'markdown-exit'
+import MarkdownExit, { type Token } from 'markdown-exit'
import components from './plugins/components.ts'
import attributes from './plugins/attributes.ts'
import taskList from './plugins/task-list.ts'
import alert from './plugins/alert.ts'
import html from './plugins/html.ts'
import frontmatterPlugin from './plugins/frontmatter.ts'
-import { applyAutoUnwrap } from './internal/parse/auto-unwrap.ts'
+import { applyAutoUnwrap, extractReusableNodes } from './internal/parse/utils.ts'
import { applyUnwrap, resolveUnwrapTags } from './internal/parse/unwrap.ts'
-import { marmdownItTokensToMarkdownDocument } from './internal/parse/token-processor.ts'
import { autoCloseMarkdown } from './internal/parse/auto-close/index.ts'
-import { extractReusableNodes } from './internal/parse/incremental.ts'
import { createSerializedTask, dedupePlugins } from './utils/helpers.ts'
import { noopTracer, withSpan } from './utils/trace.ts'
+import { tokenListToTree } from './internal/parse/tree.ts'
// Re-export frontmatter utilities
export { parseFrontmatter } from './internal/frontmatter.ts'
@@ -113,7 +112,7 @@ export function createMarkdownParser {
const state = {
options,
- tokens: [] as unknown[],
+ tokens: [] as Token[],
markdown,
tree: null as MarkdownDocument | null,
parsedLines: 0,
@@ -176,9 +175,15 @@ export function createMarkdownParser
+ plugin.markdownItPost!(state as ComarkParsePostState)
+ )
+ }
+
const nodesSpan = tracer.startSpan('comark:nodes')
- let nodes = marmdownItTokensToMarkdownDocument(state.tokens, {
+ let nodes = tokenListToTree(state.tokens, {
startLine: state.parsedLines,
preservePositions: opts.streaming ?? false,
headingIds: options.headingIds ?? true,
diff --git a/packages/comark/src/plugins/components.ts b/packages/comark/src/plugins/components.ts
index 4e514c9a..9db23767 100644
--- a/packages/comark/src/plugins/components.ts
+++ b/packages/comark/src/plugins/components.ts
@@ -62,7 +62,7 @@ const markdownItComarkBlock: PluginSimple = (md) => {
if (!silent) {
if (content !== undefined) {
- const tokenOpen = state.push('mdc_block_shorthand', name, 1)
+ const tokenOpen = state.push('mdc_block_shorthand_open', name, 1)
props?.forEach(([key, value]) => {
if (key === 'class') tokenOpen.attrJoin(key, value)
else tokenOpen.attrSet(key, value)
@@ -73,7 +73,7 @@ const markdownItComarkBlock: PluginSimple = (md) => {
inline.content = content
inline.children = []
- const tokenClose = state.push('mdc_block_shorthand', name, -1)
+ const tokenClose = state.push('mdc_block_shorthand_close', name, -1)
tokenClose.map = [startLine, startLine + 1]
} else {
const token = state.push('mdc_block_shorthand', name, 0)
@@ -381,8 +381,8 @@ const markdownItComarkBlock: PluginSimple = (md) => {
// Restore lineMax after tokenizing so it doesn't leak a narrower bound to
// whatever comes after this slot (see `comark_block`'s save/restore above).
const oldLineMax = state.lineMax
- const slot = state.push('mdc_block_slot', 'template', 1)
- slot.attrSet(`#${name}`, '')
+ const slot = state.push('mdc_block_slot_open', 'template', 1)
+ slot.attrSet('name', `${name}`)
props?.forEach(([key, value]) => {
if (key === 'class') slot.attrJoin(key, value)
else slot.attrSet(key, value)
@@ -393,7 +393,7 @@ const markdownItComarkBlock: PluginSimple = (md) => {
state.md.block.tokenize(state, startLine + 1, lineEnd)
- state.push('mdc_block_slot', 'template', -1)
+ state.push('mdc_block_slot_close', 'template', -1)
state.line = lineEnd
state.lineMax = oldLineMax
@@ -448,7 +448,7 @@ const markdownItInlineComponent: PluginSimple = (md) => {
if (silent) return true
if (contentStart !== -1) {
- state.push('mdc_inline_component', name, 1)
+ state.push('mdc_inline_component_open', name, 1)
const oldPos = state.pos
const oldPosMax = state.posMax
@@ -458,7 +458,7 @@ const markdownItInlineComponent: PluginSimple = (md) => {
state.pos = oldPos
state.posMax = oldPosMax
- state.push('mdc_inline_component', name, -1)
+ state.push('mdc_inline_component_close', name, -1)
} else {
state.push('mdc_inline_component', name, 0)
}
@@ -489,7 +489,7 @@ const markdownItInlineSpan: PluginSimple = (md) => {
// Returning `false` lets `parseLinkLabel`'s own depth tracking consume nested brackets and the outer link parse
if (silent) return false
- state.push('mdc_inline_span', 'span', 1)
+ state.push('mdc_inline_span_open', 'span', 1)
const oldPos = state.pos
const oldPosMax = state.posMax
@@ -499,7 +499,7 @@ const markdownItInlineSpan: PluginSimple = (md) => {
state.pos = oldPos
state.posMax = oldPosMax
- state.push('mdc_inline_span', 'span', -1)
+ state.push('mdc_inline_span_close', 'span', -1)
state.pos = index + 1
diff --git a/packages/comark/src/plugins/html.ts b/packages/comark/src/plugins/html.ts
index fdc1e4ed..8ab8a7d6 100644
--- a/packages/comark/src/plugins/html.ts
+++ b/packages/comark/src/plugins/html.ts
@@ -8,6 +8,13 @@
* Pass `registerDefaultPlugins: false` (and omit this plugin) to treat HTML
* tags as plain text.
*
+ * Options:
+ * - `markdown` (default `true`): expand text leaves inside closed HTML
+ * fragments as inline markdown (`Hello **World**
` → strong).
+ * When `false`, text inside HTML stays literal (CommonMark default for
+ * closed html_blocks). Blank-line bodies still nest as markdown tokens
+ * via bare open/close pairing.
+ *
* @example
* ```ts
* import { parseMarkdown } from 'comark'
@@ -21,21 +28,148 @@
* ```
*/
-import type { MarkdownExit } from 'markdown-exit'
-import type { MarkdownItPlugin } from '../types.ts'
+import { type StateBlock, Token, type MarkdownExit } from 'markdown-exit'
+import type { ComarkParseTokensState, MarkdownItPlugin } from '../types.ts'
import { defineComarkPlugin } from '../utils/helpers.ts'
-import html_block from '../internal/parse/html/html_block_rule.ts'
-import html_inline from '../internal/parse/html/html_inline_rule.ts'
-
-function markdownItHtml(md: MarkdownExit) {
- md.set({ html: true })
- md.inline.ruler.before('text', 'comark_html_inline', html_inline)
- md.block.ruler.before('html_block', 'comark_html_block', html_block, {
- alt: ['paragraph', 'reference', 'blockquote'],
- })
+
+export interface HtmlPluginOptions {
+ /**
+ * Expand text leaves inside closed HTML fragments as inline markdown.
+ * @default true
+ */
+ markdown?: boolean
+}
+
+export default defineComarkPlugin((opts: HtmlPluginOptions = {}) => {
+ const markdown = opts.markdown !== false
+
+ function markdownItHtml(md: MarkdownExit) {
+ md.set({ html: true })
+
+ // Opt-out marker read by createMarkdownParser: when present, closed HTML
+ // body text stays literal instead of being re-parsed as inline markdown.
+ if (markdown) {
+ // @ts-expect-error - internal utils
+ const html_block = md.block.ruler.__rules__.find((r) => r.name === 'html_block')
+ const fn = html_block.fn
+ html_block.fn = (state: StateBlock, startLine: number, endLine: number, silent: boolean) => {
+ let pos = state.bMarks[startLine] + state.tShift[startLine]
+
+ const tag = state.src.substring(pos, pos + 7)
+ if (tag === '
+
+ This is a warning message.
+ Your changes have been saved.
+ More information is available here.
`)
+
+ expect(result.nodes).toEqual([
+ [
+ 'style',
+ {
+ $: {
+ block: 1,
+ html: 1,
+ },
+ },
+ `.warning {
+ color: red;
+ }
+ .success {
+ color: green;
+ }
+
+ .info {
+ color: blue;
+ }`,
+ ],
+ ['p', { $: { html: 1, block: 0 }, class: 'warning' }, 'This is a warning message.'],
+ ['p', { $: { html: 1, block: 0 }, class: 'success' }, 'Your changes have been saved.'],
+ ['p', { $: { html: 1, block: 0 }, class: 'info' }, 'More information is available here.'],
+ ])
+ })
})
diff --git a/packages/comark/test/html-escape.test.ts b/packages/comark/test/html-escape.test.ts
index 6551678d..f0fb8593 100644
--- a/packages/comark/test/html-escape.test.ts
+++ b/packages/comark/test/html-escape.test.ts
@@ -17,6 +17,18 @@ describe('HTML attribute escaping', () => {
expect(html).not.toContain('title="a" onmouseover=alert(1)"')
})
+ it('decodes " inside quoted HTML attribute values', async () => {
+ const tree = await parseMarkdown('hi')
+ const p = tree.nodes[0] as [string, Record, ...Node[]]
+ const span = (Array.isArray(p[2]) ? p[2] : p) as [string, Record, ...Node[]]
+ expect(span[0]).toBe('span')
+ expect(span[1].title).toBe('A "quote"')
+
+ const html = await renderHtml('hi')
+ expect(html).toContain('title="A "quote""')
+ expect(html).not.toContain('title="A "quote""')
+ })
+
it('escapes quotes in component attribute props', async () => {
const html = await renderHtml(`:span[hi]{title='a" onmouseover=alert(1) x="b'}`)
expect(html).toContain('title="a" onmouseover=alert(1) x="b"')
@@ -49,6 +61,16 @@ describe('HTML attribute escaping', () => {
expect(html).toContain('title="a&b<c>d"')
})
+ it('escapes bare ampersands in URL attribute values', async () => {
+ // Query-string `&` must become `&` in HTML attrs (HTML5).
+ // Re-parse still yields bare `&` because htmlparser2 decodes entities.
+ const html = await renderHtml(`
`)
+ expect(html).toContain('src="https://x.com/?a=1&b=2"')
+ const tree = await parseMarkdown(html)
+ const img = tree.nodes[0] as [string, Record]
+ expect(img[1].src).toBe('https://x.com/?a=1&b=2')
+ })
+
it('escapes object attribute values as JSON with entities', async () => {
const html = await renderNodes([['div', { ':data': { x: '">
' } }, 'hi']])
expect(html).not.toContain('
')
diff --git a/packages/comark/test/plugins/default-plugins.test.ts b/packages/comark/test/plugins/default-plugins.test.ts
index d47605e5..bcd97005 100644
--- a/packages/comark/test/plugins/default-plugins.test.ts
+++ b/packages/comark/test/plugins/default-plugins.test.ts
@@ -25,7 +25,7 @@ describe('default plugin options', () => {
it('parses HTML by default', async () => {
const tree = await parseMarkdown('Hello')
- expect(tree.nodes).toEqual([['p', {}, ['strong', { class: 'bold', $: { html: 1, block: 0 } }, 'Hello']]])
+ expect(tree.nodes).toEqual([['strong', { class: 'bold', $: { html: 1, block: 0 } }, 'Hello']])
})
it('parses frontmatter by default', async () => {
@@ -64,7 +64,7 @@ describe('default plugin options', () => {
registerDefaultPlugins: false,
plugins: [html()],
})
- expect(tree.nodes).toEqual([['p', {}, ['em', { $: { html: 1, block: 0 } }, 'hi']]])
+ expect(tree.nodes).toEqual([['em', { $: { html: 1, block: 0 } }, 'hi']])
})
it('treats attribute braces as plain text when registerDefaultPlugins is false', async () => {
diff --git a/packages/comark/test/plugins/summary.test.ts b/packages/comark/test/plugins/summary.test.ts
index c820e45f..fca097c3 100644
--- a/packages/comark/test/plugins/summary.test.ts
+++ b/packages/comark/test/plugins/summary.test.ts
@@ -13,7 +13,6 @@ After the fold.
describe('summary plugin', () => {
it('writes the nodes before the delimiter to tree.meta.summary', async () => {
const tree = await parseMarkdown(CONTENT, { plugins: [summary()] })
-
expect(tree.meta.summary).toEqual([['p', {}, 'Intro paragraph.']])
})
diff --git a/packages/comark/test/visit.test.ts b/packages/comark/test/visit.test.ts
index a6b6df40..6326b18c 100644
--- a/packages/comark/test/visit.test.ts
+++ b/packages/comark/test/visit.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { visit } from 'comark/utils'
+import { TREE_WALK_MAX_DEPTH, visit } from 'comark/utils'
import type { MarkdownDocument, Node } from 'comark'
describe('visit', () => {
@@ -409,4 +409,53 @@ describe('visit', () => {
expect(tree.nodes[1]).toEqual(['section', {}, 'Replaced'])
expect(tree.nodes[2]).toEqual(['span', {}, 'Keep'])
})
+
+ it('should visit nodes within the depth cap', () => {
+ // TREE_WALK_MAX_DEPTH - 1 wrappers + text — text is at depth N-1 and still visited.
+ let node: Node = 'Deep'
+ for (let i = TREE_WALK_MAX_DEPTH - 2; i >= 0; i--) {
+ node = [`d${i}`, {}, node]
+ }
+
+ const tree: MarkdownDocument = { nodes: [node], frontmatter: {}, meta: {} }
+ const visited: string[] = []
+ visit(
+ tree,
+ () => true,
+ (n) => {
+ visited.push(typeof n === 'string' ? n : String(n[0]))
+ }
+ )
+
+ const expected = Array.from({ length: TREE_WALK_MAX_DEPTH - 1 }, (_, i) => `d${i}`)
+ expected.push('Deep')
+ expect(visited).toEqual(expected)
+ })
+
+ it('should not walk past TREE_WALK_MAX_DEPTH element levels', () => {
+ // TREE_WALK_MAX_DEPTH + 1 wrappers + text — last wrapper and its children are past the cap.
+ let node: Node = 'Too deep'
+ for (let i = TREE_WALK_MAX_DEPTH; i >= 0; i--) {
+ node = [`d${i}`, {}, node]
+ }
+
+ const tree: MarkdownDocument = { nodes: [node], frontmatter: {}, meta: {} }
+ const visited: string[] = []
+ visit(
+ tree,
+ () => true,
+ (n) => {
+ visited.push(typeof n === 'string' ? n : String(n[0]))
+ }
+ )
+
+ expect(visited).toEqual(Array.from({ length: TREE_WALK_MAX_DEPTH }, (_, i) => `d${i}`))
+
+ let cursor = tree.nodes[0] as Node[]
+ for (let i = 0; i <= TREE_WALK_MAX_DEPTH; i++) {
+ expect(cursor[0]).toBe(`d${i}`)
+ cursor = cursor[2] as Node[]
+ }
+ expect(cursor).toBe('Too deep')
+ })
})