From fdfe3cfa29a0bfc5d0686ab880d03d39f700cf51 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 28 Aug 2026 14:22:05 +0200 Subject: [PATCH 01/15] fix: handle two new line separated markdown --- .../HTML/incomplete-block-two-new-line.md | 77 +++++++++++++ .../src/internal/parse/token-processor.ts | 102 ++++++++++++++++-- .../src/internal/stringify/handlers/html.ts | 30 +++++- 3 files changed, 195 insertions(+), 14 deletions(-) create mode 100644 packages/comark/SPEC/HTML/incomplete-block-two-new-line.md diff --git a/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md b/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md new file mode 100644 index 00000000..b4af1106 --- /dev/null +++ b/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md @@ -0,0 +1,77 @@ +## Input + +```md + + +**bold** and more + +- list +- **item** +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "ai-thinking", + {"$": { "html": 1, "block": 0 }}, + [ + "p", + {}, + [ + "strong", + {}, + "bold" + ], + " and more" + ], + [ + "ul", + {}, + [ + "li", + {}, + "list" + ], + [ + "li", + {}, + [ + "strong", + {}, + "item" + ] + ] + ] + ] + ] +} +``` + +## HTML + +```html + +

bold and more

+
    +
  • list
  • +
  • item
  • +
+
+``` + +## Markdown + +```md + + +**bold** and more + +- list +- **item** + +``` diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index 6e157e26..5d3763d8 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -1,5 +1,5 @@ import type { ElementNode, Node } from 'comark' -import { htmlToNodes, parseInlineHtmlTag } from './html/index.ts' +import { htmlToNodes, parseInlineHtmlTag, VOID_ELEMENTS } from './html/index.ts' // `::tag` components that should fold into a single same-tagged child. const WRAPPER_TAGS = new Set(['ul', 'ol', 'table', 'blockquote', 'pre']) @@ -61,7 +61,7 @@ export function marmdownItTokensToMarkdownDocument(tokens: any[], opts?: TokenPr const token = tokens[i] if (token.type === 'html_block') { - const result = processHtmlBlockTokens(tokens, i) + const result = processHtmlBlockTokens(tokens, i, state) nodes.push(...result.nodes) i = result.nextIndex continue @@ -89,13 +89,95 @@ export function marmdownItTokensToMarkdownDocument(tokens: any[], opts?: TokenPr } /** - * Convert an html_block token into Comark nodes. The whole HTML payload is - * parsed once by htmlparser2; text inside is preserved verbatim (no markdown - * re-parsing — CommonMark default). + * Whether an `html_block` token's content already closes its own outer element + * (self-contained on one run: `

`, void tags, comments, etc.). */ -function processHtmlBlockTokens(tokens: any[], startIndex: number): { nodes: Node[]; nextIndex: number } { +function htmlBlockHasOwnClose(content: string): boolean { + const trimmed = content.trim() + if (!trimmed) return false + // Comments, declarations, CDATA, processing instructions: self-terminating. + if (trimmed.startsWith('`, `
`) + if (/\/\s*>\s*$/.test(trimmed) && !trimmed.slice(1).includes('<')) return true + return new RegExp(``, 'i').test(trimmed) +} + +/** Tag name of a bare closing HTML block (`
`), or null. */ +function htmlBlockCloseTag(content: string): string | null { + const match = content.trim().match(/^<\/\s*([a-zA-Z][\w:-]*)\s*>$/) + return match ? match[1].toLowerCase() : null +} + +/** + * Convert an html_block token into Comark nodes. + * + * Self-contained blocks are parsed once by htmlparser2 (text preserved + * verbatim — CommonMark default). Incomplete openers with no matching closer + * later in the token stream absorb subsequent markdown as children + * (`$.block = 0`) so streaming unfinished tags (e.g. `…`) wrap + * their body instead of leaving siblings outside the unclosed element. + */ +function processHtmlBlockTokens( + tokens: any[], + startIndex: number, + state?: ProcessState +): { nodes: Node[]; nextIndex: number } { const content = typeof tokens[startIndex]?.content === 'string' ? tokens[startIndex].content : '' - return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } + + // Self-contained / void / comment / already-closed payload, or a bare closer + // token (`

`) — CommonMark keeps these as independent html_block siblings. + // Bare closers still go through htmlToNodes (htmlparser2 may emit an empty + // element for some tags; that matches existing SPEC/tests). + if (htmlBlockHasOwnClose(content) || htmlBlockCloseTag(content)) { + return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } + } + + const openMatch = content.trim().match(/^<\s*([a-zA-Z][\w:-]*)/) + if (!openMatch) { + return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } + } + const tag = openMatch[1].toLowerCase() + + // Matching closer later in the stream → standard blank-line-terminated HTML + // block behaviour (open and close are siblings; body is markdown between them). + for (let i = startIndex + 1; i < tokens.length; i++) { + const t = tokens[i] + if (t.type === 'html_block' && htmlBlockCloseTag(typeof t.content === 'string' ? t.content : '') === tag) { + return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } + } + } + + // Incomplete open tag with no closer: absorb the rest of the stream as children. + const parsed = htmlToNodes(content) + const node = parsed[0] + if (!node || typeof node === 'string' || node[0] === null) { + return { nodes: parsed, nextIndex: startIndex + 1 } + } + + // `\0` never matches a real token type — process through end of stream. + const children = processBlockChildren(tokens, startIndex + 1, '\0', false, false, false, state) + const element = node as ElementNode + const openerAttrs = (element[1] || {}) as Record + const prevMeta = (openerAttrs.$ || {}) as Record + // `block: 0` marks markdown-parsed children (vs raw HTML body at block:1). + const attrs: Record = { + ...openerAttrs, + $: { ...prevMeta, html: 1, block: 0 }, + } + const openerChildren = element.slice(2) as Node[] + + // Multiple block children keep their structure (p + ul, etc.). A single + // markdown paragraph is left intact so incomplete multi-line bodies match + // the streaming SPEC; autoUnwrap does not apply at the document root to + // these html wrappers (the wrapper is the root node). + return { + nodes: [[element[0], attrs, ...openerChildren, ...children.nodes] as Node], + nextIndex: children.nextIndex, + } } /** @@ -307,7 +389,7 @@ function processBlockToken( // processBlockChildren / processBlockChildrenWithSlots) before reaching here. // Safety fallback when it slips through. if (token.type === 'html_block') { - const result = processHtmlBlockTokens(tokens, startIndex) + const result = processHtmlBlockTokens(tokens, startIndex, state) return { node: result.nodes[0] ?? null, nextIndex: result.nextIndex } } @@ -486,7 +568,7 @@ function processBlockChildrenWithSlots( // html_block can produce multiple nodes — handle before processBlockToken if (token.type === 'html_block') { - const result = processHtmlBlockTokens(tokens, i) + const result = processHtmlBlockTokens(tokens, i, state) if (currentSlotName !== null) { currentSlotChildren.push(...result.nodes) } else { @@ -581,7 +663,7 @@ function processBlockChildren( const token = tokens[i] if (token.type === 'html_block') { - const result = processHtmlBlockTokens(tokens, i) + const result = processHtmlBlockTokens(tokens, i, state) nodes.push(...result.nodes) i = result.nextIndex continue diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts index 73be781f..a60687b1 100644 --- a/packages/comark/src/internal/stringify/handlers/html.ts +++ b/packages/comark/src/internal/stringify/handlers/html.ts @@ -42,6 +42,11 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode const hasTextSibling = children.some((child) => typeof child === 'string') const isBlock = textBlocks.has(String(tag)) const isInline = inlineTags.has(String(tag)) && $.block === 0 + // Incomplete HTML openers (streaming) store markdown/HTML block children under + // `$.block === 0`; those still need multi-line wrapping, not one-liner inline. + const hasBlockChildren = children.some( + (child) => Array.isArray(child) && child[0] !== null && !inlineTags.has(String(child[0])) + ) let oneLiner = isBlock && hasOnlyTextChildren @@ -57,7 +62,9 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode oneLiner = true } - if ($.block === 0) { + // Inline HTML (`block: 0` with only text/inline children) collapses to one line. + // Incomplete block wrappers with real markdown block children stay multi-line. + if ($.block === 0 && !hasBlockChildren) { oneLiner = true } @@ -71,8 +78,12 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode childrenContent.push(await state.one(child, state, node)) } - // A blank line inside a raw-HTML element would terminate it on reparse - const childSeparator = state.context.html ? state.context.blockSeparator : oneLiner ? '' : '\n' + // In markdown mode, block children already append their own blockSeparator, so + // we must not inject extra newlines between them. In HTML mode the separator + // is the pretty-print block gap. A blank line inside a *raw* HTML body would + // terminate the block on reparse — markdown children of incomplete openers + // (`$.block === 0`) intentionally use blank lines like normal markdown. + const childSeparator = state.context.html ? state.context.blockSeparator : '' let content = '' let isPrevBlock = true @@ -106,7 +117,18 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode } if (!oneLiner && content) { - content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n' + if (state.context.html) { + content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n' + } else if ($.block === 0 && hasBlockChildren) { + // Incomplete HTML openers with markdown body: blank line after open tag so + // the body re-parses as markdown, children's own blockSeparators between + // blocks, single newline before close. + content = '\n\n' + content.trimEnd() + '\n' + } else { + // Raw HTML block body (block:1) — keep content flush after the open tag + // so reparse matches CommonMark html_block runs. + content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n' + } } return `<${tag}${attrs}>${content}` + (!parent && !isInline ? state.context.blockSeparator : '') From c36fa08242bae3bc596854ad22a245462684b0d3 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 28 Aug 2026 14:38:36 +0200 Subject: [PATCH 02/15] fix(comark): parse incomplete HTML openers without a blank line Leave single-line incomplete open tags (e.g. `\n**bold**`) as opener-only html_block tokens so the following markdown can be absorbed as children, matching the blank-line incomplete case. --- .../HTML/incomplete-one-new-line-no-unwrap.md | 52 +++++++++++++++++++ .../SPEC/HTML/incomplete-one-new-line.md | 38 ++++++++++++++ .../internal/parse/html/html_block_rule.ts | 31 +++++++++++ 3 files changed, 121 insertions(+) create mode 100644 packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md create mode 100644 packages/comark/SPEC/HTML/incomplete-one-new-line.md diff --git a/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md b/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md new file mode 100644 index 00000000..0da98d2c --- /dev/null +++ b/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md @@ -0,0 +1,52 @@ +--- +options: + autoUnwrap: false +--- + +## Input + +```md + +**bold** +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "ai-thinking", + {"$": { "html": 1, "block": 0 }}, + [ + "p", + {}, + [ + "strong", + {}, + "bold" + ] + ] + ] + ] +} +``` + +## HTML + +```html + +

bold

+
+``` + +## Markdown + +```md + + +**bold** + +``` diff --git a/packages/comark/SPEC/HTML/incomplete-one-new-line.md b/packages/comark/SPEC/HTML/incomplete-one-new-line.md new file mode 100644 index 00000000..74caf377 --- /dev/null +++ b/packages/comark/SPEC/HTML/incomplete-one-new-line.md @@ -0,0 +1,38 @@ +## Input + +```md + +**bold** +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "ai-thinking", + {"$": { "html": 1, "block": 0 }}, + [ + "strong", + {}, + "bold" + ] + ] + ] +} +``` + +## HTML + +```html +bold +``` + +## Markdown + +```md +**bold** +``` diff --git a/packages/comark/src/internal/parse/html/html_block_rule.ts b/packages/comark/src/internal/parse/html/html_block_rule.ts index 057a7677..2b24a069 100644 --- a/packages/comark/src/internal/parse/html/html_block_rule.ts +++ b/packages/comark/src/internal/parse/html/html_block_rule.ts @@ -17,6 +17,20 @@ const HTML_SEQUENCES: [RegExp, RegExp, boolean][] = [ [new RegExp(`${HTML_OPEN_CLOSE_TAG_RE.source}\\s*$`), /^$/, false], ] +/** Open tag name when `line` is a lone start tag (`` / ``), else null. */ +function loneOpenTagName(line: string): string | null { + const trimmed = line.trim() + // Closing tags, void self-closers, comments, declarations — not incomplete openers. + if (!trimmed.startsWith('<') || trimmed.startsWith('\s*$/.test(trimmed)) return null + const match = trimmed.match(/^<([a-zA-Z][\w:-]*)(?:\s[^>]*)?>\s*$/) + return match ? match[1] : null +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export default function html_block(state: StateBlock, startLine: number, endLine: number, silent: boolean) { let pos = state.bMarks[startLine] + state.tShift[startLine] let max = state.eMarks[startLine] @@ -36,8 +50,16 @@ export default function html_block(state: StateBlock, startLine: number, endLine let nextLine = startLine + 1 + // Sequences whose end condition is a blank line (type 6 block tags, type 7 + // generic tags). A lone open tag with no matching closer before EOF is an + // incomplete streaming opener — only consume the opener line so following + // markdown can be tokenized and absorbed by the token processor. + const blankLineTerminated = HTML_SEQUENCES[i][1].source === '^$' + const openerTag = blankLineTerminated ? loneOpenTagName(lineText) : null + // Walk forward until the closer regex matches or we hit a blank line. if (!HTML_SEQUENCES[i][1].test(lineText)) { + let sawMatchingClose = false for (; nextLine < endLine; nextLine++) { if (state.sCount[nextLine] < state.blkIndent) break @@ -45,11 +67,20 @@ export default function html_block(state: StateBlock, startLine: number, endLine max = state.eMarks[nextLine] lineText = state.src.slice(pos, max) + if (openerTag && new RegExp(`^\\s*$`, 'i').test(lineText.trim())) { + sawMatchingClose = true + } + if (HTML_SEQUENCES[i][1].test(lineText)) { if (lineText.length !== 0) nextLine++ break } } + + // Incomplete open tag running to EOF with no closer: leave body for markdown. + if (openerTag && !sawMatchingClose && nextLine >= endLine) { + nextLine = startLine + 1 + } } state.line = nextLine From 30cc1b5a3ecb8bb276859c2bfcf91ab04f0d0888 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 28 Aug 2026 15:47:40 +0200 Subject: [PATCH 03/15] fix(comark): keep trailing lone `$` as currency, not open math A `$` at end of line (or with only trailing whitespace) is not an incomplete inline-math opener, so auto-close leaves it alone instead of appending `$`. --- packages/comark/src/internal/parse/auto-close/index.ts | 10 ++++++++-- packages/comark/test/auto-close.test.ts | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index f8fc51bc..4db82ab2 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -378,8 +378,14 @@ function closeInlineMarkersLinear(line: string, attributesEnabled: boolean): str dollarCount += 2 i++ } else { - dollarCount++ - inMath = true + // A lone `$` with only trailing whitespace after it is currency/literal + // (e.g. `The cost is $`), not an open math span to complete. + let j = i + 1 + while (j < len && (line[j] === ' ' || line[j] === '\t')) j++ + if (j < len) { + dollarCount++ + inMath = true + } } continue } diff --git a/packages/comark/test/auto-close.test.ts b/packages/comark/test/auto-close.test.ts index f63f15e0..40631806 100644 --- a/packages/comark/test/auto-close.test.ts +++ b/packages/comark/test/auto-close.test.ts @@ -22,6 +22,7 @@ Some text with **bold → Some text with **bold** **bold** and *italic* and \`code\` → **bold** and *italic* and \`code\` [text](url → [text](url) $$formula → $$formula$$ +The cost is $ → The cost is $ ~Hello → ~Hello~ ~~Hello → ~~Hello~~ ~Hello~ → ~Hello~ From 59b72368bd08729bc1268a2af780679d7e7e686f Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 28 Aug 2026 16:14:15 +0200 Subject: [PATCH 04/15] fix(comark): nest blank-line HTML bodies under matching open/close Incomplete HTML openers with a later matching closer now absorb intervening tokens (including nested same-tag blocks) as children, so cases like details-in-details build a proper tree instead of sibling empties. --- .../SPEC/HTML/details-inside-details.md | 82 +++++++++++ .../src/internal/parse/token-processor.ts | 131 ++++++++++++++---- packages/comark/test/html-block.test.ts | 19 +-- 3 files changed, 198 insertions(+), 34 deletions(-) create mode 100644 packages/comark/SPEC/HTML/details-inside-details.md diff --git a/packages/comark/SPEC/HTML/details-inside-details.md b/packages/comark/SPEC/HTML/details-inside-details.md new file mode 100644 index 00000000..0dfffc58 --- /dev/null +++ b/packages/comark/SPEC/HTML/details-inside-details.md @@ -0,0 +1,82 @@ +## Input + +```md +
+Top + +
+Nested + +Nested content + +
+ +
+``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "details", + { + "$": { "html": 1, "block": 1 } + }, + [ + "summary", + { + "$": { "html": 1, "block": 1 } + }, + "Top" + ], + [ + "details", + { + "$": { "html": 1, "block": 1 } + }, + [ + "summary", + { + "$": { "html": 1, "block": 1 } + }, + "Nested" + ], + "Nested content" + ] + ] + ] +} +``` + +## HTML + +```html +
+ + Top + +
+ + Nested + Nested content +
+
+``` + +## Markdown + +```md +
+ +Top +
+ +Nested +Nested content +
+
+``` diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index 5d3763d8..976c783f 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -112,14 +112,49 @@ function htmlBlockCloseTag(content: string): string | null { return match ? match[1].toLowerCase() : null } +/** + * Depth of `tag` openers still unclosed inside `content` (can be nested). + * Positive → more openers than closers; 0 → balanced; negative is treated as 0. + */ +function htmlOuterTagDepth(content: string, tag: string): number { + const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const re = new RegExp(`]*>`, 'gi') + let depth = 0 + let m: RegExpExecArray | null + while ((m = re.exec(content)) !== null) { + if (m[0].charAt(1) === '/') depth = Math.max(0, depth - 1) + else if (!/\/\s*>$/.test(m[0])) depth++ + } + return depth +} + +/** Normalize children of an incomplete HTML wrapper before emitting the node. */ +function finalizeIncompleteHtmlChildren(nodes: Node[]): Node[] { + // Markdown paragraphs that only wrap plain text under a raw HTML parent + // (blank-line body of `
`) collapse to that text so the AST matches + // a single content string rather than an extra `

