Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
54e6ff0
fix: self-close html5 style, more tests
farnabaz Sep 7, 2026
4d61eaf
chore: cleanup redundant rules
farnabaz Sep 7, 2026
050cedd
fix: block indicator
farnabaz Sep 7, 2026
4277e8c
refactor: token processor
farnabaz Sep 8, 2026
3c6868a
Merge branch 'main' into feat/html-block-2
farnabaz Sep 8, 2026
902942e
chore: drop .only in tests
farnabaz Sep 8, 2026
ecb7344
up
farnabaz Sep 8, 2026
8dd9b22
Merge branch 'main' into feat/html-block-2
farnabaz Sep 8, 2026
1038546
chore: clean up internals
farnabaz Sep 8, 2026
4694464
fix: markdown = false
farnabaz Sep 9, 2026
43733a3
fix: multi tag html block
farnabaz Sep 9, 2026
1fc2d3d
chore: cleanup
farnabaz Sep 9, 2026
b494f6a
fix: improve html plugin
farnabaz Sep 9, 2026
ee8f9c7
fix: improve auto-unwrap perf and memory
farnabaz Sep 9, 2026
1ebabf8
fix: auto-unwrap and visit both have depth limit of 50
farnabaz Sep 9, 2026
9ba3178
fix: attribute parsing
farnabaz Sep 9, 2026
7083758
up
farnabaz Sep 9, 2026
3ac1cca
test: update bundle size snapshot
github-actions[bot] Sep 9, 2026
9139d74
Delete compare-release.ts
farnabaz Sep 9, 2026
19d0ddc
fix: style
farnabaz Sep 9, 2026
c50ef09
Merge branch 'main' into feat/html-block-2
farnabaz Sep 11, 2026
1eec348
fix(html): keep spaces in literal HTML bodies
farnabaz Sep 25, 2026
fa4559b
fix: `Object.hasOwn` error
farnabaz Sep 25, 2026
b9f3dc0
Merge branch 'main' into feat/html-block-2
farnabaz Sep 25, 2026
49d2d65
fix: handle sibling nodes `</div><div>`
farnabaz Sep 25, 2026
ed6c423
fix tests
farnabaz Sep 25, 2026
d9f36be
fix: do not create a closure function on each call
farnabaz Sep 25, 2026
d1d7642
update comark plugin type
farnabaz Sep 25, 2026
2be2d00
test: update bundle size snapshot
github-actions[bot] Sep 25, 2026
6ffaab7
fix(html): keep raw tag bodies verbatim
farnabaz Sep 25, 2026
43c7d5f
refactor: share HTML void elements via comark/utils
farnabaz Sep 25, 2026
c51b426
test: update bundle size snapshot
github-actions[bot] Sep 25, 2026
5773cb2
Merge branch 'main' into feat/html-block-2
farnabaz Oct 2, 2026
a99a99d
fix: scope html stack to markdown containers
farnabaz Oct 2, 2026
e3e79b1
test: update bundle size snapshot
github-actions[bot] Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ packages/comark/
│ │ ├── task-list.ts # GFM task lists
│ │ └── toc.ts # Table of contents
│ ├── utils/ # Shared utilities (comark/utils entry point)
│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), indent(), string/object utils
│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), isHtmlVoidElement(), indent(), string/object utils
│ │ ├── helpers.ts # defineComarkPlugin(), dedupePlugins()
│ │ ├── trace.ts # ComarkTracer helpers: noopTracer, withSpan() (comark/utils/trace)
│ │ └── caret.ts # Caret utilities for streaming
Expand Down Expand Up @@ -481,7 +481,15 @@ import { renderMarkdown } from 'comark/render'

// Document model types and utilities
import type { MarkdownDocument, Node, ElementNode, TextNode, CommentNode } from 'comark'
import { textContent, visit, visitAsync, escapeHtml, isMarkdownDocument } from 'comark/utils'
import {
textContent,
visit,
visitAsync,
escapeHtml,
isHtmlVoidElement,
HTML_VOID_ELEMENTS,
isMarkdownDocument,
} from 'comark/utils'
import { noopTracer, withSpan } from 'comark/utils/trace'

// Core plugins — use when calling parseMarkdown() directly (framework-agnostic)
Expand Down Expand Up @@ -747,6 +755,16 @@ SPEC coverage: `SPEC/COMARK/component-nested-*-outdented.md`,
`SPEC/COMARK/component-nested-codeblock-indented.md`,
`SPEC/COMARK/codeblock-indented-content.md`.

