diff --git a/packages/comark/SPEC/COMARK/codeblock-trailing-blank-lines.md b/packages/comark/SPEC/COMARK/codeblock-trailing-blank-lines.md
new file mode 100644
index 000000000..e2951491f
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/codeblock-trailing-blank-lines.md
@@ -0,0 +1,48 @@
+## Input
+
+```md
+```txt
+line
+
+```
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "pre",
+ {
+ "language": "txt"
+ },
+ [
+ "code",
+ {
+ "class": "language-txt"
+ },
+ "line\n"
+ ]
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
line
+
+```
+
+## Markdown
+
+```md
+```txt
+line
+
+```
+```
diff --git a/packages/comark/SPEC/COMARK/codeblock-trailing-whitespace.md b/packages/comark/SPEC/COMARK/codeblock-trailing-whitespace.md
new file mode 100644
index 000000000..3947463b5
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/codeblock-trailing-whitespace.md
@@ -0,0 +1,48 @@
+## Input
+
+```md
+```txt
+line
+
+```
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "pre",
+ {
+ "language": "txt"
+ },
+ [
+ "code",
+ {
+ "class": "language-txt"
+ },
+ "line \n "
+ ]
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+line
+
+```
+
+## Markdown
+
+```md
+```txt
+line
+
+```
+```
diff --git a/packages/comark/SPEC/COMARK/component-name-comment.md b/packages/comark/SPEC/COMARK/component-name-comment.md
new file mode 100644
index 000000000..43fd79e59
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/component-name-comment.md
@@ -0,0 +1,59 @@
+## Input
+
+```md
+Some :comment[a note]{author="maxi"} text.
+
+::comment
+body
+::
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "p",
+ {},
+ "Some ",
+ [
+ "comment",
+ {
+ "author": "maxi"
+ },
+ "a note"
+ ],
+ " text."
+ ],
+ [
+ "comment",
+ {},
+ "body"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+ Some a note text.
+
+
+ body
+
+```
+
+## Markdown
+
+```md
+Some :comment[a note]{author="maxi"} text.
+
+::comment
+body
+::
+```
diff --git a/packages/comark/SPEC/COMARK/component-name-math.md b/packages/comark/SPEC/COMARK/component-name-math.md
new file mode 100644
index 000000000..cebcc0677
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/component-name-math.md
@@ -0,0 +1,59 @@
+## Input
+
+```md
+Some :math[x] text.
+
+::math{k="v"}
+body
+::
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "p",
+ {},
+ "Some ",
+ [
+ "math",
+ {},
+ "x"
+ ],
+ " text."
+ ],
+ [
+ "math",
+ {
+ "k": "v"
+ },
+ "body"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+ Some text.
+
+
+```
+
+## Markdown
+
+```md
+Some :math[x] text.
+
+::math{k="v"}
+body
+::
+```
diff --git a/packages/comark/SPEC/COMARK/component-name-mermaid.md b/packages/comark/SPEC/COMARK/component-name-mermaid.md
new file mode 100644
index 000000000..69c852ca6
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/component-name-mermaid.md
@@ -0,0 +1,57 @@
+## Input
+
+```md
+Some :mermaid[x] text.
+
+::mermaid
+body
+::
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "p",
+ {},
+ "Some ",
+ [
+ "mermaid",
+ {},
+ "x"
+ ],
+ " text."
+ ],
+ [
+ "mermaid",
+ {},
+ "body"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+ Some x text.
+
+
+ body
+
+```
+
+## Markdown
+
+```md
+Some :mermaid[x] text.
+
+::mermaid
+body
+::
+```
diff --git a/packages/comark/SPEC/COMARK/component-name-table.md b/packages/comark/SPEC/COMARK/component-name-table.md
new file mode 100644
index 000000000..5b392145c
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/component-name-table.md
@@ -0,0 +1,56 @@
+## Input
+
+```md
+::table{src="data.csv"}
+::
+
+Some :table[x] text.
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "table",
+ {
+ "src": "data.csv"
+ }
+ ],
+ [
+ "p",
+ {},
+ "Some ",
+ [
+ "table",
+ {},
+ "x"
+ ],
+ " text."
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+
+ Some
+
+ text.
+
+```
+
+## Markdown
+
+```md
+::table{src="data.csv"}
+::
+
+Some :table[x] text.
+```
diff --git a/packages/comark/SPEC/COMARK/component-name-template.md b/packages/comark/SPEC/COMARK/component-name-template.md
new file mode 100644
index 000000000..e312e30aa
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/component-name-template.md
@@ -0,0 +1,59 @@
+## Input
+
+```md
+Some :template[x] text.
+
+::template{k="v"}
+body
+::
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "p",
+ {},
+ "Some ",
+ [
+ "template",
+ {},
+ "x"
+ ],
+ " text."
+ ],
+ [
+ "template",
+ {
+ "k": "v"
+ },
+ "body"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+ Some x text.
+
+
+ body
+
+```
+
+## Markdown
+
+```md
+Some :template[x] text.
+
+::template{k="v"}
+body
+::
+```
diff --git a/packages/comark/SPEC/COMARK/shiki-codeblock-twoslash.md b/packages/comark/SPEC/COMARK/shiki-codeblock-twoslash.md
index 8eecfc2c8..29e1fa6d0 100644
--- a/packages/comark/SPEC/COMARK/shiki-codeblock-twoslash.md
+++ b/packages/comark/SPEC/COMARK/shiki-codeblock-twoslash.md
@@ -160,5 +160,6 @@ const message = "Hello from twoslash"
```md
```ts twoslash
const const message: "Hello from twoslash"message = "Hello from twoslash"
+
```
```
diff --git a/packages/comark/SPEC/GFM/task-list-loose.md b/packages/comark/SPEC/GFM/task-list-loose.md
new file mode 100644
index 000000000..24ea7977d
--- /dev/null
+++ b/packages/comark/SPEC/GFM/task-list-loose.md
@@ -0,0 +1,108 @@
+## Input
+
+```md
+- [ ] Option one
+
+ > Why this is wrong.
+
+- [x] Option two
+
+ More about it.
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "ul",
+ {
+ "class": "contains-task-list"
+ },
+ [
+ "li",
+ {
+ "class": "task-list-item"
+ },
+ [
+ "p",
+ {},
+ [
+ "input",
+ {
+ "class": "task-list-item-checkbox",
+ "type": "checkbox",
+ ":disabled": "true"
+ }
+ ],
+ " Option one"
+ ],
+ [
+ "blockquote",
+ {},
+ "Why this is wrong."
+ ]
+ ],
+ [
+ "li",
+ {
+ "class": "task-list-item"
+ },
+ [
+ "p",
+ {},
+ [
+ "input",
+ {
+ "class": "task-list-item-checkbox",
+ "type": "checkbox",
+ ":disabled": "true",
+ ":checked": "true"
+ }
+ ],
+ " Option two"
+ ],
+ [
+ "p",
+ {},
+ "More about it."
+ ]
+ ]
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+```
+
+## Markdown
+
+```md
+- [ ] Option one
+ > Why this is wrong.
+- [x] Option two
+
+ More about it.
+```
diff --git a/packages/comark/SPEC/GFM/task-list-nested-blocks.md b/packages/comark/SPEC/GFM/task-list-nested-blocks.md
new file mode 100644
index 000000000..0ab676b87
--- /dev/null
+++ b/packages/comark/SPEC/GFM/task-list-nested-blocks.md
@@ -0,0 +1,116 @@
+## Input
+
+```md
+- [ ] Option one
+ > Why this is wrong.
+- [x] Option two
+ ```js
+ x
+ ```
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "ul",
+ {
+ "class": "contains-task-list"
+ },
+ [
+ "li",
+ {
+ "class": "task-list-item"
+ },
+ [
+ "p",
+ {},
+ [
+ "input",
+ {
+ "class": "task-list-item-checkbox",
+ "type": "checkbox",
+ ":disabled": "true"
+ }
+ ],
+ " Option one"
+ ],
+ [
+ "blockquote",
+ {},
+ "Why this is wrong."
+ ]
+ ],
+ [
+ "li",
+ {
+ "class": "task-list-item"
+ },
+ [
+ "p",
+ {},
+ [
+ "input",
+ {
+ "class": "task-list-item-checkbox",
+ "type": "checkbox",
+ ":disabled": "true",
+ ":checked": "true"
+ }
+ ],
+ " Option two"
+ ],
+ [
+ "pre",
+ {
+ "language": "js"
+ },
+ [
+ "code",
+ {
+ "class": "language-js"
+ },
+ "x"
+ ]
+ ]
+ ]
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+```
+
+## Markdown
+
+```md
+- [ ] Option one
+ > Why this is wrong.
+- [x] Option two
+ ```js
+ x
+ ```
+```
diff --git a/packages/comark/src/internal/stringify/handlers/comment.ts b/packages/comark/src/internal/stringify/handlers/comment.ts
index 8177dae87..a59e3e3c9 100644
--- a/packages/comark/src/internal/stringify/handlers/comment.ts
+++ b/packages/comark/src/internal/stringify/handlers/comment.ts
@@ -1,10 +1,15 @@
import type { State } from 'comark/render'
import type { ElementNode } from 'comark'
-export function comment(node: ElementNode, _state: State) {
+export function comment(node: ElementNode, state: State, parent?: ElementNode) {
if (node[0] === null) {
- return `` + _state.context.blockSeparator
+ return `` + state.context.blockSeparator
}
- return ''
+ // Comments have a `null` tag; an element named `comment` is a component.
+ // (Not via `./component.ts`: `comark/render/html` imports this file and must
+ // stay free of the other handlers. It never reaches here with an element.)
+ return state.context.format === 'markdown/comark'
+ ? state.handlers.mdc(node, state, parent)
+ : state.handlers.html(node, state, parent)
}
diff --git a/packages/comark/src/internal/stringify/handlers/component.ts b/packages/comark/src/internal/stringify/handlers/component.ts
new file mode 100644
index 000000000..d6246aeea
--- /dev/null
+++ b/packages/comark/src/internal/stringify/handlers/component.ts
@@ -0,0 +1,17 @@
+import type { State } from 'comark/render'
+import type { ElementNode } from 'comark'
+
+/**
+ * Render a node the way a tag with no handler is rendered: as a Comark
+ * component (`:name[…]` / `::name`), or as HTML in other formats.
+ *
+ * Built-in handlers whose tag can also be a component name (`table`, `math`,
+ * `template`, …) call this when the node does not have the shape the parser
+ * gives their own syntax, so `::table{src="data.csv"}` is not written as an
+ * empty Markdown table.
+ */
+export function component(node: ElementNode, state: State, parent?: ElementNode): string | Promise {
+ return state.context.format === 'markdown/comark'
+ ? state.handlers.mdc(node, state, parent)
+ : state.handlers.html(node, state, parent)
+}
diff --git a/packages/comark/src/internal/stringify/handlers/li.ts b/packages/comark/src/internal/stringify/handlers/li.ts
index 70aae9979..90651b729 100644
--- a/packages/comark/src/internal/stringify/handlers/li.ts
+++ b/packages/comark/src/internal/stringify/handlers/li.ts
@@ -18,15 +18,17 @@ export async function li(node: ElementNode, state: State) {
? node[1].className.join(' ')
: String(node[1].className || node[1].class)
- const taskList = className.includes('task-list-item')
+ // Continuation lines align with the item's content, which starts after the
+ // list marker: the `[ ] ` checkbox is part of that content, not the marker.
+ const prefixWidth = prefix.length
- if (taskList) {
- const input = children.shift() as ElementNode
- prefix += input[1].checked || input[1][':checked'] ? '[x] ' : '[ ] '
+ if (className.includes('task-list-item')) {
+ const input = takeCheckbox(children)
+ if (input) {
+ prefix += input[1].checked || input[1][':checked'] ? '[x] ' : '[ ] '
+ }
}
- const prefixWidth = prefix.length
-
// Direct text children render sibling components inline.
const hasInlineContent = children.some((child) => typeof child === 'string')
@@ -68,3 +70,29 @@ export async function li(node: ElementNode, state: State) {
return `${prefix}${result}${suffix}\n`
}
+
+function isCheckbox(node: Node | undefined): node is ElementNode {
+ return Array.isArray(node) && node[0] === 'input'
+}
+
+/**
+ * Remove the task checkbox from `children` and return it. In a tight list the
+ * checkbox is the item's first child; in a loose list it opens the item's
+ * first paragraph, which is replaced by a copy without it so the node passed
+ * in is left untouched.
+ */
+function takeCheckbox(children: Node[]): ElementNode | undefined {
+ const first = children[0]
+ if (isCheckbox(first)) {
+ children.shift()
+ return first
+ }
+ if (Array.isArray(first) && first[0] === 'p') {
+ const [tag, attrs, checkbox, ...rest] = first as ElementNode
+ if (isCheckbox(checkbox)) {
+ children[0] = [tag, attrs, ...rest]
+ return checkbox
+ }
+ }
+ return undefined
+}
diff --git a/packages/comark/src/internal/stringify/handlers/math.ts b/packages/comark/src/internal/stringify/handlers/math.ts
index 36f126e23..2c5aa6d00 100644
--- a/packages/comark/src/internal/stringify/handlers/math.ts
+++ b/packages/comark/src/internal/stringify/handlers/math.ts
@@ -1,11 +1,18 @@
import type { State } from 'comark/render'
import type { ElementNode } from 'comark'
import { textContent } from '../../../utils/index.ts'
+import { component } from './component.ts'
-export function math(node: ElementNode, state: State, parent?: ElementNode): string {
- const content = textContent(node)
+export function math(node: ElementNode, state: State, parent?: ElementNode): string | Promise {
const className = node[1].class
const classes = typeof className === 'string' ? className.split(' ') : []
+ // The math plugin always sets `class="math …"` and `content`, which the Math
+ // components also need. With neither, this is a `:math` component.
+ if (!classes.includes('math') && typeof node[1].content !== 'string') {
+ return component(node, state, parent)
+ }
+
+ const content = textContent(node)
const hasInlineClass = classes.includes('inline')
const hasBlockClass = classes.includes('block')
const hasInlineSiblings = parent?.some((child, index) => index > 1 && typeof child === 'string') ?? false
diff --git a/packages/comark/src/internal/stringify/handlers/mdc.ts b/packages/comark/src/internal/stringify/handlers/mdc.ts
index 63622fb00..faacea673 100644
--- a/packages/comark/src/internal/stringify/handlers/mdc.ts
+++ b/packages/comark/src/internal/stringify/handlers/mdc.ts
@@ -2,7 +2,6 @@ import type { State } from 'comark/render'
import type { ElementNode, Node } from 'comark'
import { indent } from '../../../utils/index.ts'
import { comarkAttributes, comarkYamlAttributes } from '../attributes.ts'
-import { html } from './html.ts'
// HTML elements that always create an inline context for their children
const INLINE_HTML_ELEMENTS = new Set(['a', 'strong', 'em', 'span'])
@@ -11,10 +10,6 @@ export async function mdc(node: ElementNode, state: State, parent?: ElementNode)
const [tag, attr, ...children] = node
const { $: _, ...attributes } = attr
- if (tag === 'table') {
- return html(node, state)
- }
-
const attributeEntries = Object.entries(attributes)
const hasObjectAttributes = attributeEntries.some(([, value]) => typeof value === 'object')
diff --git a/packages/comark/src/internal/stringify/handlers/mermaid.ts b/packages/comark/src/internal/stringify/handlers/mermaid.ts
index c93ff4580..b430c971f 100644
--- a/packages/comark/src/internal/stringify/handlers/mermaid.ts
+++ b/packages/comark/src/internal/stringify/handlers/mermaid.ts
@@ -2,10 +2,16 @@ import type { State } from 'comark/render'
import type { ElementNode } from 'comark'
import { comarkAttributes } from '../attributes.ts'
import { pickFence } from '../fence.ts'
+import { component } from './component.ts'
-export function mermaid(node: ElementNode, state: State) {
+export function mermaid(node: ElementNode, state: State, parent?: ElementNode): string | Promise {
const [_, attributes] = node
+ // The mermaid plugin always sets `content`; without it this is a `:mermaid` component.
+ if (typeof attributes.content !== 'string') {
+ return component(node, state, parent)
+ }
+
const { content, ...rest } = attributes
const attrs = comarkAttributes(rest)
diff --git a/packages/comark/src/internal/stringify/handlers/pre.ts b/packages/comark/src/internal/stringify/handlers/pre.ts
index 6b3d5c80e..dce8e3ec6 100644
--- a/packages/comark/src/internal/stringify/handlers/pre.ts
+++ b/packages/comark/src/internal/stringify/handlers/pre.ts
@@ -25,7 +25,9 @@ export function pre(node: ElementNode, state: State) {
// Meta always has a leading space
const meta = attributes.meta ? ' ' + attributes.meta : ''
- const code = String(node[1]?.code || textContent(node)).trimEnd()
+ // The parser drops only the newline before the closing fence, so trailing
+ // blank lines and spaces are part of the code and must be written back.
+ const code = String(node[1]?.code || textContent(node))
const fence = pickFence(code)
const fenceBlock = fence + language + filename + highlights + meta + '\n' + code + '\n' + fence
diff --git a/packages/comark/src/internal/stringify/handlers/table.ts b/packages/comark/src/internal/stringify/handlers/table.ts
index 9c6d54171..e5e991554 100644
--- a/packages/comark/src/internal/stringify/handlers/table.ts
+++ b/packages/comark/src/internal/stringify/handlers/table.ts
@@ -1,9 +1,12 @@
import type { State } from 'comark/render'
import type { ElementNode, Node } from 'comark'
import { comarkAttributes, userBlockAttrs } from '../attributes.ts'
+import { component } from './component.ts'
type Alignment = 'left' | 'center' | 'right' | null
+const TABLE_SECTIONS = new Set(['thead', 'tbody', 'tr'])
+
// Helper function to extract alignment from style attribute
function getAlignment(attributes: Record): Alignment {
const style = attributes.style
@@ -82,9 +85,14 @@ function getCells(row: ElementNode): ElementNode[] {
) as ElementNode[]
}
-export async function table(node: ElementNode, state: State) {
+export async function table(node: ElementNode, state: State, parent?: ElementNode) {
const [, , ...children] = node
+ // A parsed table always has rows; without them this is a `::table` component.
+ if (!children.some((child) => Array.isArray(child) && TABLE_SECTIONS.has(child[0] as string))) {
+ return component(node, state, parent)
+ }
+
// Extract thead and tbody
let headerRows: ElementNode[] = []
let bodyRows: ElementNode[] = []
diff --git a/packages/comark/src/internal/stringify/handlers/template.ts b/packages/comark/src/internal/stringify/handlers/template.ts
index 924a72947..dc5833f6d 100644
--- a/packages/comark/src/internal/stringify/handlers/template.ts
+++ b/packages/comark/src/internal/stringify/handlers/template.ts
@@ -1,11 +1,17 @@
import type { State } from 'comark/render'
import type { ElementNode, Node } from 'comark'
import { comarkAttributes } from '../attributes.ts'
+import { component } from './component.ts'
// slot template
export async function template(node: ElementNode, state: State, parent?: ElementNode) {
const [_, attrs] = node
+ // Parsed slots always carry a `name`; without one this is a `:template` component.
+ if (typeof attrs.name !== 'string') {
+ return component(node, state, parent)
+ }
+
const content = (await state.flow(node, state)).trimEnd()
// Omit #default marker if this is the only slot
diff --git a/packages/comark/test/math.test.ts b/packages/comark/test/math.test.ts
index 04d2e1c4c..73bf8ad5e 100644
--- a/packages/comark/test/math.test.ts
+++ b/packages/comark/test/math.test.ts
@@ -26,12 +26,19 @@ Inline $E = mc^2$ end.`
const document: MarkdownDocument = {
frontmatter: {},
meta: {},
- nodes: [['p', {}, 'Before ', ['math', {}, 'x^2'], ' after']],
+ nodes: [['p', {}, 'Before ', ['math', { content: 'x^2' }, 'x^2'], ' after']],
}
expect(await renderMarkdown(document)).toBe('Before $x^2$ after')
})
+ it('writes a `math` node without math class or content as a component', async () => {
+ const source = 'Some :math[x] text.\n\n::math{k="v"}\nbody\n::'
+ const document = await parseMarkdown(source)
+
+ expect(await renderMarkdown(document)).toBe(source)
+ })
+
it('keeps explicitly block math as a block inside a parent', async () => {
const source = '> $$\n> x^2\n> $$'
const document = await parseMarkdown(source, { plugins: [math()] })