`. + return nodes.map((child) => { + if ( + Array.isArray(child) && + child[0] === 'p' && + !(child[1] as Record | undefined)?.$ && + child.length === 3 && + typeof child[2] === 'string' + ) { + return child[2] as string + } + return child + }) +} + /** * Convert an html_block token into Comark nodes. * * Self-contained blocks are parsed once by htmlparser2 (text preserved - * verbatim — CommonMark default). Incomplete openers with no matching closer - * later in the token stream absorb subsequent markdown as children - * (`$.block = 0`) so streaming unfinished tags (e.g. `…`) wrap - * their body instead of leaving siblings outside the unclosed element. + * verbatim — CommonMark default). Incomplete openers absorb subsequent tokens + * as children until a matching closer (`block: 1` with a closer, or `block: 0` + * for streaming tags with no closer) so nested blank-line HTML like + * `

` builds a real tree. */ function processHtmlBlockTokens( tokens: any[], @@ -128,11 +163,14 @@ function processHtmlBlockTokens( ): { nodes: Node[]; nextIndex: number } { const content = typeof tokens[startIndex]?.content === 'string' ? tokens[startIndex].content : '' - // Self-contained / void / comment / already-closed payload, or a bare closer - // token (`

`) — CommonMark keeps these as independent html_block siblings. - // Bare closers still go through htmlToNodes (htmlparser2 may emit an empty - // element for some tags; that matches existing SPEC/tests). - if (htmlBlockHasOwnClose(content) || htmlBlockCloseTag(content)) { + // Bare closer with no surrounding open — drop (parent consumes matching ones). + if (htmlBlockCloseTag(content)) { + return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } + } + + // Fully closed in this token alone (including multi-line runs with matching + // open/close) — parse as a self-contained HTML fragment. + if (htmlBlockHasOwnClose(content)) { return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } } @@ -142,41 +180,84 @@ function processHtmlBlockTokens( } const tag = openMatch[1].toLowerCase() - // Matching closer later in the stream → standard blank-line-terminated HTML - // block behaviour (open and close are siblings; body is markdown between them). + // How many outer `tag` frames this token opens that still need a closer. + // Opener-only content like `
\n` starts depth 1. + let depth = htmlOuterTagDepth(content, tag) + if (depth <= 0) { + return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } + } + + // Scan ahead for a matching closer (nested same-tag openers bump depth). + let closeIndex = -1 for (let i = startIndex + 1; i < tokens.length; i++) { const t = tokens[i] - if (t.type === 'html_block' && htmlBlockCloseTag(typeof t.content === 'string' ? t.content : '') === tag) { - return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } + if (t.type !== 'html_block') continue + const c = typeof t.content === 'string' ? t.content : '' + const closeTag = htmlBlockCloseTag(c) + if (closeTag === tag) { + depth-- + if (depth === 0) { + closeIndex = i + break + } + continue + } + // Nested opener of the same tag (may include its own closer in the same token). + if (!htmlBlockCloseTag(c)) { + const nestedOpen = c.trim().match(/^<\s*([a-zA-Z][\w:-]*)/) + if (nestedOpen && nestedOpen[1].toLowerCase() === tag) { + depth += htmlOuterTagDepth(c, tag) + } } } - // Incomplete open tag with no closer: absorb the rest of the stream as children. const parsed = htmlToNodes(content) const node = parsed[0] if (!node || typeof node === 'string' || node[0] === null) { return { nodes: parsed, nextIndex: startIndex + 1 } } - // `\0` never matches a real token type — process through end of stream. - const children = processBlockChildren(tokens, startIndex + 1, '\0', false, false, false, state) const element = node as ElementNode const openerAttrs = (element[1] || {}) as Record const prevMeta = (openerAttrs.$ || {}) as Record - // `block: 0` marks markdown-parsed children (vs raw HTML body at block:1). + const openerChildren = element.slice(2) as Node[] + + // No matching closer → streaming incomplete tag (block: 0), absorb to EOF. + if (closeIndex < 0) { + const children = processBlockChildren(tokens, startIndex + 1, '\0', false, false, false, state) + const attrs: Record = { + ...openerAttrs, + $: { ...prevMeta, html: 1, block: 0 }, + } + return { + nodes: [ + [ + element[0], + attrs, + ...openerChildren, + ...finalizeIncompleteHtmlChildren(children.nodes), + ] as Node, + ], + nextIndex: children.nextIndex, + } + } + + // Matching closer → nest body under the opener (block: 1). Slice so + // processBlockChildren stops before the closer; recurse for nested HTML. + const bodyTokens = tokens.slice(startIndex + 1, closeIndex) + const body = processBlockChildren(bodyTokens, 0, '\0', false, false, false, state) + const attrs: Record = { ...openerAttrs, - $: { ...prevMeta, html: 1, block: 0 }, + $: { ...prevMeta, html: 1, block: 1 }, } - const openerChildren = element.slice(2) as Node[] - // Multiple block children keep their structure (p + ul, etc.). A single - // markdown paragraph is left intact so incomplete multi-line bodies match - // the streaming SPEC; autoUnwrap does not apply at the document root to - // these html wrappers (the wrapper is the root node). return { - nodes: [[element[0], attrs, ...openerChildren, ...children.nodes] as Node], - nextIndex: children.nextIndex, + nodes: [ + [element[0], attrs, ...openerChildren, ...finalizeIncompleteHtmlChildren(body.nodes)] as Node, + ], + // Consume the closer as well. + nextIndex: closeIndex + 1, } } diff --git a/packages/comark/test/html-block.test.ts b/packages/comark/test/html-block.test.ts index c19b8748..914f20a2 100644 --- a/packages/comark/test/html-block.test.ts +++ b/packages/comark/test/html-block.test.ts @@ -56,7 +56,7 @@ That is some text here.` expect(result.nodes).toEqual([['p', { $: { html: 1, block: 1 } }, 'this is **markdown**']]) }) - it('parses markdown as a sibling when a blank line separates it from the HTML tags', async () => { + it('nests blank-line markdown body under a matching HTML open/close pair', async () => { const result = await parseMarkdown(`

this is **markdown** @@ -64,9 +64,7 @@ this is **markdown**

`) expect(result.nodes).toEqual([ - ['p', { $: { html: 1, block: 1 } }], - ['p', {}, 'this is ', ['strong', {}, 'markdown']], - ['p', { $: { html: 1, block: 1 } }], + ['p', { $: { html: 1, block: 1 } }, 'this is ', ['strong', {}, 'markdown']], ]) }) @@ -88,7 +86,7 @@ this is **markdown** ]) }) - it('parses markdown and raw HTML as siblings when blank lines separate them', async () => { + it('nests blank-line markdown and HTML under a matching open/close pair', async () => { const result = await parseMarkdown(`
before **strong** @@ -100,10 +98,13 @@ after \`code\`
`) expect(result.nodes).toEqual([ - ['div', { $: { html: 1, block: 1 } }], - ['p', {}, 'before ', ['strong', {}, 'strong']], - ['img', { $: { html: 1, block: 1 }, src: '/x.png', alt: 'x' }], - ['p', {}, 'after ', ['code', {}, 'code']], + [ + 'div', + { $: { html: 1, block: 1 } }, + ['p', {}, 'before ', ['strong', {}, 'strong']], + ['img', { $: { html: 1, block: 1 }, src: '/x.png', alt: 'x' }], + ['p', {}, 'after ', ['code', {}, 'code']], + ], ]) }) From 093211d45b1453fd67ed20565a41411744943a93 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 28 Aug 2026 16:16:28 +0200 Subject: [PATCH 05/15] lint: fix --- .../comark/src/internal/parse/token-processor.ts | 13 ++----------- packages/comark/test/html-block.test.ts | 4 +--- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index 976c783f..6782b6be 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -230,14 +230,7 @@ function processHtmlBlockTokens( $: { ...prevMeta, html: 1, block: 0 }, } return { - nodes: [ - [ - element[0], - attrs, - ...openerChildren, - ...finalizeIncompleteHtmlChildren(children.nodes), - ] as Node, - ], + nodes: [[element[0], attrs, ...openerChildren, ...finalizeIncompleteHtmlChildren(children.nodes)] as Node], nextIndex: children.nextIndex, } } @@ -253,9 +246,7 @@ function processHtmlBlockTokens( } return { - nodes: [ - [element[0], attrs, ...openerChildren, ...finalizeIncompleteHtmlChildren(body.nodes)] as Node, - ], + nodes: [[element[0], attrs, ...openerChildren, ...finalizeIncompleteHtmlChildren(body.nodes)] as Node], // Consume the closer as well. nextIndex: closeIndex + 1, } diff --git a/packages/comark/test/html-block.test.ts b/packages/comark/test/html-block.test.ts index 914f20a2..3d492e9d 100644 --- a/packages/comark/test/html-block.test.ts +++ b/packages/comark/test/html-block.test.ts @@ -63,9 +63,7 @@ this is **markdown**

`) - expect(result.nodes).toEqual([ - ['p', { $: { html: 1, block: 1 } }, 'this is ', ['strong', {}, 'markdown']], - ]) + expect(result.nodes).toEqual([['p', { $: { html: 1, block: 1 } }, 'this is ', ['strong', {}, 'markdown']]]) }) it('preserves mixed text and raw HTML children verbatim inside a multiline raw HTML block', async () => { From ea9a35563fe223a0639da7ce0c83e763300a777c Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 28 Aug 2026 17:35:48 +0200 Subject: [PATCH 06/15] fix(comark): blank line after HTML closer before markdown body Markdown stringify no longer glues `Nested content`. HTML wrappers with a lone markdown paragraph among HTML siblings still auto-unwrap that paragraph; multi-block bodies keep their `

` structure. --- .../HTML/details-inside-details-no-unwrap.md | 94 ++++++++++++++++++ .../SPEC/HTML/details-inside-details.md | 4 +- .../SPEC/HTML/p-details-inside-details.md | 99 +++++++++++++++++++ .../comark/src/internal/parse/auto-unwrap.ts | 64 ++++++++++-- .../src/internal/parse/token-processor.ts | 25 +---- .../src/internal/stringify/handlers/html.ts | 27 +++-- 6 files changed, 273 insertions(+), 40 deletions(-) create mode 100644 packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md create mode 100644 packages/comark/SPEC/HTML/p-details-inside-details.md diff --git a/packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md b/packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md new file mode 100644 index 00000000..0fd3b9d0 --- /dev/null +++ b/packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md @@ -0,0 +1,94 @@ +--- +options: + autoUnwrap: false +--- + +## Input + +```md +

+Top + +
+Nested + +Nested content + +
+ +
+``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "details", + { + "$": { "html": 1, "block": 1 } + }, + [ + "summary", + { + "$": { "html": 1, "block": 1 } + }, + "Top" + ], + [ + "details", + { + "$": { "html": 1, "block": 1 } + }, + [ + "summary", + { + "$": { "html": 1, "block": 1 } + }, + "Nested" + ], + [ + "p", + {}, + "Nested content" + ] + ] + ] + ] +} +``` + +## HTML + +```html +
+ + Top + +
+ + Nested + +

Nested content