## Embedded HTML

Contract: `packages/comark/SPEC/HTML/README.md`. HTML nodes carry
`$: { html: 1, block: 0 | 1 }`. `block` means the tag outlived the paragraph
that opened it, not that the tag is a block element. `html({ markdown: false })`
keeps closed-block text literal; a blank line still nests real markdown under
the open tag. `<style>`, `<pre>`, `<script>`, and `<textarea>` are always verbatim, including newlines and inner tags. Deferred cases (cross-paragraph inline
opener, trailing text after that closer, indented child tag inside a multiline
`<a>`) are skipped tests in `test/html-block.test.ts` — do not weaken them.

## Vue/React/Svelte/Angular Components

### Markdown Component (High-level)
Expand Down
25 changes: 23 additions & 2 deletions docs/content/2.syntax/1.markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,32 @@ Comark supports all standard CommonMark and GitHub Flavored Markdown (GFM) featu
###### Heading 6
```

All headings automatically get ID attributes generated from their content for linking:
All headings automatically get an `id` from their visible text, for linking. Set [`headingIds: false`](/reference/parse#options) to skip generation. An explicit `{id="..."}` replaces the generated id.

```mdc
# Hello World
<!-- Becomes: <h1 id="hello-world">Hello World</h1> -->
<!-- <h1 id="hello-world">Hello World</h1> -->

## 1. Introduction
<!-- <h2 id="_1-introduction">1. Introduction</h2> -->

## The `parse` function
<!-- <h2 id="the-parse-function">The parse function</h2> -->
```

The id is the heading's visible text, lowercased, with spaces turned into `-` and other punctuation removed. A leading digit is prefixed with `_`. `**bold**`, `` `code` ``, and link labels contribute their text, not the tag name. An inline component name is omitted (`## Star :icon-star here` → `star-here`).

Two headings with the same id get a numeric suffix: the second `## Options` is `options-1`.

From `h3` down, the id is prefixed with the nearest `h2` or deeper ancestor, not the `h1`:

```md
# Guide

## Ending space

### Slash in title
<!-- id="ending-space-slash-in-title" -->
```

## Text formatting
Expand Down
62 changes: 57 additions & 5 deletions docs/content/4.plugins/0.defaults/html.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,64 @@ const result = await parseMarkdown(content, { registerDefaultPlugins: false })
`ParserOptions.html` is **deprecated** and logs a warning. Prefer `registerDefaultPlugins: false` (and register `html()` only when you need it). `html: false` still skips the default html plugin for compatibility.
::

## Options

```typescript
import html from 'comark/plugins/html'

await parseMarkdown(content, {
plugins: [html({ markdown: false })],
})
```

| Option | Default | Description |
| --- | --- | --- |
| `markdown` | `true` | Expand text inside closed HTML as inline markdown. `false` keeps that text literal. |

`markdown: false` does not turn blank-line bodies into literal text. A blank line ends a CommonMark HTML block, so the following paragraphs are normal markdown and nest under the still-open tag either way.

```mdc
<!-- markdown: true (default) — ** becomes strong -->
<div>
Hello **World**
</div>

<!-- markdown: false — body stays the text "Hello **World**" -->
<div>
Hello **World**
</div>

<!-- both modes — the blank line already ended the HTML block -->
<div>

Hello **World**

</div>
```

`<style>`, `<pre>`, `<script>`, and `<textarea>` are never re-parsed as markdown, including when they have attributes. Each body keeps every character between the tags — newlines, indentation, and inner tags (`a < b`, `<div>**no**</div>`) stay text.

Spaces beside a tag are content. `<p>Hello <em>x</em> <a>two</a></p>` keeps the space after `Hello` and the space between `</em>` and `<a>`, including when `markdown` is `false`.

## Block and inline

Every HTML node is marked `$: { html: 1, block: 0 | 1 }` so renderers can tell it apart from an element that came from markdown (`**bold**` → `['strong', {}, 'bold']`, no `$`).

`block` is not "`div` is a block tag." It records whether the tag outlived the paragraph that opened it.

| `block` | Meaning |
| --- | --- |
| `0` | Opened and closed inside one paragraph. `<em>x</em>`, and each `<p>…</p>` in a tight run of sibling tags. |
| `1` | Still open after that paragraph, or unwrapped because it was the only thing in it. `<div>` across lines, an unclosed `<ai-thinking>`. |

Void tags (`img`, `br`, …) are always `block: 0`.

