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 new file mode 100644 index 00000000..e56712ae --- /dev/null +++ b/packages/comark/SPEC/HTML/details-inside-details.md @@ -0,0 +1,84 @@ +## 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/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

+ +
+``` + +## Markdown + +```md + + +**bold** and more + +- 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 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/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-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/src/internal/parse/auto-unwrap.ts b/packages/comark/src/internal/parse/auto-unwrap.ts index 1a9304d9..59fef4cd 100644 --- a/packages/comark/src/internal/parse/auto-unwrap.ts +++ b/packages/comark/src/internal/parse/auto-unwrap.ts @@ -18,6 +18,10 @@ 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 +29,55 @@ 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/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 diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index 6e157e26..a4017ee2 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,150 @@ 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 +} + +/** + * 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 +} + +/** + * Convert an html_block token into Comark nodes. + * + * 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 + * `
` builds a real tree. + */ +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 } + + // 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 } + } + + 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) + 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 parsed = htmlToNodes(content) + const node = parsed[0] + if (!node || typeof node === 'string' || node[0] === null) { + return { nodes: parsed, nextIndex: startIndex + 1 } + } + + 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 (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, ...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 }, + } + + return { + nodes: [[element[0], attrs, ...openerChildren, ...body.nodes] as Node], + // Consume the closer as well. + nextIndex: closeIndex + 1, + } } /** @@ -307,7 +444,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 +623,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 +718,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..ff935d45 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,25 +78,45 @@ 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 *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 = '' let isPrevBlock = true 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 } } @@ -106,7 +133,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 : '') 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~ diff --git a/packages/comark/test/html-block.test.ts b/packages/comark/test/html-block.test.ts index c19b8748..3d492e9d 100644 --- a/packages/comark/test/html-block.test.ts +++ b/packages/comark/test/html-block.test.ts @@ -56,18 +56,14 @@ 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**

`) - expect(result.nodes).toEqual([ - ['p', { $: { html: 1, block: 1 } }], - ['p', {}, 'this is ', ['strong', {}, 'markdown']], - ['p', { $: { html: 1, block: 1 } }], - ]) + 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 () => { @@ -88,7 +84,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 +96,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']], + ], ]) })