+
+
+``` + +## Markdown + +```md +
+ +Top +
+ +Nested + + +Nested content +
+
+``` diff --git a/packages/comark/SPEC/HTML/details-inside-details.md b/packages/comark/SPEC/HTML/details-inside-details.md index 0dfffc58..e56712ae 100644 --- a/packages/comark/SPEC/HTML/details-inside-details.md +++ b/packages/comark/SPEC/HTML/details-inside-details.md @@ -76,7 +76,9 @@ Top
Nested -Nested content + + +Nested content
``` diff --git a/packages/comark/SPEC/HTML/p-details-inside-details.md b/packages/comark/SPEC/HTML/p-details-inside-details.md new file mode 100644 index 00000000..fa75de77 --- /dev/null +++ b/packages/comark/SPEC/HTML/p-details-inside-details.md @@ -0,0 +1,99 @@ +## Input + +```md +
+Top + +
+Nested + +Nested content + +Nested content2 + +
+ +
+``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "details", + { + "$": { "html": 1, "block": 1 } + }, + [ + "summary", + { + "$": { "html": 1, "block": 1 } + }, + "Top" + ], + [ + "details", + { + "$": { "html": 1, "block": 1 } + }, + [ + "summary", + { + "$": { "html": 1, "block": 1 } + }, + "Nested" + ], + [ + "p", + {}, + "Nested content" + ], + [ + "p", + {}, + "Nested content2" + ] + ] + ] + ] +} +``` + +## HTML + +```html +
+ + Top + +
+ + Nested + +

Nested content

+

Nested content2

+
+
+``` + +## Markdown + +```md +
+ +Top +
+ +Nested + + +Nested content + +Nested content2 +
+
+``` diff --git a/packages/comark/src/internal/parse/auto-unwrap.ts b/packages/comark/src/internal/parse/auto-unwrap.ts index 1a9304d9..2e620b6f 100644 --- a/packages/comark/src/internal/parse/auto-unwrap.ts +++ b/packages/comark/src/internal/parse/auto-unwrap.ts @@ -18,6 +18,14 @@ import type { Node } from 'comark' * // After: * { tag: 'alert', children: [{ type: 'text', value: 'Text' }] } */ +function isMarkdownParagraph(child: Node): child is [string, Record, ...Node[]] { + return ( + Array.isArray(child) && + child[0] === 'p' && + !(child[1] as Record | undefined)?.$ + ) +} + export function applyAutoUnwrap(node: Node): Node { if (typeof node === 'string' || node.length < 2) { return node @@ -25,22 +33,58 @@ export function applyAutoUnwrap(node: Node): Node { const [tag, props, ...children] = node + // Recurse first so nested HTML wrappers (details → details → p) unwrap bottom-up. + const unwrappedChildren = children.map((child: Node) => applyAutoUnwrap(child as Node)) + // Filter out empty text nodes for checking - const nonEmptyChildren = children.filter((child: Node) => typeof child !== 'string' || (child && child.trim())) + const nonEmptyChildren = unwrappedChildren.filter( + (child: Node) => typeof child !== 'string' || (child && child.trim()) + ) if (nonEmptyChildren.length === 0) { - return node + return [tag, props, ...unwrappedChildren] as Node } - // Check if we have exactly one paragraph child (and possibly empty text nodes) - if (nonEmptyChildren.length > 1 || typeof nonEmptyChildren[0] === 'string' || nonEmptyChildren[0][0] !== 'p') { - return [tag, props, ...children.map((child: Node) => applyAutoUnwrap(child as Node))] as Node + // Classic case: container has only a single markdown paragraph child. + if (nonEmptyChildren.length === 1 && isMarkdownParagraph(nonEmptyChildren[0])) { + // 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 = nonEmptyChildren[0][1] as Record + const mergedProps = + paragraphAttrs && Object.keys(paragraphAttrs).length > 0 ? { ...paragraphAttrs, ...props } : props + return [tag, mergedProps, ...(nonEmptyChildren[0].slice(2) as Node[])] as Node } - // 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 = nonEmptyChildren[0][1] as Record - const mergedProps = paragraphAttrs && Object.keys(paragraphAttrs).length > 0 ? { ...paragraphAttrs, ...props } : props + // HTML wrapper (e.g. nested
) may mix raw-HTML siblings (`summary` + // with `$.html`) with a single markdown paragraph body. Unwrap that lone + // markdown p only when every other non-empty sibling is itself HTML-originated + // — so `p + ul` under an incomplete `` stays as-is. + const isHtmlParent = + (props as Record | undefined)?.$ && + typeof (props as Record).$ === 'object' && + (props as Record).$.html === 1 + if (isHtmlParent) { + const markdownParagraphs = nonEmptyChildren.filter(isMarkdownParagraph) + const otherChildren = nonEmptyChildren.filter((c) => !isMarkdownParagraph(c)) + const othersAreHtml = otherChildren.every( + (c) => + Array.isArray(c) && + typeof c[1] === 'object' && + c[1] !== null && + (c[1] as Record).$?.html === 1 + ) + if (markdownParagraphs.length === 1 && othersAreHtml) { + const out: Node[] = [] + for (const child of unwrappedChildren) { + if (isMarkdownParagraph(child)) { + out.push(...(child.slice(2) as Node[])) + } else { + out.push(child) + } + } + return [tag, props, ...out] as Node + } + } - return [tag, mergedProps, ...(nonEmptyChildren[0].slice(2) as Node[])] as Node + return [tag, props, ...unwrappedChildren] as Node } diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index 6782b6be..a4017ee2 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -128,25 +128,6 @@ function htmlOuterTagDepth(content: string, tag: string): number { return depth } -/** Normalize children of an incomplete HTML wrapper before emitting the node. */ -function finalizeIncompleteHtmlChildren(nodes: Node[]): Node[] { - // Markdown paragraphs that only wrap plain text under a raw HTML parent - // (blank-line body of `
`) collapse to that text so the AST matches - // a single content string rather than an extra `

`. - return nodes.map((child) => { - if ( - Array.isArray(child) && - child[0] === 'p' && - !(child[1] as Record | undefined)?.$ && - child.length === 3 && - typeof child[2] === 'string' - ) { - return child[2] as string - } - return child - }) -} - /** * Convert an html_block token into Comark nodes. * @@ -230,13 +211,15 @@ function processHtmlBlockTokens( $: { ...prevMeta, html: 1, block: 0 }, } return { - nodes: [[element[0], attrs, ...openerChildren, ...finalizeIncompleteHtmlChildren(children.nodes)] as Node], + nodes: [[element[0], attrs, ...openerChildren, ...children.nodes] as Node], nextIndex: children.nextIndex, } } // Matching closer → nest body under the opener (block: 1). Slice so // processBlockChildren stops before the closer; recurse for nested HTML. + // Single-paragraph bodies are left as `

` here; `applyAutoUnwrap` lifts + // them when `autoUnwrap` is on (default). const bodyTokens = tokens.slice(startIndex + 1, closeIndex) const body = processBlockChildren(bodyTokens, 0, '\0', false, false, false, state) @@ -246,7 +229,7 @@ function processHtmlBlockTokens( } return { - nodes: [[element[0], attrs, ...openerChildren, ...finalizeIncompleteHtmlChildren(body.nodes)] as Node], + nodes: [[element[0], attrs, ...openerChildren, ...body.nodes] as Node], // Consume the closer as well. nextIndex: closeIndex + 1, } diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts index a60687b1..4644e7aa 100644 --- a/packages/comark/src/internal/stringify/handlers/html.ts +++ b/packages/comark/src/internal/stringify/handlers/html.ts @@ -79,10 +79,11 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode } // In markdown mode, block children already append their own blockSeparator, so - // we must not inject extra newlines between them. In HTML mode the separator - // is the pretty-print block gap. A blank line inside a *raw* HTML body would - // terminate the block on reparse — markdown children of incomplete openers - // (`$.block === 0`) intentionally use blank lines like normal markdown. + // we must not inject extra newlines between *markdown* siblings. HTML element + // closers (``) do not carry a trailing separator, so a following + // markdown body would otherwise glue on (`Nested content`). Insert + // a blank line when the previous render ends with an HTML closer and the next + // is not itself an HTML open tag. In HTML mode use the pretty-print gap. const childSeparator = state.context.html ? state.context.blockSeparator : '' let content = '' @@ -90,17 +91,27 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode for (let i = 0; i < children.length; i++) { const childContent = childrenContent[i] const child = children[i] - const isBlock = + const childIsBlock = typeof child !== 'string' && (blockTags.has(String(child?.[0])) || (!inlineTags.has(String(child?.[0])) && !hasTextSibling)) - if (i > 0 && !isPrevBlock && isBlock) { + if (i > 0 && !isPrevBlock && childIsBlock) { content += childSeparator } + + if (i > 0 && !state.context.html) { + const prevContent = childrenContent[i - 1] + // `…` + `Nested content` → blank line so the body re-parses as + // a separate markdown block. Keep HTML→HTML tight (`

