+**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/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
new file mode 100644
index 00000000..d2746c92
--- /dev/null
+++ b/packages/comark/SPEC/HTML/p-details-inside-details.md
@@ -0,0 +1,103 @@
+## Input
+
+```md
+
+Top
+
+
+Nested
+
+Nested content
+
+Nested content2
+
+
+
+
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "details",
+ {
+ "$": {
+ "html": 1,
+ "block": 1
+ }
+ },
+ [
+ "summary",
+ {
+ "$": {
+ "html": 1,
+ "block": 0
+ }
+ },
+ "Top"
+ ],
+ [
+ "details",
+ {
+ "$": {
+ "html": 1,
+ "block": 1
+ }
+ },
+ [
+ "summary",
+ {
+ "$": {
+ "html": 1,
+ "block": 0
+ }
+ },
+ "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/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
+
+
+
+```
+
+## 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
+



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



+```
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
+







+```
+
+## 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
+







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







+```
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
@@ -46,7 +46,7 @@ A high-performance markdown parser and renderer.
## HTML
```html
-
+
comark
A high-performance markdown parser and renderer.
```
@@ -54,7 +54,7 @@ A high-performance markdown parser and renderer.
## Markdown
```md
-
+
# 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
-
+
```
## 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
-
+
```
## 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
-
+
```
## 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:
-
+
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/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts
index 3fb0ee22..f662957b 100644
--- a/packages/comark/src/internal/parse/auto-close/index.ts
+++ b/packages/comark/src/internal/parse/auto-close/index.ts
@@ -569,6 +569,7 @@ function healInline(text: string, opts: HealOpts): string {
if (next === '$') {
out.push('$')
i++
+
if (opts.math) {
inBlockMath = !inBlockMath
toggle('$$')
diff --git a/packages/comark/src/internal/parse/auto-unwrap.ts b/packages/comark/src/internal/parse/auto-unwrap.ts
index 1a9304d9..20f849e7 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,40 @@ export function applyAutoUnwrap(node: Node): Node {
const [tag, props, ...children] = node
- // Filter out empty text nodes for checking
- const nonEmptyChildren = children.filter((child: Node) => typeof child !== 'string' || (child && child.trim()))
+ // Recurse first so nested HTML wrappers (details → details → p) unwrap bottom-up.
+ const unwrappedChildren = children.map((child: Node) => applyAutoUnwrap(child as Node))
+ const nonEmptyChildren = unwrappedChildren.filter(
+ (child: Node) => typeof child !== 'string' || (child && child.trim())
+ )
- if (nonEmptyChildren.length === 0) {
- return 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
}
- // 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
+ // 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 htmlMeta = (props as Record)?.$
+ if (htmlMeta && typeof htmlMeta === 'object' && htmlMeta.html === 1) {
+ const markdownParagraphs = nonEmptyChildren.filter(isMarkdownParagraph)
+ const othersAreHtml = nonEmptyChildren.every(
+ (c) => isMarkdownParagraph(c) || (Array.isArray(c) && (c[1] as Record)?.$?.html === 1)
+ )
+ if (markdownParagraphs.length === 1 && othersAreHtml) {
+ return [
+ tag,
+ props,
+ ...unwrappedChildren.flatMap((child) => (isMarkdownParagraph(child) ? (child.slice(2) as Node[]) : [child])),
+ ] 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
-
- 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..e83b51d3 100644
--- a/packages/comark/src/internal/parse/html/html_block_rule.ts
+++ b/packages/comark/src/internal/parse/html/html_block_rule.ts
@@ -2,60 +2,111 @@
// 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'
import { HTML_OPEN_CLOSE_TAG_RE } from './html_re.ts'
+const BLANK_LINE = /^$/
+
const HTML_SEQUENCES: [RegExp, RegExp, boolean][] = [
[/^<(script|pre|style|textarea)(?=(\s|>|$))/i, /<\/(script|pre|style|textarea)>/i, true],
[/^/, true],
[/^<\?/, /\?>/, true],
[/^/, true],
[/^/, true],
- [new RegExp(`^?(${block_names.join('|')})(?=(\\s|/?>|$))`, 'i'), /^$/, true],
- [new RegExp(`${HTML_OPEN_CLOSE_TAG_RE.source}\\s*$`), /^$/, false],
+ [new RegExp(`^?(${block_names.join('|')})(?=(\\s|/?>|$))`, 'i'), BLANK_LINE, true],
+ [new RegExp(`${HTML_OPEN_CLOSE_TAG_RE.source}\\s*$`), BLANK_LINE, false],
]
-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]
+/** Open tag name when `line` is a lone start tag (`` / ``), else null. */
+function loneOpenTagName(line: string): string | null {
+ const trimmed = line.trim()
+ if (/\/\s*>\s*$/.test(trimmed)) return null
+ const match = trimmed.match(/^<([a-zA-Z][\w:-]*)(?:\s[^>]*)?>\s*$/)
+ return match ? match[1] : null
+}
- if (state.sCount[startLine] - state.blkIndent >= 4) return false
- if (state.src.charCodeAt(pos) !== 0x3c /* < */) return false
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
- let lineText = state.src.slice(pos, max)
+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 i = 0
- for (; i < HTML_SEQUENCES.length; i++) {
- if (HTML_SEQUENCES[i][0].test(lineText)) break
- }
- if (i === HTML_SEQUENCES.length) return false
+export default function createHtmlBlockRule(options: HtmlBlockRuleOptions = {}) {
+ const allowIncompleteMarkdown = options.markdown !== false
+
+ 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]
- if (silent) return HTML_SEQUENCES[i][2]
+ if (state.sCount[startLine] - state.blkIndent >= 4) return false
+ if (state.src.charCodeAt(pos) !== 0x3c /* < */) return false
- let nextLine = startLine + 1
+ 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
- // Walk forward until the closer regex matches or we hit a blank line.
- if (!HTML_SEQUENCES[i][1].test(lineText)) {
- for (; nextLine < endLine; nextLine++) {
- if (state.sCount[nextLine] < state.blkIndent) break
+ if (silent) return HTML_SEQUENCES[i][2]
- pos = state.bMarks[nextLine] + state.tShift[nextLine]
- max = state.eMarks[nextLine]
- lineText = state.src.slice(pos, max)
+ let nextLine = startLine + 1
- if (HTML_SEQUENCES[i][1].test(lineText)) {
- if (lineText.length !== 0) nextLine++
- break
+ // 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 closer = HTML_SEQUENCES[i][1]
+ const openerTag = allowIncompleteMarkdown && closer === BLANK_LINE ? loneOpenTagName(lineText) : null
+ const matchingClose = openerTag ? new RegExp(`^\\s*${escapeRegExp(openerTag)}\\s*>\\s*$`, 'i') : null
+
+ // Walk forward until the closer regex matches or we hit a blank line.
+ if (!closer.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 (matchingClose?.test(lineText.trim())) sawMatchingClose = true
+
+ if (closer.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 (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/internal/parse/html/index.ts b/packages/comark/src/internal/parse/html/index.ts
index a69864fe..2c103b0c 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,25 +77,62 @@ 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
+
+ meta.block = isRootLevel || children.some(isBlockHtmlElement) ? 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[] = []
const stack: { tag: string; attrs: Record; children: Node[] }[] = []
+ const append = (node: Node) => {
+ if (stack.length > 0) stack[stack.length - 1].children.push(node)
+ else root.push(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) {
- stack[stack.length - 1].children.push(node)
- } else {
- root.push(node)
- }
+ append([name, attrs] as Node)
return
}
stack.push({ tag: name, attrs, children: [] })
@@ -103,18 +140,11 @@ export function htmlToNodes(html: string): Node[] {
ontext(text) {
const trimmed = text.trim()
- if (!trimmed) return
- if (stack.length > 0) {
- stack[stack.length - 1].children.push(trimmed)
- } else {
- root.push(trimmed)
- }
+ if (trimmed) append(trimmed)
},
onclosetag(name) {
- if (VOID_ELEMENTS.has(name)) {
- return
- }
+ if (VOID_ELEMENTS.has(name)) return
// Find matching frame (handles mismatched tags gracefully)
let idx = stack.length - 1
while (idx >= 0 && stack[idx].tag !== name) {
@@ -123,26 +153,17 @@ export function htmlToNodes(html: string): Node[] {
if (idx >= 0) {
while (stack.length > idx) {
const frame = stack.pop()!
- const node =
- frame.children.length > 0
- ? ([frame.tag, frame.attrs, ...frame.children] as Node)
- : ([frame.tag, frame.attrs] as Node)
- if (stack.length > 0) {
- stack[stack.length - 1].children.push(node)
- } else {
- root.push(node)
- }
+ append(
+ (frame.children.length > 0
+ ? [frame.tag, frame.attrs, ...frame.children]
+ : [frame.tag, frame.attrs]) as Node
+ )
}
}
},
oncomment(data) {
- const node = [null, {}, data] as unknown as Node
- if (stack.length > 0) {
- stack[stack.length - 1].children.push(node)
- } else {
- root.push(node)
- }
+ append([null, {}, data] as unknown as Node)
},
},
{ decodeEntities: true }
@@ -151,5 +172,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/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts
index 6e157e26..e9f1e2ad 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
@@ -88,14 +88,110 @@ export function marmdownItTokensToMarkdownDocument(tokens: any[], opts?: TokenPr
return nodes
}
+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(HTML_CLOSE_TAG_RE)
+ return match ? match[1].toLowerCase() : null
+}
+
+/** Unclosed `tag` openers in `content`. Nested same-tag pairs cancel out. */
+function htmlOuterTagDepth(content: string, tag: string): number {
+ const re = new RegExp(`?\\s*${escapeRegExp(tag)}(?![\\w:-])[^>]*>`, '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
+}
+
+/** 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. The whole HTML payload is
- * parsed once by htmlparser2; text inside is preserved verbatim (no markdown
- * re-parsing — CommonMark default).
+ * 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`). 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(tokens: any[], startIndex: number): { nodes: Node[]; nextIndex: number } {
+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 }
+ const tag = htmlOpenTagName(content)
+ const depth = tag && !VOID_ELEMENTS.has(tag) ? htmlOuterTagDepth(content, tag) : 0
+ // Comments, closers, void tags, and already-balanced fragments stay as-is.
+ if (!tag || depth <= 0) {
+ return { nodes: htmlToNodes(content), nextIndex: startIndex + 1 }
+ }
+
+ const closeIndex = findHtmlBlockCloseIndex(tokens, startIndex, tag, depth)
+ 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 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],
+ { ...openerAttrs, $: { ...prevMeta, html: 1, block } },
+ ...(element.slice(2) as Node[]),
+ ...body.nodes,
+ ] as Node,
+ ],
+ nextIndex: closeIndex < 0 ? tokens.length : closeIndex + 1,
+ }
}
/**
@@ -307,7 +403,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 +582,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 +677,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..707390a7 100644
--- a/packages/comark/src/internal/stringify/handlers/html.ts
+++ b/packages/comark/src/internal/stringify/handlers/html.ts
@@ -42,24 +42,29 @@ 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
-
- let oneLiner = isBlock && hasOnlyTextChildren
-
- if (!oneLiner && inlineTags.has(String(tag)) && hasOnlyTextChildren) {
- oneLiner = true
- }
- if (tag === 'pre') {
- oneLiner = true
- }
-
- // If parent is a paragraph, it is inline
- if (parent?.[0] === 'p' || state.context.inline) {
- oneLiner = true
- }
-
- if ($.block === 0) {
- oneLiner = true
- }
+ // 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
+
+ const oneLiner =
+ tag === 'pre' ||
+ parent?.[0] === 'p' ||
+ state.context.inline ||
+ (hasOnlyTextChildren && (isBlock || inlineTags.has(String(tag)))) ||
+ // Inline HTML (`block: 0` with only text/inline children) collapses to one line.
+ // Block wrappers with real block children stay multi-line.
+ ($.block === 0 && !hasBlockChildren)
const isSelfClose = selfCloseTags.has(String(tag))
@@ -71,25 +76,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
}
}
@@ -101,27 +126,26 @@ 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) {
- content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n'
+ if (!state.context.html && bodyStartsWithMarkdown) {
+ // blank line after open so the body re-parses as markdown;
+ content = '\n\n' + content.trimEnd() + '\n\n'
+ } else {
+ content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n'
+ }
}
- return `<${tag}${attrs}>${content}${tag}>` + (!parent && !isInline ? state.context.blockSeparator : '')
+ return `<${tag}${attrs}>${content}${tag}>` + trail
}
-// Literal-content tags whose body must be rendered verbatim (no indentation
-// re-flow). Matches the parser-side set so `