## How it works

The plugin:
1. Enables markdown-exit HTML tokenization (`html: true`).
2. With `markdown: true`, narrows the built-in `html_block` rule to `<style>`, `<pre>`, `<script>`, and `<textarea>`, so other closed HTML is tokenized as normal markdown (`**`, code spans, nested tags).
3. Splits any remaining raw HTML block into tags and literal text, then pairs open/close tags across paragraphs with one HTML stack.

1. Calls `md.set({ html: true })` so markdown-exit's built-in HTML gates are open
2. Registers Comark's HTML block and inline ruler rules (`comark_html_block`, `comark_html_inline`)
3. Leaves token → AST conversion to the core token processor (`htmlparser2`)
A matching close tag collects every block parsed while it was open. `</div><div>` emits two sibling elements, not a `div` nested inside the next `div`.

HTML nodes are marked with `$: { html: 1, block: 0 | 1 }` so renderers can distinguish them from markdown-originated elements.
Not supported yet: an opener and its closer split by a blank line when the opener sat mid-paragraph (`before <div>` … blank line … `</div> after`), and an indented child tag inside a multiline inline element (`<a>` → indented `<img>` → `</a>`). Put the close tag on its own line in the same block, or separate body paragraphs with a blank line inside a block container (`<div>`, `<main>`).
2 changes: 1 addition & 1 deletion docs/content/5.reference/1.parse.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ Both `parseMarkdown()` and `createMarkdownParser()` accept the same `ParserOptio
| `unwrap` | `boolean \| string \| string[]` | `false` | Remove wrapper tags from the tree, hoisting their children (MDC `unwrap` behaviour). `true` unwraps `p`; a comma/whitespace-separated string or array unwraps the listed tags; `'*'` matches any tag. Tags apply sequentially (each descends one level), and adjacent text is merged into a single string. |
| `html` | `boolean` | `true` | **Deprecated** (warns). Prefer `registerDefaultPlugins: false` and register `html()` explicitly. `html: false` still skips the default html plugin. |
| `linkify` | `boolean` | `true` | Auto-convert URL-like text into links. Set `false` to disable |
| `headingIds` | `boolean` | `true` | Auto-generate `id` attributes for `h1`–`h6` headings. Unicode letters, marks, and numbers are kept (`## Café` → `café`); punctuation and symbols are dropped, and a heading that slugifies to nothing gets no id. Set `false` to disable. |
| `headingIds` | `boolean` | `true` | Auto-generate `id` attributes for `h1`–`h6` from visible heading text (not tag or component names). Unicode letters, marks, and numbers are kept (`## Café` → `café`); punctuation and symbols are dropped, and a heading that slugifies to nothing gets no id. An explicit `id` attribute wins. Set `false` to disable. See [Headings](/syntax/markdown#headings). |
| `registerDefaultPlugins` | `boolean` | `true` | Register the built-in default plugins (`frontmatter`, `html`, `alert`, `task-list`, `components`, `attributes`). Set `false` to disable them. |
| `plugins` | `ComarkPlugin[]` | `[]` | Ordered plugins to run after the defaults. A same-name plugin replaces its default; duplicate explicit names keep the first instance. See [Default plugins](/plugins#default-plugins). |
| `tracer` | `ComarkTracer` | `undefined` | Timing recorder for the parse pipeline — see [Timing the parse](#timing-the-parse) |
Expand Down
2 changes: 1 addition & 1 deletion docs/content/5.reference/3.reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ interface ParserOptions {
/** @deprecated Prefer registerDefaultPlugins: false */
html?: boolean // default: true
linkify?: boolean // default: true
headingIds?: boolean // default: true; keeps Unicode letters/numbers, omits symbol-only ids
headingIds?: boolean // default: true — slug from visible text (keeps Unicode letters/numbers, omits symbol-only ids); explicit id wins
registerDefaultPlugins?: boolean // default: true
plugins?: ComarkPlugin[]
tracer?: ComarkTracer // OpenTelemetry-style tracer for parse spans
Expand Down
2 changes: 1 addition & 1 deletion docs/skills/comark/references/markdown-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Comark supports all standard CommonMark and GitHub Flavored Markdown (GFM) featu
###### Heading 6
```

**Note:** All headings automatically get ID attributes generated from their content for linking (e.g., `# Hello World` becomes `<h1 id="hello-world">`). Unicode letters, marks, and numbers are kept (`## Café` → `café`); punctuation and symbols are stripped, and a heading that slugifies to nothing gets no id. Set `headingIds: false` in parse options to disable auto-generated ids.
**Note:** All headings automatically get an `id` from their visible text (e.g., `# Hello World` becomes `<h1 id="hello-world">`). Unicode letters, marks, and numbers are kept (`## Café` → `café`); punctuation and symbols are stripped, and a heading that slugifies to nothing gets no id. A leading digit is prefixed with `_` (`## 1. Introduction` → `_1-introduction`), and a duplicate gets a numeric suffix (`options`, `options-1`). From `h3` down, the id is prefixed with the nearest `h2`+ ancestor, not the `h1`. Inline marks and link labels contribute their text; an inline component name is omitted (`:icon-star` does not appear in the slug). An explicit `{id="..."}` replaces the generated id. Set `headingIds: false` to disable generation; a user-supplied `id` is still kept.

### Text Formatting

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '@angular/core'
import type { ElementNode, Node as MarkdownAstNode, NodeRenderData } from 'comark'
import { resolveIfWrapper, selectIfBranch, shouldRenderIf, type IfProps } from 'comark/plugins/binding'
import { pascalCase, resolveAttributes, toNativeAttributes } from 'comark/utils'
import { isHtmlVoidElement, pascalCase, resolveAttributes, toNativeAttributes } from 'comark/utils'

interface StructuralComponent extends Type<any> {
ɵcomarkIf?: boolean
Expand Down Expand Up @@ -62,24 +62,6 @@ function resolveComponent(tag: string, components: Record<string, Type<any>>): T
return components[proseTag] || components[pascalTag] || components[tag]
}

/** Void (self-closing) HTML elements that must not have children. */
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr',
])

/**
* MarkdownNode - recursive component that renders a single Comark AST node.
*
Expand Down Expand Up @@ -204,7 +186,7 @@ export class MarkdownNode implements OnChanges {

// `innerHTML` from document attributes is never applied — resolveAttributes
// drops DOM sink props, and raw HTML has its own explicit parse path.
if (!VOID_ELEMENTS.has(tag)) {
if (!isHtmlVoidElement(tag)) {
this.renderChildren(el, children, childrenRenderData)
}

Expand Down
9 changes: 2 additions & 7 deletions packages/comark-svelte/src/components/MarkdownNode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ naturally appears inline after the deepest trailing text node.
import MarkdownNode from './MarkdownNode.svelte'
import ComarkComponent from './ComarkComponent.svelte'
import Resolve from './Resolve.svelte'
import { resolveAttributes, toNativeAttributes } from 'comark/utils'
import { isHtmlVoidElement, resolveAttributes, toNativeAttributes } from 'comark/utils'

const EMPTY_RENDER_DATA: NodeRenderData = { frontmatter: {}, meta: {}, data: {}, props: {} }

Expand All @@ -96,11 +96,6 @@ naturally appears inline after the deepest trailing text node.
const CARET_STYLE
= 'background-color: currentColor; display: inline-block; margin-left: 0.25rem; margin-right: 0.25rem; animation: pulse 0.75s cubic-bezier(0.4,0,0.6,1) infinite;'

const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr',
])

interface RenderChild {
node: NodeType
caretClass: string | null
Expand Down Expand Up @@ -166,7 +161,7 @@ naturally appears inline after the deepest trailing text node.
}

tag = node[0] as string
isVoid = VOID_ELEMENTS.has(tag)
isVoid = isHtmlVoidElement(tag)
const nodeProps: Record<string, any>
= (node.length >= 2 ? node[1] : {}) ?? {}
children = node.length > 2 ? (node.slice(2) as NodeType[]) : []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ full-width: true
"svg",
{
"$": {
"block": 0,
"block": 1,
"html": 1
},
"width": "10"
Expand All @@ -99,7 +99,7 @@ full-width: true
"svg",
{
"$": {
"block": 0,
"block": 1,
"html": 1
},
"width": "10"
Expand All @@ -115,7 +115,7 @@ full-width: true
"svg",
{
"$": {
"block": 0,
"block": 1,
"html": 1
},
"width": "10"
Expand All @@ -131,7 +131,7 @@ full-width: true
"svg",
{
"$": {
"block": 0,
"block": 1,
"html": 1
},
"width": "10"
Expand All @@ -147,7 +147,7 @@ full-width: true
"svg",
{
"$": {
"block": 0,
"block": 1,
"html": 1
},
"width": "10"
Expand Down
Loading