`). + if (prevContent.endsWith('>') && childContent && !childContent.startsWith('<') && !childContent.startsWith('\n')) { + content += state.context.blockSeparator + } + } + content += childContent - isPrevBlock = isBlock + isPrevBlock = childIsBlock - if (isBlock && i < children.length - 1) { + if (childIsBlock && i < children.length - 1) { content += childSeparator } } From a9d96b5351c344e6869e60303e055d3ba9245003 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 28 Aug 2026 17:36:59 +0200 Subject: [PATCH 07/15] lint: fix --- packages/comark/src/internal/parse/auto-unwrap.ts | 11 ++--------- .../comark/src/internal/stringify/handlers/html.ts | 7 ++++++- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/comark/src/internal/parse/auto-unwrap.ts b/packages/comark/src/internal/parse/auto-unwrap.ts index 2e620b6f..59fef4cd 100644 --- a/packages/comark/src/internal/parse/auto-unwrap.ts +++ b/packages/comark/src/internal/parse/auto-unwrap.ts @@ -19,11 +19,7 @@ import type { Node } from 'comark' * { tag: 'alert', children: [{ type: 'text', value: 'Text' }] } */ function isMarkdownParagraph(child: Node): child is [string, Record, ...Node[]] { - return ( - Array.isArray(child) && - child[0] === 'p' && - !(child[1] as Record | undefined)?.$ - ) + return Array.isArray(child) && child[0] === 'p' && !(child[1] as Record | undefined)?.$ } export function applyAutoUnwrap(node: Node): Node { @@ -68,10 +64,7 @@ export function applyAutoUnwrap(node: Node): Node { const otherChildren = nonEmptyChildren.filter((c) => !isMarkdownParagraph(c)) const othersAreHtml = otherChildren.every( (c) => - Array.isArray(c) && - typeof c[1] === 'object' && - c[1] !== null && - (c[1] as Record).$?.html === 1 + Array.isArray(c) && typeof c[1] === 'object' && c[1] !== null && (c[1] as Record).$?.html === 1 ) if (markdownParagraphs.length === 1 && othersAreHtml) { const out: Node[] = [] diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts index 4644e7aa..ff935d45 100644 --- a/packages/comark/src/internal/stringify/handlers/html.ts +++ b/packages/comark/src/internal/stringify/handlers/html.ts @@ -103,7 +103,12 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode const prevContent = childrenContent[i - 1] // `…` + `Nested content` → blank line so the body re-parses as // a separate markdown block. Keep HTML→HTML tight (`
`). - if (prevContent.endsWith('>') && childContent && !childContent.startsWith('<') && !childContent.startsWith('\n')) { + if ( + prevContent.endsWith('>') && + childContent && + !childContent.startsWith('<') && + !childContent.startsWith('\n') + ) { content += state.context.blockSeparator } } From d36cbfc9baba8489f28bd924276de8ad9b56168f Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 3 Sep 2026 14:25:54 +0200 Subject: [PATCH 08/15] test: add extra tests --- packages/comark/test/auto-close.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/comark/test/auto-close.test.ts b/packages/comark/test/auto-close.test.ts index 68a11174..ae71f4b9 100644 --- a/packages/comark/test/auto-close.test.ts +++ b/packages/comark/test/auto-close.test.ts @@ -32,7 +32,11 @@ $$formula → $$formula _ not valid → _ not valid __ not valid → __ not valid ~ not valid → ~ not valid -~~ not valid → ~~ not valid` +~~ not valid → ~~ not valid +The cost is $ → The cost is $ +~Hello → ~Hello +~~Hello~~ → ~~Hello~~ +~~ Hello → ~~ Hello` const multilines = ` | Month | Savings From 90c01676b5301ad9e730a2b4da17184b3a0fce5e Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Thu, 3 Sep 2026 18:18:16 +0200 Subject: [PATCH 09/15] fix: imporove block detection --- .../HTML/incomplete-block-two-new-line.md | 3 +- .../HTML/incomplete-one-new-line-no-unwrap.md | 1 + .../src/internal/parse/token-processor.ts | 16 +++++++--- .../src/internal/stringify/handlers/html.ts | 29 ++++++++++++------- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md b/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md index b4af1106..5d8c922b 100644 --- a/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md +++ b/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md @@ -18,7 +18,7 @@ "nodes": [ [ "ai-thinking", - {"$": { "html": 1, "block": 0 }}, + {"$": { "html": 1, "block": 1 }}, [ "p", {}, @@ -73,5 +73,6 @@ - list - **item** + ``` diff --git a/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md b/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md index 0da98d2c..5ab53dc6 100644 --- a/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md +++ b/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md @@ -48,5 +48,6 @@ options: **bold** + ``` diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index a4017ee2..93fd0351 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -133,8 +133,9 @@ function htmlOuterTagDepth(content: string, tag: string): number { * * Self-contained blocks are parsed once by htmlparser2 (text preserved * verbatim — CommonMark default). Incomplete openers absorb subsequent tokens - * as children until a matching closer (`block: 1` with a closer, or `block: 0` - * for streaming tags with no closer) so nested blank-line HTML like + * as children until a matching closer (`block: 1`). Streaming openers with no + * closer are `block: 1` when the body is multi-block markdown, otherwise + * `block: 0` (lone paragraph / inline-like). Nested blank-line HTML like * `
` builds a real tree. */ function processHtmlBlockTokens( @@ -203,12 +204,19 @@ function processHtmlBlockTokens( const prevMeta = (openerAttrs.$ || {}) as Record const openerChildren = element.slice(2) as Node[] - // No matching closer → streaming incomplete tag (block: 0), absorb to EOF. + // No matching closer → streaming incomplete tag, absorb to EOF. + // Multi-block markdown bodies are real block containers (`block: 1`). + // A lone paragraph (often auto-unwrapped later) stays `block: 0` so it can + // serialize as a one-liner: `**bold**`. if (closeIndex < 0) { const children = processBlockChildren(tokens, startIndex + 1, '\0', false, false, false, state) + const nonEmpty = children.nodes.filter((child) => typeof child !== 'string' || (child && child.trim())) + const isMultiBlock = + nonEmpty.length > 1 || + (nonEmpty.length === 1 && Array.isArray(nonEmpty[0]) && nonEmpty[0][0] !== null && nonEmpty[0][0] !== 'p') const attrs: Record = { ...openerAttrs, - $: { ...prevMeta, html: 1, block: 0 }, + $: { ...prevMeta, html: 1, block: isMultiBlock ? 1 : 0 }, } return { nodes: [[element[0], attrs, ...openerChildren, ...children.nodes] as Node], diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts index ff935d45..b633aca1 100644 --- a/packages/comark/src/internal/stringify/handlers/html.ts +++ b/packages/comark/src/internal/stringify/handlers/html.ts @@ -42,11 +42,20 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode const hasTextSibling = children.some((child) => typeof child === 'string') const isBlock = textBlocks.has(String(tag)) const isInline = inlineTags.has(String(tag)) && $.block === 0 - // Incomplete HTML openers (streaming) store markdown/HTML block children under - // `$.block === 0`; those still need multi-line wrapping, not one-liner inline. + // Any non-inline child (markdown p/ul or nested HTML) needs multi-line wrapping. const hasBlockChildren = children.some( (child) => Array.isArray(child) && child[0] !== null && !inlineTags.has(String(child[0])) ) + // Blank line after the open tag only when the body *starts* with markdown + // (`\n\n**bold**…`), so it re-parses as markdown. HTML-first bodies + // (`
\n…`) stay flush; the sibling join path adds the gap + // before a later markdown block. + const firstMeaningfulChild = children.find((child) => typeof child !== 'string' || (child && child.trim())) + const bodyStartsWithMarkdown = + Array.isArray(firstMeaningfulChild) && + firstMeaningfulChild[0] !== null && + !inlineTags.has(String(firstMeaningfulChild[0])) && + !(firstMeaningfulChild[1] as Record | undefined)?.$?.html let oneLiner = isBlock && hasOnlyTextChildren @@ -63,7 +72,7 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode } // Inline HTML (`block: 0` with only text/inline children) collapses to one line. - // Incomplete block wrappers with real markdown block children stay multi-line. + // Block wrappers with real block children stay multi-line. if ($.block === 0 && !hasBlockChildren) { oneLiner = true } @@ -135,14 +144,14 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode if (!oneLiner && content) { if (state.context.html) { content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n' - } else if ($.block === 0 && hasBlockChildren) { - // Incomplete HTML openers with markdown body: blank line after open tag so - // the body re-parses as markdown, children's own blockSeparators between - // blocks, single newline before close. - content = '\n\n' + content.trimEnd() + '\n' + } else if (bodyStartsWithMarkdown) { + // Markdown-first body: blank line after open so the body re-parses as + // markdown; blank line before close so a trailing closer is its own + // html_block (not absorbed into a list item / paragraph). + content = '\n\n' + content.trimEnd() + '\n\n' } else { - // Raw HTML block body (block:1) — keep content flush after the open tag - // so reparse matches CommonMark html_block runs. + // Raw HTML / HTML-first body — keep content flush after the open tag so + // reparse matches CommonMark html_block runs. content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n' } } From 4c6b33fe40771e49d382f549b1c6decccbdc38d2 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 4 Sep 2026 15:32:16 +0200 Subject: [PATCH 10/15] fix: better block detection + self close html5 style --- .../comark/SPEC/COMARK/attribute-image.md | 2 +- .../SPEC/COMARK/attributes/paragraph+span.md | 14 +- .../SPEC/COMARK/attributes/task-list.md | 4 +- .../attributes/wrapped-pre-highlighted.md | 98 ++++----- .../SPEC/COMARK/component-with-heading.md | 2 +- ...nt-yaml-props-thematic-break-named-slot.md | 36 ++- .../component-yaml-props-thematic-break.md | 32 ++- .../emoji-inside-component-autounwrap.md | 8 +- .../SPEC/COMARK/emoji-inside-component.md | 8 +- .../comark/SPEC/COMARK/frontmatter-empty.md | 4 +- .../frontmatter-unclosed-is-thematic-break.md | 2 +- .../comark/SPEC/COMARK/image-attribute.md | 2 +- packages/comark/SPEC/COMARK/image-no-alt.md | 2 +- packages/comark/SPEC/COMARK/image-title.md | 6 +- packages/comark/SPEC/COMARK/link-with-span.md | 79 +++---- .../COMARK/wrapped-emoji-inside-component.md | 8 +- .../comark/SPEC/GFM/task-list-multiple.md | 6 +- packages/comark/SPEC/GFM/task-list.md | 4 +- packages/comark/SPEC/HTML/block+component.md | 5 +- .../comark/SPEC/HTML/block+inline-children.md | 14 +- packages/comark/SPEC/HTML/block.md | 5 +- .../HTML/details-inside-details-no-unwrap.md | 36 +-- .../SPEC/HTML/details-inside-details.md | 36 +-- .../HTML/incomplete-block-two-new-line.md | 7 +- .../HTML/incomplete-one-new-line-no-unwrap.md | 7 +- .../SPEC/HTML/incomplete-one-new-line.md | 7 +- packages/comark/SPEC/HTML/inline.md | 5 +- .../SPEC/HTML/p-details-inside-details.md | 36 +-- .../comark/SPEC/HTML/real-life-sample-1.md | 128 +++++++++++ .../comark/SPEC/HTML/real-life-sample-2.md | 205 ++++++++++++++++++ .../SPEC/common-mark/horizontal-rule-3-.md | 2 +- .../common-mark/horizontal-rule-3-asterisk.md | 2 +- .../SPEC/common-mark/horizontal-rule-bare.md | 2 +- .../SPEC/common-mark/horizontal-rule-more-.md | 2 +- .../SPEC/common-mark/html-block-void-br.md | 4 +- .../common-mark/html-block-void-element.md | 6 +- .../comark/SPEC/common-mark/image-title.md | 2 +- .../comark/SPEC/common-mark/line-break.md | 2 +- packages/comark/SPEC/common-mark/links.md | 77 +++---- .../SPEC/common-mark/paragraph-image.md | 2 +- .../SPEC/common-mark/paragraph-link-image.md | 2 +- .../SPEC/common-mark/paragraph-multiple.md | 2 +- packages/comark/SPEC/common-mark/xyz.md | 2 +- .../directive-no-content.md | 4 +- .../comark/src/internal/parse/html/index.ts | 50 ++++- .../src/internal/stringify/handlers/html.ts | 2 +- packages/comark/test/html-block.test.ts | 16 +- packages/comark/test/html-escape.test.ts | 10 + packages/comark/test/index.test.ts | 1 + 49 files changed, 735 insertions(+), 263 deletions(-) create mode 100644 packages/comark/SPEC/HTML/real-life-sample-1.md create mode 100644 packages/comark/SPEC/HTML/real-life-sample-2.md diff --git a/packages/comark/SPEC/COMARK/attribute-image.md b/packages/comark/SPEC/COMARK/attribute-image.md index 131e5e46..e789107d 100644 --- a/packages/comark/SPEC/COMARK/attribute-image.md +++ b/packages/comark/SPEC/COMARK/attribute-image.md @@ -58,7 +58,7 @@ Here ![alt](https://example.com/image.jpg){bool} ![alt](https://example.com/imag ## HTML ```html -

Here alt alt alt alt

+

Here alt alt alt alt

``` ## Markdown diff --git a/packages/comark/SPEC/COMARK/attributes/paragraph+span.md b/packages/comark/SPEC/COMARK/attributes/paragraph+span.md index 5c536e2b..ea6a2170 100644 --- a/packages/comark/SPEC/COMARK/attributes/paragraph+span.md +++ b/packages/comark/SPEC/COMARK/attributes/paragraph+span.md @@ -19,13 +19,23 @@ A paragraph [span]{attr="value"} "attr": "value" }, "A paragraph ", - ["span", {}, "span"] + [ + "span", + {}, + "span" + ] ], [ "p", {}, "A paragraph ", - ["span", { "attr": "value" }, "span"] + [ + "span", + { + "attr": "value" + }, + "span" + ] ] ] } diff --git a/packages/comark/SPEC/COMARK/attributes/task-list.md b/packages/comark/SPEC/COMARK/attributes/task-list.md index 7cb5ecfd..c4f30ba6 100644 --- a/packages/comark/SPEC/COMARK/attributes/task-list.md +++ b/packages/comark/SPEC/COMARK/attributes/task-list.md @@ -60,10 +60,10 @@ ```html
  • - Task list item + Task list item
  • - Task list item + Task list item
``` diff --git a/packages/comark/SPEC/COMARK/attributes/wrapped-pre-highlighted.md b/packages/comark/SPEC/COMARK/attributes/wrapped-pre-highlighted.md index 764daff0..4327a366 100644 --- a/packages/comark/SPEC/COMARK/attributes/wrapped-pre-highlighted.md +++ b/packages/comark/SPEC/COMARK/attributes/wrapped-pre-highlighted.md @@ -37,55 +37,55 @@ const variable = "value" { "class": "language-ts" }, - [ - "span", - { - "class": "line", - "style": "display: inline" - }, - [ - "span", - { - "style": "color:#D32F2F;--shiki-dark:#81A1C1" - }, - "const" - ], - [ - "span", - { - "style": "color:#1976D2;--shiki-dark:#D8DEE9" - }, - " variable" - ], - [ - "span", - { - "style": "color:#D32F2F;--shiki-dark:#81A1C1" - }, - " =" - ], - [ - "span", - { - "style": "color:#22863A;--shiki-dark:#ECEFF4" - }, - " \"" - ], - [ - "span", - { - "style": "color:#22863A;--shiki-dark:#A3BE8C" - }, - "value" - ], - [ - "span", - { - "style": "color:#22863A;--shiki-dark:#ECEFF4" - }, - "\"" - ] - ] + [ + "span", + { + "class": "line", + "style": "display: inline" + }, + [ + "span", + { + "style": "color:#D32F2F;--shiki-dark:#81A1C1" + }, + "const" + ], + [ + "span", + { + "style": "color:#1976D2;--shiki-dark:#D8DEE9" + }, + " variable" + ], + [ + "span", + { + "style": "color:#D32F2F;--shiki-dark:#81A1C1" + }, + " =" + ], + [ + "span", + { + "style": "color:#22863A;--shiki-dark:#ECEFF4" + }, + " \"" + ], + [ + "span", + { + "style": "color:#22863A;--shiki-dark:#A3BE8C" + }, + "value" + ], + [ + "span", + { + "style": "color:#22863A;--shiki-dark:#ECEFF4" + }, + "\"" + ] + ] ] ] ] diff --git a/packages/comark/SPEC/COMARK/component-with-heading.md b/packages/comark/SPEC/COMARK/component-with-heading.md index f1870f73..4bd6a595 100644 --- a/packages/comark/SPEC/COMARK/component-with-heading.md +++ b/packages/comark/SPEC/COMARK/component-with-heading.md @@ -62,7 +62,7 @@ Text content ```html

Step 1

-

Image

+

Image

Step 2

Text content

diff --git a/packages/comark/SPEC/COMARK/component-yaml-props-thematic-break-named-slot.md b/packages/comark/SPEC/COMARK/component-yaml-props-thematic-break-named-slot.md index ba091b01..d583dcf3 100644 --- a/packages/comark/SPEC/COMARK/component-yaml-props-thematic-break-named-slot.md +++ b/packages/comark/SPEC/COMARK/component-yaml-props-thematic-break-named-slot.md @@ -27,12 +27,32 @@ Line three {}, [ "template", - { "name": "description" }, - ["p", {}, "Line one"], - ["hr", {}], - ["p", {}, "Middle line"], - ["hr", {}], - ["p", {}, "Line three"] + { + "name": "description" + }, + [ + "p", + {}, + "Line one" + ], + [ + "hr", + {} + ], + [ + "p", + {}, + "Middle line" + ], + [ + "hr", + {} + ], + [ + "p", + {}, + "Line three" + ] ] ] ] @@ -45,9 +65,9 @@ Line three diff --git a/packages/comark/SPEC/COMARK/component-yaml-props-thematic-break.md b/packages/comark/SPEC/COMARK/component-yaml-props-thematic-break.md index 307515f4..e0dae776 100644 --- a/packages/comark/SPEC/COMARK/component-yaml-props-thematic-break.md +++ b/packages/comark/SPEC/COMARK/component-yaml-props-thematic-break.md @@ -24,11 +24,29 @@ Below [ "card", {}, - ["p", {}, "Above"], - ["hr", {}], - ["p", {}, "Middle text"], - ["hr", {}], - ["p", {}, "Below"] + [ + "p", + {}, + "Above" + ], + [ + "hr", + {} + ], + [ + "p", + {}, + "Middle text" + ], + [ + "hr", + {} + ], + [ + "p", + {}, + "Below" + ] ] ] } @@ -39,9 +57,9 @@ Below ```html

Above

-
+

Middle text

-
+

Below

``` diff --git a/packages/comark/SPEC/COMARK/emoji-inside-component-autounwrap.md b/packages/comark/SPEC/COMARK/emoji-inside-component-autounwrap.md index 7b42dc7e..f2311f94 100644 --- a/packages/comark/SPEC/COMARK/emoji-inside-component-autounwrap.md +++ b/packages/comark/SPEC/COMARK/emoji-inside-component-autounwrap.md @@ -29,12 +29,16 @@ options: "nodes": [ [ "alert", - { "type": "success" }, + { + "type": "success" + }, "✅ Successfully deployed! 🚀" ], [ "alert", - { "type": "warning" }, + { + "type": "warning" + }, "⚠️ Please backup your data before proceeding" ] ] diff --git a/packages/comark/SPEC/COMARK/emoji-inside-component.md b/packages/comark/SPEC/COMARK/emoji-inside-component.md index 7b42dc7e..f2311f94 100644 --- a/packages/comark/SPEC/COMARK/emoji-inside-component.md +++ b/packages/comark/SPEC/COMARK/emoji-inside-component.md @@ -29,12 +29,16 @@ options: "nodes": [ [ "alert", - { "type": "success" }, + { + "type": "success" + }, "✅ Successfully deployed! 🚀" ], [ "alert", - { "type": "warning" }, + { + "type": "warning" + }, "⚠️ Please backup your data before proceeding" ] ] diff --git a/packages/comark/SPEC/COMARK/frontmatter-empty.md b/packages/comark/SPEC/COMARK/frontmatter-empty.md index bc3a0a12..07956934 100644 --- a/packages/comark/SPEC/COMARK/frontmatter-empty.md +++ b/packages/comark/SPEC/COMARK/frontmatter-empty.md @@ -35,8 +35,8 @@ ## HTML ```html -
-
+
+

Content

``` diff --git a/packages/comark/SPEC/COMARK/frontmatter-unclosed-is-thematic-break.md b/packages/comark/SPEC/COMARK/frontmatter-unclosed-is-thematic-break.md index cc6909a7..1836f714 100644 --- a/packages/comark/SPEC/COMARK/frontmatter-unclosed-is-thematic-break.md +++ b/packages/comark/SPEC/COMARK/frontmatter-unclosed-is-thematic-break.md @@ -30,7 +30,7 @@ ## HTML ```html -
+

Heading

``` diff --git a/packages/comark/SPEC/COMARK/image-attribute.md b/packages/comark/SPEC/COMARK/image-attribute.md index 131e5e46..e789107d 100644 --- a/packages/comark/SPEC/COMARK/image-attribute.md +++ b/packages/comark/SPEC/COMARK/image-attribute.md @@ -58,7 +58,7 @@ Here ![alt](https://example.com/image.jpg){bool} ![alt](https://example.com/imag ## HTML ```html -

Here alt alt alt alt

+

Here alt alt alt alt

``` ## Markdown diff --git a/packages/comark/SPEC/COMARK/image-no-alt.md b/packages/comark/SPEC/COMARK/image-no-alt.md index 202816a6..3535d02d 100644 --- a/packages/comark/SPEC/COMARK/image-no-alt.md +++ b/packages/comark/SPEC/COMARK/image-no-alt.md @@ -28,7 +28,7 @@ ## HTML ```html -

+

``` ## Markdown diff --git a/packages/comark/SPEC/COMARK/image-title.md b/packages/comark/SPEC/COMARK/image-title.md index 7868ccb4..9b80bb81 100644 --- a/packages/comark/SPEC/COMARK/image-title.md +++ b/packages/comark/SPEC/COMARK/image-title.md @@ -36,7 +36,7 @@ "alt": "alt", "title": "A title", "class": "rounded-asymmetric", - "width":"200" + "width": "200" } ] ] @@ -47,8 +47,8 @@ ## HTML ```html -

alt

-

alt

+

alt

+

alt

``` ## Markdown diff --git a/packages/comark/SPEC/COMARK/link-with-span.md b/packages/comark/SPEC/COMARK/link-with-span.md index 9d41e107..92be8f43 100644 --- a/packages/comark/SPEC/COMARK/link-with-span.md +++ b/packages/comark/SPEC/COMARK/link-with-span.md @@ -9,54 +9,45 @@ ## AST ```json - { - "frontmatter":{ - - }, - "meta":{ - - }, - "nodes":[ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "p", + {}, [ - "p", - { - - }, - [ - "a", - { - "href":"#" - }, - [ - "span", - {}, - "1" - ], - " Document" - ] - ], + "a", + { + "href": "#" + }, + [ + "span", + {}, + "1" + ], + " Document" + ] + ], + [ + "p", + {}, [ - "p", - { - - }, - [ - "a", - { - "href":"https://example.com" - }, - [ - "span", - { - "class": "cls" - }, - "link-name" - ], - " more" - ] + "a", + { + "href": "https://example.com" + }, + [ + "span", + { + "class": "cls" + }, + "link-name" + ], + " more" ] - ] + ] + ] } ``` diff --git a/packages/comark/SPEC/COMARK/wrapped-emoji-inside-component.md b/packages/comark/SPEC/COMARK/wrapped-emoji-inside-component.md index d7c69623..c7c6a80f 100644 --- a/packages/comark/SPEC/COMARK/wrapped-emoji-inside-component.md +++ b/packages/comark/SPEC/COMARK/wrapped-emoji-inside-component.md @@ -30,7 +30,9 @@ options: "nodes": [ [ "alert", - { "type": "success" }, + { + "type": "success" + }, [ "p", {}, @@ -39,7 +41,9 @@ options: ], [ "alert", - { "type": "warning" }, + { + "type": "warning" + }, [ "p", {}, diff --git a/packages/comark/SPEC/GFM/task-list-multiple.md b/packages/comark/SPEC/GFM/task-list-multiple.md index 71e62f04..cd5ae174 100644 --- a/packages/comark/SPEC/GFM/task-list-multiple.md +++ b/packages/comark/SPEC/GFM/task-list-multiple.md @@ -82,13 +82,13 @@ timeout: ```html
  • - Done + Done
  • - Done + Done
  • - todo + todo
