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/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 ``` 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 ``` 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 ``` 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 new file mode 100644 index 00000000..3c1d681b --- /dev/null +++ b/packages/comark/SPEC/HTML/details-inside-details-no-unwrap.md @@ -0,0 +1,98 @@ +--- +options: + autoUnwrap: false +--- + +## Input + +```md +
+Top + +
+Nested + +Nested content + +
+ +
+``` + +## 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" + ] + ] + ] + ] +} +``` + +## 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..24ea7fb0 --- /dev/null +++ b/packages/comark/SPEC/HTML/details-inside-details.md @@ -0,0 +1,88 @@ +## Input + +```md +
+Top + +
+Nested + +Nested content + +
+ +
+``` + +## 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" + ], + "Nested content" + ] + ] + ] +} +``` + +## HTML + +```html +
+ Top +
+ NestedNested 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..5673606f --- /dev/null +++ b/packages/comark/SPEC/HTML/incomplete-block-two-new-line.md @@ -0,0 +1,83 @@ +## Input + +```md + + +**bold** and more + +- list +- **item** +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "ai-thinking", + { + "$": { + "html": 1, + "block": 1 + } + }, + [ + "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..0690a732 --- /dev/null +++ b/packages/comark/SPEC/HTML/incomplete-one-new-line-no-unwrap.md @@ -0,0 +1,58 @@ +--- +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..926de7ff --- /dev/null +++ b/packages/comark/SPEC/HTML/incomplete-one-new-line.md @@ -0,0 +1,43 @@ +## 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/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 +

+ 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/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(`^|$))`, 'i'), /^$/, true], - [new RegExp(`${HTML_OPEN_CLOSE_TAG_RE.source}\\s*$`), /^$/, false], + [new RegExp(`^|$))`, '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*$`, '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(`]*>`, '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}` + (!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 `