= {
+ ...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}${tag}>` + (!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']],
+ ],
])
})