``` diff --git a/packages/comark/SPEC/GFM/task-list.md b/packages/comark/SPEC/GFM/task-list.md index 27799ce9..34565704 100644 --- a/packages/comark/SPEC/GFM/task-list.md +++ b/packages/comark/SPEC/GFM/task-list.md @@ -65,10 +65,10 @@ timeout: ```html
  • - Done + Done
  • - todo + todo
``` diff --git a/packages/comark/SPEC/HTML/block+component.md b/packages/comark/SPEC/HTML/block+component.md index 58ddd83a..8343e7ee 100644 --- a/packages/comark/SPEC/HTML/block+component.md +++ b/packages/comark/SPEC/HTML/block+component.md @@ -18,7 +18,10 @@ Default Slot [ "hello", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, "::component\nDefault Slot\n::" ] diff --git a/packages/comark/SPEC/HTML/block+inline-children.md b/packages/comark/SPEC/HTML/block+inline-children.md index e7b0a7e3..2cd33122 100644 --- a/packages/comark/SPEC/HTML/block+inline-children.md +++ b/packages/comark/SPEC/HTML/block+inline-children.md @@ -14,12 +14,18 @@ [ "p", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, [ "img", { - "$": { "html": 1, "block": 1 }, + "$": { + "html": 1, + "block": 0 + }, "src": "/foo.png", "alt": "x" } @@ -32,11 +38,11 @@ ## HTML ```html -

x

+

x

``` ## Markdown ```md -

x

+

x

``` diff --git a/packages/comark/SPEC/HTML/block.md b/packages/comark/SPEC/HTML/block.md index 025011ba..f0a61ba9 100644 --- a/packages/comark/SPEC/HTML/block.md +++ b/packages/comark/SPEC/HTML/block.md @@ -16,7 +16,10 @@ Hello **World** [ "hello", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, "Hello **World**" ] diff --git a/packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md b/packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md index 0fd3b9d0..3c1d681b 100644 --- a/packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md +++ b/packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md @@ -29,24 +29,36 @@ Nested content [ "details", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, [ "summary", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 0 + } }, "Top" ], [ "details", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, [ "summary", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 0 + } }, "Nested" ], @@ -65,13 +77,9 @@ Nested content ```html
- - Top - + Top
- - Nested - + Nested

Nested content

@@ -81,12 +89,8 @@ Nested content ```md
- -Top -
- -Nested - +Top
+Nested Nested content
diff --git a/packages/comark/SPEC/HTML/details-inside-details.md b/packages/comark/SPEC/HTML/details-inside-details.md index e56712ae..24ea7fb0 100644 --- a/packages/comark/SPEC/HTML/details-inside-details.md +++ b/packages/comark/SPEC/HTML/details-inside-details.md @@ -24,24 +24,36 @@ Nested content [ "details", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, [ "summary", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 0 + } }, "Top" ], [ "details", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, [ "summary", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 0 + } }, "Nested" ], @@ -56,13 +68,9 @@ Nested content ```html
- - Top - + Top
- - Nested - Nested content + NestedNested content
``` @@ -71,12 +79,8 @@ Nested content ```md
- -Top -
- -Nested - +Top
+Nested Nested content
diff --git a/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md b/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md index 5d8c922b..5673606f 100644 --- a/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md +++ b/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md @@ -18,7 +18,12 @@ "nodes": [ [ "ai-thinking", - {"$": { "html": 1, "block": 1 }}, + { + "$": { + "html": 1, + "block": 1 + } + }, [ "p", {}, diff --git a/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md b/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md index 5ab53dc6..0690a732 100644 --- a/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md +++ b/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md @@ -19,7 +19,12 @@ options: "nodes": [ [ "ai-thinking", - {"$": { "html": 1, "block": 0 }}, + { + "$": { + "html": 1, + "block": 0 + } + }, [ "p", {}, diff --git a/packages/comark/SPEC/HTML/incomplete-one-new-line.md b/packages/comark/SPEC/HTML/incomplete-one-new-line.md index 74caf377..926de7ff 100644 --- a/packages/comark/SPEC/HTML/incomplete-one-new-line.md +++ b/packages/comark/SPEC/HTML/incomplete-one-new-line.md @@ -14,7 +14,12 @@ "nodes": [ [ "ai-thinking", - {"$": { "html": 1, "block": 0 }}, + { + "$": { + "html": 1, + "block": 0 + } + }, [ "strong", {}, diff --git a/packages/comark/SPEC/HTML/inline.md b/packages/comark/SPEC/HTML/inline.md index 3432fa54..4bfbce3f 100644 --- a/packages/comark/SPEC/HTML/inline.md +++ b/packages/comark/SPEC/HTML/inline.md @@ -17,7 +17,10 @@ [ "hello", { - "$": { "html": 1, "block": 0 } + "$": { + "html": 1, + "block": 0 + } }, "Hello ", [ diff --git a/packages/comark/SPEC/HTML/p-details-inside-details.md b/packages/comark/SPEC/HTML/p-details-inside-details.md index fa75de77..d2746c92 100644 --- a/packages/comark/SPEC/HTML/p-details-inside-details.md +++ b/packages/comark/SPEC/HTML/p-details-inside-details.md @@ -26,24 +26,36 @@ Nested content2 [ "details", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, [ "summary", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 0 + } }, "Top" ], [ "details", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 1 + } }, [ "summary", { - "$": { "html": 1, "block": 1 } + "$": { + "html": 1, + "block": 0 + } }, "Nested" ], @@ -67,13 +79,9 @@ Nested content2 ```html
- - Top - + Top
- - Nested - + Nested

Nested content

Nested content2

@@ -84,12 +92,8 @@ Nested content2 ```md
- -Top -
- -Nested - +Top
+Nested Nested content diff --git a/packages/comark/SPEC/HTML/real-life-sample-1.md b/packages/comark/SPEC/HTML/real-life-sample-1.md new file mode 100644 index 00000000..5d00e453 --- /dev/null +++ b/packages/comark/SPEC/HTML/real-life-sample-1.md @@ -0,0 +1,128 @@ +## Input + +```md +

+ Discord  Twitter  GitHub  Bluesky +

+``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "p", + { + "$": { + "html": 1, + "block": 1 + }, + "valign": "center" + }, + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://go.nuxt.com/discord" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "width": "20", + "src": "./.github/assets/discord.svg", + "alt": "Discord" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://go.nuxt.com/x" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "width": "20", + "src": "./.github/assets/twitter.svg", + "alt": "Twitter" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://go.nuxt.com/github" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "width": "20", + "src": "./.github/assets/github.svg", + "alt": "GitHub" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://go.nuxt.com/bluesky" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "width": "20", + "src": "./.github/assets/bluesky.svg", + "alt": "Bluesky" + } + ] + ] + ] + ] +} +``` + +## HTML + +```html +

DiscordTwitterGitHubBluesky

+``` + +## Markdown + +```md +

DiscordTwitterGitHubBluesky

+``` diff --git a/packages/comark/SPEC/HTML/real-life-sample-2.md b/packages/comark/SPEC/HTML/real-life-sample-2.md new file mode 100644 index 00000000..68ffd4e5 --- /dev/null +++ b/packages/comark/SPEC/HTML/real-life-sample-2.md @@ -0,0 +1,205 @@ +## Input + +```md +

VersionDownloadsLicenseModulesWebsiteDiscordNuxt openssf scorecard scoreAsk DeepWiki

+``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "p", + { + "$": { + "html": 1, + "block": 1 + } + }, + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://npmx.dev/package/nuxt" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "src": "https://npmx.dev/api/registry/badge/version/nuxt", + "alt": "Version" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://npmx.dev/package/nuxt" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "src": "https://npmx.dev/api/registry/badge/downloads/nuxt", + "alt": "Downloads" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://github.com/nuxt/nuxt/blob/main/LICENSE" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "src": "https://img.shields.io/github/license/nuxt/nuxt.svg?style=flat&colorA=18181B&colorB=28CF8D", + "alt": "License" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://nuxt.com/modules" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "src": "https://img.shields.io/badge/dynamic/json?url=https://nuxt.com/api/v1/modules&query=$.stats.modules&label=Modules&style=flat&colorA=18181B&colorB=28CF8D", + "alt": "Modules" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://nuxt.com" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "src": "https://img.shields.io/badge/Nuxt%20Docs-18181B?logo=nuxt", + "alt": "Website" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://chat.nuxt.dev" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "src": "https://img.shields.io/badge/Nuxt%20Discord-18181B?logo=discord", + "alt": "Discord" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://securityscorecards.dev/viewer/?uri=github.com/nuxt/nuxt" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "src": "https://api.securityscorecards.dev/projects/github.com/nuxt/nuxt/badge", + "alt": "Nuxt openssf scorecard score" + } + ] + ], + [ + "a", + { + "$": { + "html": 1, + "block": 0 + }, + "href": "https://deepwiki.com/nuxt/nuxt" + }, + [ + "img", + { + "$": { + "html": 1, + "block": 0 + }, + "src": "https://deepwiki.com/badge.svg", + "alt": "Ask DeepWiki" + } + ] + ] + ] + ] +} +``` + +## HTML + +```html +

VersionDownloadsLicenseModulesWebsiteDiscordNuxt openssf scorecard scoreAsk DeepWiki

+``` + +## Markdown + +```md +

VersionDownloadsLicenseModulesWebsiteDiscordNuxt openssf scorecard scoreAsk DeepWiki

+``` diff --git a/packages/comark/SPEC/common-mark/horizontal-rule-3-.md b/packages/comark/SPEC/common-mark/horizontal-rule-3-.md index 9d3eaebc..f17c300c 100644 --- a/packages/comark/SPEC/common-mark/horizontal-rule-3-.md +++ b/packages/comark/SPEC/common-mark/horizontal-rule-3-.md @@ -30,7 +30,7 @@ Paragraph ```html

Paragraph

-
+
``` ## Markdown diff --git a/packages/comark/SPEC/common-mark/horizontal-rule-3-asterisk.md b/packages/comark/SPEC/common-mark/horizontal-rule-3-asterisk.md index 52de83df..1277c6d0 100644 --- a/packages/comark/SPEC/common-mark/horizontal-rule-3-asterisk.md +++ b/packages/comark/SPEC/common-mark/horizontal-rule-3-asterisk.md @@ -22,7 +22,7 @@ ## HTML ```html -
+
``` ## Markdown diff --git a/packages/comark/SPEC/common-mark/horizontal-rule-bare.md b/packages/comark/SPEC/common-mark/horizontal-rule-bare.md index 1cf93a05..00e35920 100644 --- a/packages/comark/SPEC/common-mark/horizontal-rule-bare.md +++ b/packages/comark/SPEC/common-mark/horizontal-rule-bare.md @@ -22,7 +22,7 @@ ## HTML ```html -
+
``` ## Markdown diff --git a/packages/comark/SPEC/common-mark/horizontal-rule-more-.md b/packages/comark/SPEC/common-mark/horizontal-rule-more-.md index 646d2556..6c4dc48d 100644 --- a/packages/comark/SPEC/common-mark/horizontal-rule-more-.md +++ b/packages/comark/SPEC/common-mark/horizontal-rule-more-.md @@ -30,7 +30,7 @@ Paragraph ```html

Paragraph

-
+
``` ## Markdown diff --git a/packages/comark/SPEC/common-mark/html-block-void-br.md b/packages/comark/SPEC/common-mark/html-block-void-br.md index 086652b3..37c08cd8 100644 --- a/packages/comark/SPEC/common-mark/html-block-void-br.md +++ b/packages/comark/SPEC/common-mark/html-block-void-br.md @@ -36,14 +36,14 @@ ## HTML ```html -
+

After br

``` ## Markdown ```md -
+
# After br ``` diff --git a/packages/comark/SPEC/common-mark/html-block-void-element.md b/packages/comark/SPEC/common-mark/html-block-void-element.md index d2cfdb2a..b6bcbdca 100644 --- a/packages/comark/SPEC/common-mark/html-block-void-element.md +++ b/packages/comark/SPEC/common-mark/html-block-void-element.md @@ -1,7 +1,7 @@ ## Input ```md -Comark banner +Comark banner # comark @@ -46,7 +46,7 @@ A high-performance markdown parser and renderer. ## HTML ```html -Comark banner +Comark banner

comark

A high-performance markdown parser and renderer.

``` @@ -54,7 +54,7 @@ A high-performance markdown parser and renderer. ## Markdown ```md -Comark banner +Comark banner # comark diff --git a/packages/comark/SPEC/common-mark/image-title.md b/packages/comark/SPEC/common-mark/image-title.md index 1ea11b02..659ab4cf 100644 --- a/packages/comark/SPEC/common-mark/image-title.md +++ b/packages/comark/SPEC/common-mark/image-title.md @@ -30,7 +30,7 @@ ## HTML ```html -

alt

+

alt

``` ## Markdown diff --git a/packages/comark/SPEC/common-mark/line-break.md b/packages/comark/SPEC/common-mark/line-break.md index e52208bd..62262fa6 100644 --- a/packages/comark/SPEC/common-mark/line-break.md +++ b/packages/comark/SPEC/common-mark/line-break.md @@ -29,7 +29,7 @@ World ## HTML ```html -

Hello
World

+

Hello
World

``` ## Markdown diff --git a/packages/comark/SPEC/common-mark/links.md b/packages/comark/SPEC/common-mark/links.md index 96125775..2240dc1b 100644 --- a/packages/comark/SPEC/common-mark/links.md +++ b/packages/comark/SPEC/common-mark/links.md @@ -11,55 +11,44 @@ ## AST ```json - { - "frontmatter":{ - - }, - "meta":{ - - }, - "nodes":[ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "p", + {}, [ - "p", - { - - }, - [ - "a", - { - "href":"#" - }, - "Document" - ] - ], + "a", + { + "href": "#" + }, + "Document" + ] + ], + [ + "p", + {}, [ - "p", - { - - }, - [ - "a", - { - "href":"#" - }, - "[1] Document" - ] - ], + "a", + { + "href": "#" + }, + "[1] Document" + ] + ], + [ + "p", + {}, [ - "p", - { - - }, - [ - "a", - { - "href":"https://example.com" - }, - "[link-name] more" - ] + "a", + { + "href": "https://example.com" + }, + "[link-name] more" ] - ] + ] + ] } ``` diff --git a/packages/comark/SPEC/common-mark/paragraph-image.md b/packages/comark/SPEC/common-mark/paragraph-image.md index 5a2cde59..24c091c9 100644 --- a/packages/comark/SPEC/common-mark/paragraph-image.md +++ b/packages/comark/SPEC/common-mark/paragraph-image.md @@ -30,7 +30,7 @@ ## HTML ```html -

The San Juan Mountains are beautiful

+

The San Juan Mountains are beautiful

``` ## Markdown diff --git a/packages/comark/SPEC/common-mark/paragraph-link-image.md b/packages/comark/SPEC/common-mark/paragraph-link-image.md index 72370b33..7f464609 100644 --- a/packages/comark/SPEC/common-mark/paragraph-link-image.md +++ b/packages/comark/SPEC/common-mark/paragraph-link-image.md @@ -36,7 +36,7 @@ ## HTML ```html -

An old rock in the desert

+

An old rock in the desert

``` ## Markdown diff --git a/packages/comark/SPEC/common-mark/paragraph-multiple.md b/packages/comark/SPEC/common-mark/paragraph-multiple.md index 9e38af2f..32af066c 100644 --- a/packages/comark/SPEC/common-mark/paragraph-multiple.md +++ b/packages/comark/SPEC/common-mark/paragraph-multiple.md @@ -36,7 +36,7 @@ This is another paragraph ## HTML ```html -

This is a simple paragraph
And continues in next line

+

This is a simple paragraph
And continues in next line

This is another paragraph

``` diff --git a/packages/comark/SPEC/common-mark/xyz.md b/packages/comark/SPEC/common-mark/xyz.md index bc390447..aaf4369c 100644 --- a/packages/comark/SPEC/common-mark/xyz.md +++ b/packages/comark/SPEC/common-mark/xyz.md @@ -313,7 +313,7 @@ And here's a code block:

Section Two

Here's an image:

-

Alt text

+

Alt text

And here's a code block:

``` diff --git a/packages/comark/SPEC/markdown-directive/directive-no-content.md b/packages/comark/SPEC/markdown-directive/directive-no-content.md index 0d6212ae..b015ad25 100644 --- a/packages/comark/SPEC/markdown-directive/directive-no-content.md +++ b/packages/comark/SPEC/markdown-directive/directive-no-content.md @@ -58,10 +58,10 @@ a :br with no content or attributes ```html

a -


+
directive with no content

-

a
with no content or attributes

+

a
with no content or attributes

``` diff --git a/packages/comark/src/internal/parse/html/index.ts b/packages/comark/src/internal/parse/html/index.ts index a69864fe..222d6588 100644 --- a/packages/comark/src/internal/parse/html/index.ts +++ b/packages/comark/src/internal/parse/html/index.ts @@ -1,5 +1,5 @@ import { Parser } from 'htmlparser2' -import type { Node } from 'comark' +import type { ElementNode, Node } from 'comark' export const VOID_ELEMENTS = new Set([ 'area', @@ -77,9 +77,53 @@ export function parseInlineHtmlTag(html: string): HtmlTagInfo | null { return info } +/** + * Whether a node is a block-level HTML element (`$.block === 1`). + * Text and comments are not block elements. + */ +function isBlockHtmlElement(node: Node): boolean { + if (typeof node === 'string' || !Array.isArray(node) || node[0] === null) return false + const meta = (node[1] as Record | undefined)?.$ as Record | undefined + return meta?.html === 1 && meta?.block === 1 +} + +/** + * Infer `$.block` from structure (no tag-name allowlists): + * + * - Root of an `html_block` fragment stays `block: 1` (it was a block unit). + * - Nested element is `block: 0` when every child is text / comment / inline HTML + * (no nested `block: 1` descendants that make it a block container). + * - Nested element is `block: 1` when it contains at least one block child. + * + * Walks bottom-up so children's flags are settled before the parent is classified. + */ +function inferBlockFromChildren(nodes: Node[], isRootLevel: boolean): void { + for (const node of nodes) { + if (typeof node === 'string' || !Array.isArray(node) || node[0] === null) continue + + const element = node as ElementNode + const children = element.slice(2) as Node[] + inferBlockFromChildren(children, false) + + const attrs = element[1] as Record + const meta = (attrs.$ ||= {}) as Record + if (meta.html !== 1) continue + + if (isRootLevel) { + // Top-level of an html_block token is always a block unit. + meta.block = 1 + continue + } + + const hasBlockChild = children.some(isBlockHtmlElement) + meta.block = hasBlockChild ? 1 : 0 + } +} + /** * Parse a full HTML string into Nodes using htmlparser2. * Handles nested elements, text, void elements, and comments. + * `$.block` is inferred from children after the tree is built. */ export function htmlToNodes(html: string): Node[] { const root: Node[] = [] @@ -88,7 +132,8 @@ export function htmlToNodes(html: string): Node[] { const parser = new Parser( { onopentag(name, attribs) { - const attrs = attribsToComarkAttrs(attribs) + // Provisional block:1; refined by inferBlockFromChildren after close. + const attrs = attribsToComarkAttrs(attribs, false) if (VOID_ELEMENTS.has(name)) { const node = [name, attrs] as Node if (stack.length > 0) { @@ -151,5 +196,6 @@ export function htmlToNodes(html: string): Node[] { parser.write(html.trim()) parser.end() + inferBlockFromChildren(root, true) return root } diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts index b633aca1..627d08c6 100644 --- a/packages/comark/src/internal/stringify/handlers/html.ts +++ b/packages/comark/src/internal/stringify/handlers/html.ts @@ -138,7 +138,7 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode const attrs = Object.keys(attributes).length > 0 ? ` ${htmlAttributes(attributes)}` : '' if (isSelfClose) { - return `<${tag}${attrs} />` + (!parent && !isInline ? state.context.blockSeparator : '') + return `<${tag}${attrs}>` + (!parent && !isInline ? state.context.blockSeparator : '') } if (!oneLiner && content) { diff --git a/packages/comark/test/html-block.test.ts b/packages/comark/test/html-block.test.ts index 3d492e9d..40edfa4a 100644 --- a/packages/comark/test/html-block.test.ts +++ b/packages/comark/test/html-block.test.ts @@ -8,7 +8,7 @@ describe('block-level raw HTML', () => { const result = await parseMarkdown('

x

') expect(result.nodes).toEqual([ - ['p', { $: { html: 1, block: 1 } }, ['img', { $: { html: 1, block: 1 }, src: '/foo.png', alt: 'x' }]], + ['p', { $: { html: 1, block: 1 } }, ['img', { $: { html: 1, block: 0 }, src: '/foo.png', alt: 'x' }]], ]) }) @@ -20,7 +20,7 @@ describe('block-level raw HTML', () => { 'p', { $: { html: 1, block: 1 } }, 'hello', - ['img', { $: { html: 1, block: 1 }, src: '/foo.png', alt: 'x' }], + ['img', { $: { html: 1, block: 0 }, src: '/foo.png', alt: 'x' }], 'world', ], ]) @@ -37,7 +37,7 @@ That is some text here.` expect(result.nodes).toEqual([ ['h1', { id: 'hello' }, 'Hello'], - ['p', { $: { html: 1, block: 1 } }, ['img', { $: { html: 1, block: 1 }, src: '/foo.png', alt: 'x' }]], + ['p', { $: { html: 1, block: 1 } }, ['img', { $: { html: 1, block: 0 }, src: '/foo.png', alt: 'x' }]], ['p', {}, 'That is some text here.'], ]) }) @@ -78,7 +78,7 @@ this is **markdown** 'div', { $: { html: 1, block: 1 } }, 'before **strong**', - ['img', { $: { html: 1, block: 1 }, src: '/x.png', alt: 'x' }], + ['img', { $: { html: 1, block: 0 }, src: '/x.png', alt: 'x' }], 'after `code`', ], ]) @@ -121,7 +121,7 @@ after \`code\` `) expect(result.nodes).toEqual([ - ['div', { $: { html: 1, block: 1 } }, [null, {}, ' note '], ['img', { $: { html: 1, block: 1 }, src: '/x.png' }]], + ['div', { $: { html: 1, block: 1 } }, [null, {}, ' note '], ['img', { $: { html: 1, block: 0 }, src: '/x.png' }]], ]) }) @@ -134,7 +134,7 @@ after \`code\` [ 'a', { $: { html: 1, block: 1 }, href: sponsorsUrl }, - ['img', { $: { html: 1, block: 1 }, src: sponsorsUrl, alt: 'Sponsors' }], + ['img', { $: { html: 1, block: 0 }, src: sponsorsUrl, alt: 'Sponsors' }], ], ]) }) @@ -152,8 +152,8 @@ after \`code\` { $: { html: 1, block: 1 }, align: 'center' }, [ 'a', - { $: { html: 1, block: 1 }, href: sponsorsUrl }, - ['img', { $: { html: 1, block: 1 }, src: sponsorsUrl, alt: 'Sponsors' }], + { $: { html: 1, block: 0 }, href: sponsorsUrl }, + ['img', { $: { html: 1, block: 0 }, src: sponsorsUrl, alt: 'Sponsors' }], ], ], ]) diff --git a/packages/comark/test/html-escape.test.ts b/packages/comark/test/html-escape.test.ts index 6551678d..7a1cca8a 100644 --- a/packages/comark/test/html-escape.test.ts +++ b/packages/comark/test/html-escape.test.ts @@ -49,6 +49,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(`x`) + 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/index.test.ts b/packages/comark/test/index.test.ts index 20819467..33bb59e9 100644 --- a/packages/comark/test/index.test.ts +++ b/packages/comark/test/index.test.ts @@ -269,6 +269,7 @@ describe('Comark Tests', () => { }, }) const expectedHTML = testCase.html.trim() + console.log(result) expect(result).toBe(expectedHTML) }) From daf10ab11c397a8b2666cf5fbde013dbefbc8e9a Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 4 Sep 2026 16:15:11 +0200 Subject: [PATCH 11/15] feat: allow disabling markdown in html to follow commonmark spec --- AGENTS.md | 2 +- .../internal/parse/html/html_block_rule.ts | 115 +++++++++++------- packages/comark/src/plugins/html.ts | 70 +++++++++-- packages/comark/src/types.ts | 1 + packages/comark/test/html-block.test.ts | 49 ++++++++ 5 files changed, 180 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0336235e..2a4396e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -412,7 +412,7 @@ import alert from 'comark/plugins/alert' import frontmatter from 'comark/plugins/frontmatter' // default via registerDefaultPlugins import components from 'comark/plugins/components' // default via registerDefaultPlugins import attributes from 'comark/plugins/attributes' // default via registerDefaultPlugins -import html from 'comark/plugins/html' // default via registerDefaultPlugins +import html from 'comark/plugins/html' // default via registerDefaultPlugins; html({ markdown: false }) = blank-line-only markdown nesting (CommonMark-style) // markdown-it / markdown-exit adapters (e.g. VitePress) import { markdownItComponents } from 'comark/plugins/components' diff --git a/packages/comark/src/internal/parse/html/html_block_rule.ts b/packages/comark/src/internal/parse/html/html_block_rule.ts index 2b24a069..776c4fe4 100644 --- a/packages/comark/src/internal/parse/html/html_block_rule.ts +++ b/packages/comark/src/internal/parse/html/html_block_rule.ts @@ -2,6 +2,11 @@ // https://spec.commonmark.org/0.30/#html-blocks // // 7 sequences in priority order, each: [opener regex, closer regex, can-terminate-paragraph] +// +// Blank-line terminated HTML (CommonMark types 6/7) already allows markdown in +// the body after `\n\n`. The `markdown` option only changes tight incomplete +// openers (no closer before EOF, no blank line): with `markdown: true` (default) +// the body is left for markdown; with `markdown: false` the block stays raw. import type { StateBlock } from 'markdown-exit' import block_names from './html_blocks.ts' @@ -31,62 +36,78 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -export default function html_block(state: StateBlock, startLine: number, endLine: number, silent: boolean) { - let pos = state.bMarks[startLine] + state.tShift[startLine] - let max = state.eMarks[startLine] - - if (state.sCount[startLine] - state.blkIndent >= 4) return false - if (state.src.charCodeAt(pos) !== 0x3c /* < */) return false - - let lineText = state.src.slice(pos, max) - - let i = 0 - for (; i < HTML_SEQUENCES.length; i++) { - if (HTML_SEQUENCES[i][0].test(lineText)) break - } - if (i === HTML_SEQUENCES.length) return false - - if (silent) return HTML_SEQUENCES[i][2] +export interface HtmlBlockRuleOptions { + /** + * When true, incomplete lone open tags (no closer before EOF) leave the body + * for markdown tokenization. When false, they stay CommonMark-raw until a + * blank line (markdown only after `\n\n`). + * @default true + */ + markdown?: boolean +} - let nextLine = startLine + 1 +export default function createHtmlBlockRule(options: HtmlBlockRuleOptions = {}) { + const allowIncompleteMarkdown = options.markdown !== false - // Sequences whose end condition is a blank line (type 6 block tags, type 7 - // generic tags). A lone open tag with no matching closer before EOF is an - // incomplete streaming opener — only consume the opener line so following - // markdown can be tokenized and absorbed by the token processor. - const blankLineTerminated = HTML_SEQUENCES[i][1].source === '^$' - const openerTag = blankLineTerminated ? loneOpenTagName(lineText) : null + return function html_block(state: StateBlock, startLine: number, endLine: number, silent: boolean) { + let pos = state.bMarks[startLine] + state.tShift[startLine] + let max = state.eMarks[startLine] - // Walk forward until the closer regex matches or we hit a blank line. - if (!HTML_SEQUENCES[i][1].test(lineText)) { - let sawMatchingClose = false - for (; nextLine < endLine; nextLine++) { - if (state.sCount[nextLine] < state.blkIndent) break + if (state.sCount[startLine] - state.blkIndent >= 4) return false + if (state.src.charCodeAt(pos) !== 0x3c /* < */) return false - pos = state.bMarks[nextLine] + state.tShift[nextLine] - max = state.eMarks[nextLine] - lineText = state.src.slice(pos, max) + let lineText = state.src.slice(pos, max) - if (openerTag && new RegExp(`^\\s*$`, 'i').test(lineText.trim())) { - sawMatchingClose = true + let i = 0 + for (; i < HTML_SEQUENCES.length; i++) { + if (HTML_SEQUENCES[i][0].test(lineText)) break + } + if (i === HTML_SEQUENCES.length) return false + + if (silent) return HTML_SEQUENCES[i][2] + + let nextLine = startLine + 1 + + // Sequences whose end condition is a blank line (type 6 block tags, type 7 + // generic tags). A lone open tag with no matching closer before EOF is an + // incomplete streaming opener — only consume the opener line so following + // markdown can be tokenized and absorbed by the token processor (when + // `markdown` is enabled). + const blankLineTerminated = HTML_SEQUENCES[i][1].source === '^$' + const openerTag = blankLineTerminated ? loneOpenTagName(lineText) : null + + // Walk forward until the closer regex matches or we hit a blank line. + if (!HTML_SEQUENCES[i][1].test(lineText)) { + let sawMatchingClose = false + for (; nextLine < endLine; nextLine++) { + if (state.sCount[nextLine] < state.blkIndent) break + + pos = state.bMarks[nextLine] + state.tShift[nextLine] + max = state.eMarks[nextLine] + lineText = state.src.slice(pos, max) + + if (openerTag && new RegExp(`^\\s*$`, 'i').test(lineText.trim())) { + sawMatchingClose = true + } + + if (HTML_SEQUENCES[i][1].test(lineText)) { + if (lineText.length !== 0) nextLine++ + break + } } - if (HTML_SEQUENCES[i][1].test(lineText)) { - if (lineText.length !== 0) nextLine++ - break + // Incomplete open tag running to EOF with no closer: leave body for markdown + // (opt-in; `markdown: false` keeps CommonMark raw until blank line / EOF). + if (allowIncompleteMarkdown && openerTag && !sawMatchingClose && nextLine >= endLine) { + nextLine = startLine + 1 } } - // Incomplete open tag running to EOF with no closer: leave body for markdown. - if (openerTag && !sawMatchingClose && nextLine >= endLine) { - nextLine = startLine + 1 - } - } - - state.line = nextLine - const token = state.push('html_block', '', 1) - token.map = [startLine, nextLine] - token.content = state.getLines(startLine, nextLine, state.blkIndent, true) + state.line = nextLine + const token = state.push('html_block', '', 1) + token.map = [startLine, nextLine] + token.content = state.getLines(startLine, nextLine, state.blkIndent, true) - return true + return true + } } diff --git a/packages/comark/src/plugins/html.ts b/packages/comark/src/plugins/html.ts index fdc1e4ed..b26c0a98 100644 --- a/packages/comark/src/plugins/html.ts +++ b/packages/comark/src/plugins/html.ts @@ -18,24 +18,76 @@ * plugins: [html()], * }) * // → [ ['strong', { class: 'bold', $: { html: 1, block: 0 } }, 'Hello'] ] + * + * // Blank-line-only markdown nesting (CommonMark-style HTML blocks) + * const raw = await parseMarkdown('
\n**bold**\n
', { + * plugins: [html({ markdown: false })], + * }) + * // → body stays literal `**bold**` (no strong node) + * + * // Blank line still enables markdown either way: + * //
\n\n**bold**\n\n
→ strong * ``` */ import type { MarkdownExit } from 'markdown-exit' import type { MarkdownItPlugin } from '../types.ts' import { defineComarkPlugin } from '../utils/helpers.ts' -import html_block from '../internal/parse/html/html_block_rule.ts' +import createHtmlBlockRule 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 { + /** + * When markdown is allowed inside / after HTML **without** a blank line. + * + * HTML nesting has two shapes: + * + * 1. **Blank-line body** (always markdown, either mode): + * ```md + *
+ * + * **bold** + * + *
+ * ``` + * CommonMark ends the HTML block on the blank line, so the body is normal + * markdown and is later nested under the open tag. + * + * 2. **Tight body** (no blank line after the open tag): + * ```md + *
+ * **bold** + *
+ * ``` + * or incomplete / streaming: + * ```md + * + * **bold** + * ``` + * + * - `true` (default): tight incomplete openers (no closer yet) still tokenize + * the body as markdown. Closed tight bodies stay CommonMark-raw. + * - `false`: tight bodies stay raw HTML (CommonMark). Markdown only when a + * blank line separates the open tag from the body. + * + * @default true + */ + markdown?: boolean +} + +function markdownItHtml(options: HtmlPluginOptions = {}) { + const html_block = createHtmlBlockRule({ markdown: options.markdown }) + + return function install(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 default defineComarkPlugin(() => ({ +export default defineComarkPlugin((options = {}) => ({ name: 'html', - markdownItPlugins: [markdownItHtml as unknown as MarkdownItPlugin], + markdownItPlugins: [markdownItHtml(options) as unknown as MarkdownItPlugin], })) diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts index 50ceeae9..32f9f948 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -1,6 +1,7 @@ import type { DumpOptions } from 'js-yaml' import type MarkdownExit from 'markdown-exit' import type MarkdownIt from 'markdown-it' +import { HtmlPluginOptions } from './plugins/html' // #region Utility Types /** diff --git a/packages/comark/test/html-block.test.ts b/packages/comark/test/html-block.test.ts index 40edfa4a..96e5e7a6 100644 --- a/packages/comark/test/html-block.test.ts +++ b/packages/comark/test/html-block.test.ts @@ -1,8 +1,57 @@ import { describe, expect, it } from 'vitest' import { parseMarkdown } from '../src/index' +import html from '../src/plugins/html' const sponsorsUrl = 'https://cdn.jsdelivr.net/gh/antfu/static/sponsors.svg' +describe('html({ markdown })', () => { + it('parses markdown inside incomplete HTML by default', async () => { + const result = await parseMarkdown('\n**bold**') + + expect(result.nodes).toEqual([ + ['ai-thinking', { $: { html: 1, block: 0 } }, ['strong', {}, 'bold']], + ]) + }) + + it('keeps markdown literal inside incomplete HTML when markdown: false', async () => { + const result = await parseMarkdown('\n**bold**', { + // Replace the default html plugin so only this config is active. + plugins: [html({ markdown: false })], + }) + + // Body is a single text leaf → block: 0 (inline-like incomplete opener). + expect(result.nodes).toEqual([['ai-thinking', { $: { html: 1, block: 0 } }, '**bold**']]) + }) + + it('still parses markdown after a blank line when markdown: false', async () => { + const result = await parseMarkdown('\n\n**bold**\n\n', { + plugins: [html({ markdown: false })], + }) + + expect(result.nodes).toEqual([ + ['ai-thinking', { $: { html: 1, block: 0 } }, ['strong', {}, 'bold']], + ]) + }) + + it('still keeps closed HTML body literal without a blank line when markdown: false', async () => { + const result = await parseMarkdown('
\nHello **World**\n
', { + plugins: [html({ markdown: false })], + }) + + expect(result.nodes).toEqual([['div', { $: { html: 1, block: 1 } }, 'Hello **World**']]) + }) + + it('parses markdown inside closed HTML after a blank line when markdown: false', async () => { + const result = await parseMarkdown('
\n\nHello **World**\n\n
', { + plugins: [html({ markdown: false })], + }) + + expect(result.nodes).toEqual([ + ['div', { $: { html: 1, block: 1 } }, 'Hello ', ['strong', {}, 'World']], + ]) + }) +}) + describe('block-level raw HTML', () => { it('preserves inline children inside a self-contained block-level

', async () => { const result = await parseMarkdown('

x

') From 932e2813803734d45a629a4049fbcaff7bcae6f4 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 4 Sep 2026 16:16:08 +0200 Subject: [PATCH 12/15] lint: fix --- packages/comark/src/types.ts | 1 - packages/comark/test/html-block.test.ts | 12 +++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts index 32f9f948..50ceeae9 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -1,7 +1,6 @@ import type { DumpOptions } from 'js-yaml' import type MarkdownExit from 'markdown-exit' import type MarkdownIt from 'markdown-it' -import { HtmlPluginOptions } from './plugins/html' // #region Utility Types /** diff --git a/packages/comark/test/html-block.test.ts b/packages/comark/test/html-block.test.ts index 96e5e7a6..57e99928 100644 --- a/packages/comark/test/html-block.test.ts +++ b/packages/comark/test/html-block.test.ts @@ -8,9 +8,7 @@ describe('html({ markdown })', () => { it('parses markdown inside incomplete HTML by default', async () => { const result = await parseMarkdown('\n**bold**') - expect(result.nodes).toEqual([ - ['ai-thinking', { $: { html: 1, block: 0 } }, ['strong', {}, 'bold']], - ]) + expect(result.nodes).toEqual([['ai-thinking', { $: { html: 1, block: 0 } }, ['strong', {}, 'bold']]]) }) it('keeps markdown literal inside incomplete HTML when markdown: false', async () => { @@ -28,9 +26,7 @@ describe('html({ markdown })', () => { plugins: [html({ markdown: false })], }) - expect(result.nodes).toEqual([ - ['ai-thinking', { $: { html: 1, block: 0 } }, ['strong', {}, 'bold']], - ]) + expect(result.nodes).toEqual([['ai-thinking', { $: { html: 1, block: 0 } }, ['strong', {}, 'bold']]]) }) it('still keeps closed HTML body literal without a blank line when markdown: false', async () => { @@ -46,9 +42,7 @@ describe('html({ markdown })', () => { plugins: [html({ markdown: false })], }) - expect(result.nodes).toEqual([ - ['div', { $: { html: 1, block: 1 } }, 'Hello ', ['strong', {}, 'World']], - ]) + expect(result.nodes).toEqual([['div', { $: { html: 1, block: 1 } }, 'Hello ', ['strong', {}, 'World']]]) }) }) From 596e3141a8cc521ba4619d92a69d527773dbe980 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 4 Sep 2026 17:18:36 +0200 Subject: [PATCH 13/15] cleanup --- .../src/internal/stringify/handlers/html.ts | 10 ++------ packages/comark/src/plugins/html.ts | 25 ------------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts index 627d08c6..5c842ee7 100644 --- a/packages/comark/src/internal/stringify/handlers/html.ts +++ b/packages/comark/src/internal/stringify/handlers/html.ts @@ -142,16 +142,10 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode } if (!oneLiner && content) { - if (state.context.html) { - content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n' - } else if (bodyStartsWithMarkdown) { - // Markdown-first body: blank line after open so the body re-parses as - // markdown; blank line before close so a trailing closer is its own - // html_block (not absorbed into a list item / paragraph). + if (!state.context.html && bodyStartsWithMarkdown) { + // blank line after open so the body re-parses as markdown; content = '\n\n' + content.trimEnd() + '\n\n' } else { - // Raw HTML / HTML-first body — keep content flush after the open tag so - // reparse matches CommonMark html_block runs. content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n' } } diff --git a/packages/comark/src/plugins/html.ts b/packages/comark/src/plugins/html.ts index b26c0a98..930952c4 100644 --- a/packages/comark/src/plugins/html.ts +++ b/packages/comark/src/plugins/html.ts @@ -40,31 +40,6 @@ export interface HtmlPluginOptions { /** * When markdown is allowed inside / after HTML **without** a blank line. * - * HTML nesting has two shapes: - * - * 1. **Blank-line body** (always markdown, either mode): - * ```md - *
- * - * **bold** - * - *
- * ``` - * CommonMark ends the HTML block on the blank line, so the body is normal - * markdown and is later nested under the open tag. - * - * 2. **Tight body** (no blank line after the open tag): - * ```md - *
- * **bold** - *
- * ``` - * or incomplete / streaming: - * ```md - * - * **bold** - * ``` - * * - `true` (default): tight incomplete openers (no closer yet) still tokenize * the body as markdown. Closed tight bodies stay CommonMark-raw. * - `false`: tight bodies stay raw HTML (CommonMark). Markdown only when a From 5187324e8ef57c55e46e3eee7a0269d197c06f5b Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Fri, 4 Sep 2026 18:12:04 +0200 Subject: [PATCH 14/15] cleanup --- .../comark/src/internal/parse/auto-unwrap.ts | 33 +--- .../internal/parse/html/html_block_rule.ts | 15 +- .../comark/src/internal/parse/html/index.ts | 9 +- .../src/internal/parse/token-processor.ts | 157 +++++++----------- .../src/internal/stringify/handlers/html.ts | 16 +- packages/comark/src/plugins/html.ts | 16 +- 6 files changed, 84 insertions(+), 162 deletions(-) diff --git a/packages/comark/src/internal/parse/auto-unwrap.ts b/packages/comark/src/internal/parse/auto-unwrap.ts index 59fef4cd..20f849e7 100644 --- a/packages/comark/src/internal/parse/auto-unwrap.ts +++ b/packages/comark/src/internal/parse/auto-unwrap.ts @@ -31,16 +31,10 @@ export function applyAutoUnwrap(node: Node): Node { // Recurse first so nested HTML wrappers (details → details → p) unwrap bottom-up. const unwrappedChildren = children.map((child: Node) => applyAutoUnwrap(child as Node)) - - // Filter out empty text nodes for checking const nonEmptyChildren = unwrappedChildren.filter( (child: Node) => typeof child !== 'string' || (child && child.trim()) ) - if (nonEmptyChildren.length === 0) { - return [tag, props, ...unwrappedChildren] as Node - } - // Classic case: container has only a single markdown paragraph child. if (nonEmptyChildren.length === 1 && isMarkdownParagraph(nonEmptyChildren[0])) { // Lift the paragraph's attrs onto the parent so trailing `{attr}` survives the unwrap. @@ -55,27 +49,18 @@ export function applyAutoUnwrap(node: Node): Node { // with `$.html`) with a single markdown paragraph body. Unwrap that lone // markdown p only when every other non-empty sibling is itself HTML-originated // — so `p + ul` under an incomplete `` stays as-is. - const isHtmlParent = - (props as Record | undefined)?.$ && - typeof (props as Record).$ === 'object' && - (props as Record).$.html === 1 - if (isHtmlParent) { + const htmlMeta = (props as Record)?.$ + if (htmlMeta && typeof htmlMeta === 'object' && htmlMeta.html === 1) { const markdownParagraphs = nonEmptyChildren.filter(isMarkdownParagraph) - const otherChildren = nonEmptyChildren.filter((c) => !isMarkdownParagraph(c)) - const othersAreHtml = otherChildren.every( - (c) => - Array.isArray(c) && typeof c[1] === 'object' && c[1] !== null && (c[1] as Record).$?.html === 1 + const othersAreHtml = nonEmptyChildren.every( + (c) => isMarkdownParagraph(c) || (Array.isArray(c) && (c[1] as Record)?.$?.html === 1) ) if (markdownParagraphs.length === 1 && othersAreHtml) { - const out: Node[] = [] - for (const child of unwrappedChildren) { - if (isMarkdownParagraph(child)) { - out.push(...(child.slice(2) as Node[])) - } else { - out.push(child) - } - } - return [tag, props, ...out] as Node + return [ + tag, + props, + ...unwrappedChildren.flatMap((child) => (isMarkdownParagraph(child) ? (child.slice(2) as Node[]) : [child])), + ] as Node } } diff --git a/packages/comark/src/internal/parse/html/html_block_rule.ts b/packages/comark/src/internal/parse/html/html_block_rule.ts index 776c4fe4..50a2bb5e 100644 --- a/packages/comark/src/internal/parse/html/html_block_rule.ts +++ b/packages/comark/src/internal/parse/html/html_block_rule.ts @@ -25,8 +25,6 @@ const HTML_SEQUENCES: [RegExp, RegExp, boolean][] = [ /** Open tag name when `line` is a lone start tag (`` / ``), else null. */ function loneOpenTagName(line: string): string | null { const trimmed = line.trim() - // Closing tags, void self-closers, comments, declarations — not incomplete openers. - if (!trimmed.startsWith('<') || trimmed.startsWith('\s*$/.test(trimmed)) return null const match = trimmed.match(/^<([a-zA-Z][\w:-]*)(?:\s[^>]*)?>\s*$/) return match ? match[1] : null @@ -73,11 +71,12 @@ export default function createHtmlBlockRule(options: HtmlBlockRuleOptions = {}) // incomplete streaming opener — only consume the opener line so following // markdown can be tokenized and absorbed by the token processor (when // `markdown` is enabled). - const blankLineTerminated = HTML_SEQUENCES[i][1].source === '^$' - const openerTag = blankLineTerminated ? loneOpenTagName(lineText) : null + const closer = HTML_SEQUENCES[i][1] + const openerTag = allowIncompleteMarkdown && closer.source === '^$' ? loneOpenTagName(lineText) : null + const matchingClose = openerTag ? new RegExp(`^\\s*$`, 'i') : null // Walk forward until the closer regex matches or we hit a blank line. - if (!HTML_SEQUENCES[i][1].test(lineText)) { + if (!closer.test(lineText)) { let sawMatchingClose = false for (; nextLine < endLine; nextLine++) { if (state.sCount[nextLine] < state.blkIndent) break @@ -86,11 +85,9 @@ export default function createHtmlBlockRule(options: HtmlBlockRuleOptions = {}) max = state.eMarks[nextLine] lineText = state.src.slice(pos, max) - if (openerTag && new RegExp(`^\\s*$`, 'i').test(lineText.trim())) { - sawMatchingClose = true - } + if (matchingClose?.test(lineText.trim())) sawMatchingClose = true - if (HTML_SEQUENCES[i][1].test(lineText)) { + if (closer.test(lineText)) { if (lineText.length !== 0) nextLine++ break } diff --git a/packages/comark/src/internal/parse/html/index.ts b/packages/comark/src/internal/parse/html/index.ts index 222d6588..b681709a 100644 --- a/packages/comark/src/internal/parse/html/index.ts +++ b/packages/comark/src/internal/parse/html/index.ts @@ -109,14 +109,7 @@ function inferBlockFromChildren(nodes: Node[], isRootLevel: boolean): void { const meta = (attrs.$ ||= {}) as Record if (meta.html !== 1) continue - if (isRootLevel) { - // Top-level of an html_block token is always a block unit. - meta.block = 1 - continue - } - - const hasBlockChild = children.some(isBlockHtmlElement) - meta.block = hasBlockChild ? 1 : 0 + meta.block = isRootLevel || children.some(isBlockHtmlElement) ? 1 : 0 } } diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index 93fd0351..5101a451 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -88,37 +88,27 @@ export function marmdownItTokensToMarkdownDocument(tokens: any[], opts?: TokenPr return nodes } -/** - * Whether an `html_block` token's content already closes its own outer element - * (self-contained on one run: `

`, void tags, comments, etc.). - */ -function htmlBlockHasOwnClose(content: string): boolean { - const trimmed = content.trim() - if (!trimmed) return false - // Comments, declarations, CDATA, processing instructions: self-terminating. - if (trimmed.startsWith('`, `
`) - if (/\/\s*>\s*$/.test(trimmed) && !trimmed.slice(1).includes('<')) return true - return new RegExp(``, 'i').test(trimmed) +const HTML_OPEN_TAG_RE = /^<\s*([a-zA-Z][\w:-]*)/ +const HTML_CLOSE_TAG_RE = /^<\/\s*([a-zA-Z][\w:-]*)\s*>$/ + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function htmlOpenTagName(content: string): string | null { + const match = content.trim().match(HTML_OPEN_TAG_RE) + return match ? match[1].toLowerCase() : null } /** Tag name of a bare closing HTML block (`
`), or null. */ function htmlBlockCloseTag(content: string): string | null { - const match = content.trim().match(/^<\/\s*([a-zA-Z][\w:-]*)\s*>$/) + const match = content.trim().match(HTML_CLOSE_TAG_RE) return match ? match[1].toLowerCase() : null } -/** - * Depth of `tag` openers still unclosed inside `content` (can be nested). - * Positive → more openers than closers; 0 → balanced; negative is treated as 0. - */ +/** Unclosed `tag` openers in `content`. Nested same-tag pairs cancel out. */ function htmlOuterTagDepth(content: string, tag: string): number { - const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const re = new RegExp(`]*>`, 'gi') + const re = new RegExp(`]*>`, 'gi') let depth = 0 let m: RegExpExecArray | null while ((m = re.exec(content)) !== null) { @@ -128,6 +118,32 @@ function htmlOuterTagDepth(content: string, tag: string): number { return depth } +/** Index of the matching closer, or -1 if this opener runs to EOF. */ +function findHtmlBlockCloseIndex(tokens: any[], startIndex: number, tag: string, depth: number): number { + for (let i = startIndex + 1; i < tokens.length; i++) { + const t = tokens[i] + if (t.type !== 'html_block') continue + const c = typeof t.content === 'string' ? t.content : '' + const closeTag = htmlBlockCloseTag(c) + if (closeTag === tag) { + depth-- + if (depth === 0) return i + continue + } + // Nested opener of the same tag (may include its own closer in the same token). + if (!closeTag && htmlOpenTagName(c) === tag) depth += htmlOuterTagDepth(c, tag) + } + return -1 +} + +function isMultiBlockBody(nodes: Node[]): boolean { + const nonEmpty = nodes.filter((child) => typeof child !== 'string' || (child && child.trim())) + return ( + nonEmpty.length > 1 || + (nonEmpty.length === 1 && Array.isArray(nonEmpty[0]) && nonEmpty[0][0] !== null && nonEmpty[0][0] !== 'p') + ) +} + /** * Convert an html_block token into Comark nodes. * @@ -144,55 +160,18 @@ function processHtmlBlockTokens( state?: ProcessState ): { nodes: Node[]; nextIndex: number } { const content = typeof tokens[startIndex]?.content === 'string' ? tokens[startIndex].content : '' - - // Bare closer with no surrounding open — drop (parent consumes matching ones). - if (htmlBlockCloseTag(content)) { - return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } - } - - // Fully closed in this token alone (including multi-line runs with matching - // open/close) — parse as a self-contained HTML fragment. - if (htmlBlockHasOwnClose(content)) { + const tag = htmlOpenTagName(content) + // Comments, closers, void tags, and already-balanced fragments stay as-is. + if (!tag || VOID_ELEMENTS.has(tag)) { return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } } - const openMatch = content.trim().match(/^<\s*([a-zA-Z][\w:-]*)/) - if (!openMatch) { - return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } - } - const tag = openMatch[1].toLowerCase() - - // How many outer `tag` frames this token opens that still need a closer. - // Opener-only content like `
\n` starts depth 1. - let depth = htmlOuterTagDepth(content, tag) + const depth = htmlOuterTagDepth(content, tag) if (depth <= 0) { return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 } } - // Scan ahead for a matching closer (nested same-tag openers bump depth). - let closeIndex = -1 - for (let i = startIndex + 1; i < tokens.length; i++) { - const t = tokens[i] - if (t.type !== 'html_block') continue - const c = typeof t.content === 'string' ? t.content : '' - const closeTag = htmlBlockCloseTag(c) - if (closeTag === tag) { - depth-- - if (depth === 0) { - closeIndex = i - break - } - continue - } - // Nested opener of the same tag (may include its own closer in the same token). - if (!htmlBlockCloseTag(c)) { - const nestedOpen = c.trim().match(/^<\s*([a-zA-Z][\w:-]*)/) - if (nestedOpen && nestedOpen[1].toLowerCase() === tag) { - depth += htmlOuterTagDepth(c, tag) - } - } - } - + const closeIndex = findHtmlBlockCloseIndex(tokens, startIndex, tag, depth) const parsed = htmlToNodes(content) const node = parsed[0] if (!node || typeof node === 'string' || node[0] === null) { @@ -202,44 +181,20 @@ function processHtmlBlockTokens( const element = node as ElementNode const openerAttrs = (element[1] || {}) as Record const prevMeta = (openerAttrs.$ || {}) as Record - const openerChildren = element.slice(2) as Node[] - - // No matching closer → streaming incomplete tag, absorb to EOF. - // Multi-block markdown bodies are real block containers (`block: 1`). - // A lone paragraph (often auto-unwrapped later) stays `block: 0` so it can - // serialize as a one-liner: `**bold**`. - if (closeIndex < 0) { - const children = processBlockChildren(tokens, startIndex + 1, '\0', false, false, false, state) - const nonEmpty = children.nodes.filter((child) => typeof child !== 'string' || (child && child.trim())) - const isMultiBlock = - nonEmpty.length > 1 || - (nonEmpty.length === 1 && Array.isArray(nonEmpty[0]) && nonEmpty[0][0] !== null && nonEmpty[0][0] !== 'p') - const attrs: Record = { - ...openerAttrs, - $: { ...prevMeta, html: 1, block: isMultiBlock ? 1 : 0 }, - } - return { - nodes: [[element[0], attrs, ...openerChildren, ...children.nodes] as Node], - nextIndex: children.nextIndex, - } - } - - // Matching closer → nest body under the opener (block: 1). Slice so - // processBlockChildren stops before the closer; recurse for nested HTML. - // Single-paragraph bodies are left as `

` here; `applyAutoUnwrap` lifts - // them when `autoUnwrap` is on (default). - const bodyTokens = tokens.slice(startIndex + 1, closeIndex) - const body = processBlockChildren(bodyTokens, 0, '\0', false, false, false, state) - - const attrs: Record = { - ...openerAttrs, - $: { ...prevMeta, html: 1, block: 1 }, - } + const end = closeIndex < 0 ? tokens.length : closeIndex + const body = processBlockChildren(tokens.slice(startIndex + 1, end), 0, '\0', false, false, false, state) + const block = closeIndex < 0 && !isMultiBlockBody(body.nodes) ? 0 : 1 return { - nodes: [[element[0], attrs, ...openerChildren, ...body.nodes] as Node], - // Consume the closer as well. - nextIndex: closeIndex + 1, + nodes: [ + [ + element[0], + { ...openerAttrs, $: { ...prevMeta, html: 1, block } }, + ...(element.slice(2) as Node[]), + ...body.nodes, + ] as Node, + ], + nextIndex: closeIndex < 0 ? tokens.length : closeIndex + 1, } } diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts index 5c842ee7..34d48484 100644 --- a/packages/comark/src/internal/stringify/handlers/html.ts +++ b/packages/comark/src/internal/stringify/handlers/html.ts @@ -137,8 +137,9 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode const attrs = Object.keys(attributes).length > 0 ? ` ${htmlAttributes(attributes)}` : '' + const trail = !parent && !isInline ? state.context.blockSeparator : '' if (isSelfClose) { - return `<${tag}${attrs}>` + (!parent && !isInline ? state.context.blockSeparator : '') + return `<${tag}${attrs}>` + trail } if (!oneLiner && content) { @@ -150,19 +151,12 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode } } - return `<${tag}${attrs}>${content}` + (!parent && !isInline ? state.context.blockSeparator : '') + return `<${tag}${attrs}>${content}` + trail } -// Literal-content tags whose body must be rendered verbatim (no indentation -// re-flow). Matches the parser-side set so `