diff --git a/AGENTS.md b/AGENTS.md
index 8e629188..de5caaeb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,6 +93,7 @@ packages/comark/
│ └── internal/ # Internal implementation (not exported)
│ ├── shiki.ts # Shared Shiki runtime used by both entry points
│ ├── frontmatter.ts # parseFrontmatter() / renderFrontmatter()
+│ ├── heading-id.ts # Auto heading id generation (shared by parser + stringifier)
│ ├── yaml.ts # YAML helpers
│ ├── props-validation.ts # Component props validation
│ ├── parse/ # Parsing pipeline
diff --git a/docs/content/2.syntax/1.markdown.md b/docs/content/2.syntax/1.markdown.md
index c6109952..6bc30dc1 100644
--- a/docs/content/2.syntax/1.markdown.md
+++ b/docs/content/2.syntax/1.markdown.md
@@ -44,9 +44,23 @@ All headings automatically get an `id` from their visible text, for linking. Set
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`.
+Ids are unique within a document. A heading whose id is already taken gets a numeric suffix, at any level and in any section:
-From `h3` down, the id is prefixed with the nearest `h2` or deeper ancestor, not the `h1`:
+```md
+## Npm
+### Install
+
+
+## Yarn
+### Install
+
+
+## Options
+## Options
+
+```
+
+Ids are flat by default. Set `headingIds: 'nested'` in the [parse options](/reference/parse#options) to prefix each id from `h3` down with the nearest `h2` or deeper ancestor (never the `h1`):
```md
# Guide
@@ -54,7 +68,7 @@ From `h3` down, the id is prefixed with the nearest `h2` or deeper ancestor, not
## Ending space
### Slash in title
-
+
```
## Text formatting
diff --git a/docs/content/2.syntax/3.attributes.md b/docs/content/2.syntax/3.attributes.md
index a80a0c50..a87b71b4 100644
--- a/docs/content/2.syntax/3.attributes.md
+++ b/docs/content/2.syntax/3.attributes.md
@@ -219,6 +219,18 @@ A paragraph {attr="value"}
```
::
+A `{#id}` overrides the auto-generated id. When rendering back to markdown, the id is written out only if it differs from the auto-generated one, so custom ids survive a round trip:
+
+::code-group
+```mdc [Syntax]
+# Product Documentation {#prod-doc}
+```
+
+```html [Output]
+
Product Documentation
+```
+::
+
### List items
Bullet, ordered, and task-list items all accept trailing attributes:
diff --git a/docs/content/5.reference/0.render.md b/docs/content/5.reference/0.render.md
index 11de8c8b..bf0f0a3c 100644
--- a/docs/content/5.reference/0.render.md
+++ b/docs/content/5.reference/0.render.md
@@ -74,6 +74,7 @@ For the complete parsed document and node types, see the [Document Model](/getti
| [`maxInlineAttributes`](#options-maxinlineattributes) | `number` | `3` | Maximum attributes before switching to YAML block syntax. Set to `0` to always use block syntax |
| `blockAttributesStyle` | `'frontmatter' \| 'codeblock'` | `'codeblock'` | Syntax style for block attributes when they exceed `maxInlineAttributes` |
| [`frontmatterOptions`](#options-frontmatteroptions) | `DumpOptions` | `{ indent: 2, lineWidth: -1 }` | js-yaml [DumpOptions](https://github.com/nodeca/js-yaml#dump-object---options-) passed to the frontmatter serializer |
+| `headingIds` | `boolean \| 'nested' \| 'flat'` | `true` | The [`headingIds`](/reference/parse#options) mode the document was parsed with. A heading id equal to the auto-generated one is omitted; any other id is written as `{#id}` |
| [`components`](#options-components) | `Record` | `{}` | Custom render handlers for specific elements |
| [`data`](#options-data) | `Record` | `{}` | Additional data passed to render handlers |
diff --git a/docs/content/5.reference/1.parse.md b/docs/content/5.reference/1.parse.md
index 43857042..b853574e 100644
--- a/docs/content/5.reference/1.parse.md
+++ b/docs/content/5.reference/1.parse.md
@@ -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` 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). |
+| `headingIds` | `boolean \| 'nested' \| 'flat'` | `true` | Auto-generate `id` attributes for `h1`–`h6` from visible heading text (not tag or component names). `true`/`'flat'` uses the heading's own text only (`install`); `'nested'` prefixes each id with its parent heading's id (`setup-install`); `false` disables auto ids. Unicode letters, marks, and numbers are kept (`## Café` → `café`); punctuation and symbols are dropped, and a heading that slugifies to nothing gets no id. Ids are unique per document: duplicates get a `-1`, `-2`, ... suffix (`options`, `options-1`, `options-2`). An explicit `id` attribute wins. 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) |
diff --git a/docs/content/5.reference/3.reference.md b/docs/content/5.reference/3.reference.md
index 39c6d396..c3a21dba 100644
--- a/docs/content/5.reference/3.reference.md
+++ b/docs/content/5.reference/3.reference.md
@@ -301,7 +301,7 @@ interface ParserOptions {
/** @deprecated Prefer registerDefaultPlugins: false */
html?: boolean // default: true
linkify?: boolean // default: true
- headingIds?: boolean // default: true — slug from visible text (keeps Unicode letters/numbers, omits symbol-only ids); explicit id wins
+ headingIds?: boolean | 'nested' | 'flat' // default: true ('flat') — slug from visible text; duplicates get -1, -2, … (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
diff --git a/docs/skills/comark/references/markdown-syntax.md b/docs/skills/comark/references/markdown-syntax.md
index 1f3e2e40..0351df0a 100644
--- a/docs/skills/comark/references/markdown-syntax.md
+++ b/docs/skills/comark/references/markdown-syntax.md
@@ -31,7 +31,8 @@ The standard features:
###### Heading 6
```
-**Note:** All headings automatically get an `id` from their visible text (e.g., `# Hello World` becomes ``). 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.
+
+**Note:** All headings automatically get an `id` from their visible text (e.g., `# Hello World` becomes ``). 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 anywhere in the document gets a numeric suffix (`options`, `options-1`, `options-2`). Ids are flat by default (`## Setup` / `### Install` → `setup`, `install`); set `headingIds: 'nested'` in parse options to prefix each id from `h3` down with the nearest `h2`+ ancestor (`setup-install`). 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
diff --git a/packages/comark/SPEC/COMARK/attributes/headings.md b/packages/comark/SPEC/COMARK/attributes/headings.md
index 32ec704e..17703047 100644
--- a/packages/comark/SPEC/COMARK/attributes/headings.md
+++ b/packages/comark/SPEC/COMARK/attributes/headings.md
@@ -40,7 +40,7 @@
[
"h3",
{
- "id": "h2-h3",
+ "id": "h3",
"attr": "value"
},
"h3"
@@ -48,7 +48,7 @@
[
"h4",
{
- "id": "h2-h3-h4",
+ "id": "h4",
"attr": "value"
},
"h4"
@@ -56,7 +56,7 @@
[
"h5",
{
- "id": "h2-h3-h4-h5",
+ "id": "h5",
"attr": "value"
},
"h5"
@@ -64,7 +64,7 @@
[
"h6",
{
- "id": "h2-h3-h4-h5-h6",
+ "id": "h6",
"attr": "value"
},
"h6"
@@ -78,10 +78,10 @@
```html
h1
h2
-h3
-h4
-h5
-h6
+h3
+h4
+h5
+h6
```
## Markdown
diff --git a/packages/comark/SPEC/COMARK/heading-attributes-disabled.md b/packages/comark/SPEC/COMARK/heading-attributes-disabled.md
new file mode 100644
index 00000000..72b739a9
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/heading-attributes-disabled.md
@@ -0,0 +1,50 @@
+---
+options:
+ headingIds: false
+---
+
+## Input
+
+```md
+# Product Documentation {#prod-doc}
+
+## Installation
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "h1",
+ {
+ "id": "prod-doc"
+ },
+ "Product Documentation"
+ ],
+ [
+ "h2",
+ {},
+ "Installation"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+Product Documentation
+Installation
+```
+
+## Markdown
+
+```md
+# Product Documentation {#prod-doc}
+
+## Installation
+```
diff --git a/packages/comark/SPEC/COMARK/heading-attributes-flat-collision.md b/packages/comark/SPEC/COMARK/heading-attributes-flat-collision.md
new file mode 100644
index 00000000..79316ab7
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/heading-attributes-flat-collision.md
@@ -0,0 +1,100 @@
+---
+options:
+ headingIds: flat
+---
+
+## Input
+
+```md
+## Foo
+
+### Foo
+
+### Foo-1
+
+## Bar-1
+
+## Bar
+
+## Bar
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "h2",
+ {
+ "id": "foo"
+ },
+ "Foo"
+ ],
+ [
+ "h3",
+ {
+ "id": "foo-1"
+ },
+ "Foo"
+ ],
+ [
+ "h3",
+ {
+ "id": "foo-1-1"
+ },
+ "Foo-1"
+ ],
+ [
+ "h2",
+ {
+ "id": "bar-1"
+ },
+ "Bar-1"
+ ],
+ [
+ "h2",
+ {
+ "id": "bar"
+ },
+ "Bar"
+ ],
+ [
+ "h2",
+ {
+ "id": "bar-2"
+ },
+ "Bar"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+Foo
+Foo
+Foo-1
+Bar-1
+Bar
+Bar
+```
+
+## Markdown
+
+```md
+## Foo
+
+### Foo
+
+### Foo-1
+
+## Bar-1
+
+## Bar
+
+## Bar
+```
diff --git a/packages/comark/SPEC/COMARK/heading-attributes-flat.md b/packages/comark/SPEC/COMARK/heading-attributes-flat.md
new file mode 100644
index 00000000..95751eff
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/heading-attributes-flat.md
@@ -0,0 +1,100 @@
+---
+options:
+ headingIds: flat
+---
+
+## Input
+
+```md
+# Product Documentation
+
+## Installation
+
+### Npm
+
+### Yarn {#installation-yarn}
+
+## Usage
+
+### Npm
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "h1",
+ {
+ "id": "product-documentation"
+ },
+ "Product Documentation"
+ ],
+ [
+ "h2",
+ {
+ "id": "installation"
+ },
+ "Installation"
+ ],
+ [
+ "h3",
+ {
+ "id": "npm"
+ },
+ "Npm"
+ ],
+ [
+ "h3",
+ {
+ "id": "installation-yarn"
+ },
+ "Yarn"
+ ],
+ [
+ "h2",
+ {
+ "id": "usage"
+ },
+ "Usage"
+ ],
+ [
+ "h3",
+ {
+ "id": "npm-1"
+ },
+ "Npm"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+Product Documentation
+Installation
+Npm
+Yarn
+Usage
+Npm
+```
+
+## Markdown
+
+```md
+# Product Documentation
+
+## Installation
+
+### Npm
+
+### Yarn {#installation-yarn}
+
+## Usage
+
+### Npm
+```
diff --git a/packages/comark/SPEC/COMARK/heading-attributes.md b/packages/comark/SPEC/COMARK/heading-attributes.md
new file mode 100644
index 00000000..0103b656
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/heading-attributes.md
@@ -0,0 +1,98 @@
+## Input
+
+```md
+# Product Documentation {#prod-doc}
+
+## Installation {#installation}
+
+### Npm {#npm .highlight}
+
+### Yarn {#installation-yarn}
+
+## Usage {.lead data-level="2"}
+
+## Installation
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "h1",
+ {
+ "id": "prod-doc"
+ },
+ "Product Documentation"
+ ],
+ [
+ "h2",
+ {
+ "id": "installation"
+ },
+ "Installation"
+ ],
+ [
+ "h3",
+ {
+ "id": "npm",
+ "class": "highlight"
+ },
+ "Npm"
+ ],
+ [
+ "h3",
+ {
+ "id": "installation-yarn"
+ },
+ "Yarn"
+ ],
+ [
+ "h2",
+ {
+ "id": "usage",
+ "class": "lead",
+ "data-level": "2"
+ },
+ "Usage"
+ ],
+ [
+ "h2",
+ {
+ "id": "installation-1"
+ },
+ "Installation"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+Product Documentation
+Installation
+Npm
+Yarn
+Usage
+Installation
+```
+
+## Markdown
+
+```md
+# Product Documentation {#prod-doc}
+
+## Installation
+
+### Npm {.highlight}
+
+### Yarn {#installation-yarn}
+
+## Usage {.lead data-level="2"}
+
+## Installation
+```
diff --git a/packages/comark/SPEC/COMARK/headings-ids-collision.md b/packages/comark/SPEC/COMARK/headings-ids-collision.md
new file mode 100644
index 00000000..6099a5fb
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/headings-ids-collision.md
@@ -0,0 +1,112 @@
+---
+options:
+ headingIds: nested
+---
+
+## Input
+
+```md
+# Bar
+
+# Bar
+
+# Bar-1
+
+## Setup {#custom}
+
+### Install
+
+## Setup
+
+### Install
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "h1",
+ {
+ "id": "bar"
+ },
+ "Bar"
+ ],
+ [
+ "h1",
+ {
+ "id": "bar-1"
+ },
+ "Bar"
+ ],
+ [
+ "h1",
+ {
+ "id": "bar-1-1"
+ },
+ "Bar-1"
+ ],
+ [
+ "h2",
+ {
+ "id": "custom"
+ },
+ "Setup"
+ ],
+ [
+ "h3",
+ {
+ "id": "setup-install"
+ },
+ "Install"
+ ],
+ [
+ "h2",
+ {
+ "id": "setup-1"
+ },
+ "Setup"
+ ],
+ [
+ "h3",
+ {
+ "id": "setup-install-1"
+ },
+ "Install"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+Bar
+Bar
+Bar-1
+Setup
+Install
+Setup
+Install
+```
+
+## Markdown
+
+```md
+# Bar
+
+# Bar
+
+# Bar-1
+
+## Setup {#custom}
+
+### Install
+
+## Setup
+
+### Install
+```
diff --git a/packages/comark/SPEC/COMARK/headings-ids.md b/packages/comark/SPEC/COMARK/headings-ids.md
index 48f4dcfe..b9ef690f 100644
--- a/packages/comark/SPEC/COMARK/headings-ids.md
+++ b/packages/comark/SPEC/COMARK/headings-ids.md
@@ -1,3 +1,8 @@
+---
+options:
+ headingIds: nested
+---
+
## Input
```md
diff --git a/packages/comark/SPEC/common-mark/headings-id.md b/packages/comark/SPEC/common-mark/headings-id.md
index 953f311c..2a372866 100644
--- a/packages/comark/SPEC/common-mark/headings-id.md
+++ b/packages/comark/SPEC/common-mark/headings-id.md
@@ -65,28 +65,28 @@
[
"h3",
{
- "id": "ending-space-slash-in-title"
+ "id": "slash-in-title"
},
"Slash - in - Title"
],
[
"h3",
{
- "id": "ending-space-starting-dash"
+ "id": "starting-dash"
},
"-Starting dash"
],
[
"h3",
{
- "id": "ending-space-ending-dash"
+ "id": "ending-dash"
},
"Ending dash-"
],
[
"h3",
{
- "id": "ending-space-dash"
+ "id": "dash"
},
"-Dash-"
]
@@ -102,10 +102,10 @@
C. Great
1. Introduction
ending space
-Slash - in - Title
--Starting dash
-Ending dash-
--Dash-
+Slash - in - Title
+-Starting dash
+Ending dash-
+-Dash-
```
## Markdown
diff --git a/packages/comark/SPEC/common-mark/headings.md b/packages/comark/SPEC/common-mark/headings.md
index 9fb0d13c..8f8ae1e5 100644
--- a/packages/comark/SPEC/common-mark/headings.md
+++ b/packages/comark/SPEC/common-mark/headings.md
@@ -38,28 +38,28 @@
[
"h3",
{
- "id": "h2-h3"
+ "id": "h3"
},
"H3"
],
[
"h4",
{
- "id": "h2-h3-h4"
+ "id": "h4"
},
"H4"
],
[
"h5",
{
- "id": "h2-h3-h4-h5"
+ "id": "h5"
},
"H5"
],
[
"h6",
{
- "id": "h2-h3-h4-h5-h6"
+ "id": "h6"
},
"H6"
]
@@ -72,10 +72,10 @@
```html
H1
H2
-H3
-H4
-H5
-H6
+H3
+H4
+H5
+H6
```
## Markdown
diff --git a/packages/comark/SPEC/common-mark/xyz.md b/packages/comark/SPEC/common-mark/xyz.md
index aaf4369c..097004bc 100644
--- a/packages/comark/SPEC/common-mark/xyz.md
+++ b/packages/comark/SPEC/common-mark/xyz.md
@@ -114,7 +114,7 @@ And here's a code block:
[
"h3",
{
- "id": "section-one-subsection"
+ "id": "subsection"
},
"Subsection"
],
@@ -141,7 +141,7 @@ And here's a code block:
[
"h4",
{
- "id": "section-one-subsection-nested-heading"
+ "id": "nested-heading"
},
"Nested Heading"
],
@@ -281,12 +281,12 @@ And here's a code block:
This is a comprehensive markdown document with multiple features and various formatting options.
Section One
Here's a paragraph with a link to Nuxt and some inline code. You can also find bold text and italic text together.
-Subsection
+Subsection
This is a blockquote with important information.
It can span multiple lines and contain formatted text.
-Nested Heading
+Nested Heading
Here's an unordered list:
- First item
diff --git a/packages/comark/src/internal/heading-id.ts b/packages/comark/src/internal/heading-id.ts
new file mode 100644
index 00000000..ed7b0043
--- /dev/null
+++ b/packages/comark/src/internal/heading-id.ts
@@ -0,0 +1,109 @@
+import type { HeadingIdsOption } from 'comark'
+
+/**
+ * Auto-generated heading ids. Shared by the parser (to assign ids) and the
+ * markdown stringifier (to tell whether a heading's id is the implicit one or
+ * must be written back as `{#id}`). Both walk headings in document order, so
+ * they build the same hierarchy and duplicate counts.
+ */
+export interface HeadingIdTracker {
+ nested: boolean
+ slugCounts: Map
+ /** Every id handed out so far, so a suffixed id never collides with a literal one (`Foo`, `Foo`, `Foo-1`) */
+ usedIds: Set
+ stack: Array<{ level: number; id: string }>
+}
+
+/**
+ * Create a tracker for the given `headingIds` option, or `undefined` when
+ * auto-generated ids are disabled.
+ */
+export function createHeadingIdTracker(option: HeadingIdsOption = true): HeadingIdTracker | undefined {
+ if (option === false) return undefined
+ return { nested: option === 'nested', slugCounts: new Map(), usedIds: new Set(), stack: [] }
+}
+
+/**
+ * Convert text to a slug for heading IDs.
+ * Example: "Hello World" -> "hello-world"
+ * Example: "1. Introduction" -> "_1-introduction"
+ * Example: "Café" -> "café"
+ *
+ * Keeps Unicode letters, marks, decimal digits, and letter numbers. A combining mark is
+ * dropped wherever it would lead, so the result is a valid HTML5 id (NameStartChar is a
+ * letter or `_`, never a mark).
+ */
+export function slugify(text: string): string {
+ let slug = text
+ .normalize('NFC')
+ .toLowerCase()
+ .trim()
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
+ // Keep Unicode letters, marks, decimal digits and letter numbers; drop the rest.
+ // Other numbers (No: ①, ½, ²) are not valid HTML5 id or CSS ident characters.
+ .replace(/[^\p{L}\p{M}\p{Nd}\p{Nl}_-]+/gu, '')
+ .replace(/-{2,}/g, '-') // Replace multiple hyphens with single hyphen
+ // Drop a leading run of marks and hyphens, plus trailing hyphens. Marks and
+ // hyphens only ever expose each other (`\u0301-1` would otherwise survive as
+ // "-1"), so one alternation covers the run.
+ .replace(/^(?:\p{M}+|-+)+|-+$/gu, '')
+
+ // Prefix an ASCII leading digit. `#123` is not a valid CSS ident; a non-ASCII
+ // digit (U+0660 ARABIC-INDIC DIGIT ZERO and friends) is, so it is left as-is.
+ if (slug.charCodeAt(0) >= 48 && slug.charCodeAt(0) <= 57) {
+ slug = '_' + slug
+ }
+
+ return slug
+}
+
+/**
+ * Reserve a heading's effective id when a user-supplied `id` overrides the
+ * generated one, so later generated ids get a suffix instead of reusing it
+ * (`# Foo {#foo-1}` then `# Foo` → `foo-1`, `foo-2`).
+ */
+export function reserveHeadingId(tracker: HeadingIdTracker, id: unknown): void {
+ if (typeof id === 'string' && id) tracker.usedIds.add(id)
+}
+
+/**
+ * Return the auto-generated id for the next heading, suffixed with a counter
+ * for duplicates. In nested mode it is also prefixed with its parent heading's
+ * id (h2+ parents only). Returns `undefined` when the heading text slugifies to
+ * nothing (emoji, punctuation only) — such a heading gets no id.
+ */
+export function nextHeadingId(text: string, level: number, tracker: HeadingIdTracker): string | undefined {
+ let slug = slugify(text)
+ const { stack, slugCounts, usedIds } = tracker
+
+ if (tracker.nested) {
+ // Pop headings at same level or deeper
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
+ stack.pop()
+ }
+ // Use parent's full ID as prefix (h1 doesn't prefix children). Skip the
+ // composition when either side is empty, so a symbol-only parent or child
+ // never yields a degenerate id like "-café" or "setup-".
+ if (stack.length > 0) {
+ const parent = stack[stack.length - 1]
+ if (parent.level >= 2 && parent.id && slug) {
+ slug = parent.id + '-' + slug
+ }
+ }
+
+ // Push onto stack for child headings to reference
+ stack.push({ level, id: slug })
+ }
+
+ if (!slug) return undefined
+
+ let count = slugCounts.get(slug) ?? 0
+ let id = count === 0 ? slug : `${slug}-${count}`
+ while (usedIds.has(id)) {
+ count++
+ id = `${slug}-${count}`
+ }
+ slugCounts.set(slug, count + 1)
+ usedIds.add(id)
+ return id
+}
diff --git a/packages/comark/src/internal/parse/tree-utils.ts b/packages/comark/src/internal/parse/tree-utils.ts
index 2edfe21b..0f9c344a 100644
--- a/packages/comark/src/internal/parse/tree-utils.ts
+++ b/packages/comark/src/internal/parse/tree-utils.ts
@@ -187,40 +187,6 @@ export function extractAttributes(
return { attrs: {}, nextIndex: startIndex }
}
-/**
- * Convert text to a slug for heading IDs.
- * Example: "Hello World" -> "hello-world"
- * Example: "1. Introduction" -> "_1-introduction"
- * Example: "Café" -> "café"
- *
- * Keeps Unicode letters, marks, decimal digits, and letter numbers. A combining mark is
- * dropped wherever it would lead, so the result is a valid HTML5 id (NameStartChar is a
- * letter or `_`, never a mark).
- */
-export function slugify(text: string): string {
- let slug = text
- .normalize('NFC')
- .toLowerCase()
- .trim()
- .replace(/\s+/g, '-') // Replace spaces with hyphens
- // Keep Unicode letters, marks, decimal digits and letter numbers; drop the rest.
- // Other numbers (No: ①, ½, ²) are not valid HTML5 id or CSS ident characters.
- .replace(/[^\p{L}\p{M}\p{Nd}\p{Nl}_-]+/gu, '')
- .replace(/-{2,}/g, '-') // Replace multiple hyphens with single hyphen
- // Drop a leading run of marks and hyphens, plus trailing hyphens. Marks and
- // hyphens only ever expose each other (`\u0301-1` would otherwise survive as
- // "-1", which the dedup guard discards), so one alternation covers the run.
- .replace(/^(?:\p{M}+|-+)+|-+$/gu, '')
-
- // Prefix an ASCII leading digit. `#123` is not a valid CSS ident; a non-ASCII
- // digit (U+0660 ARABIC-INDIC DIGIT ZERO and friends) is, so it is left as-is.
- if (slug.charCodeAt(0) >= 48 && slug.charCodeAt(0) <= 57) {
- slug = '_' + slug
- }
-
- return slug
-}
-
/**
* Merge adjacent string nodes in an array of nodes
*/
diff --git a/packages/comark/src/internal/parse/tree.ts b/packages/comark/src/internal/parse/tree.ts
index b7608c7d..943fbbfe 100644
--- a/packages/comark/src/internal/parse/tree.ts
+++ b/packages/comark/src/internal/parse/tree.ts
@@ -1,12 +1,7 @@
import { Token } from 'markdown-exit'
-import type { CommentNode, ElementNode, ElementNodeAttributes, Node } from 'comark'
-import {
- extractAttributes,
- mergeAdjacentTextNodes,
- parseCodeblockInfo,
- processAttributes,
- slugify,
-} from './tree-utils.ts'
+import type { CommentNode, ElementNode, ElementNodeAttributes, HeadingIdsOption, Node } from 'comark'
+import { extractAttributes, mergeAdjacentTextNodes, parseCodeblockInfo, processAttributes } from './tree-utils.ts'
+import { createHeadingIdTracker, nextHeadingId, reserveHeadingId, type HeadingIdTracker } from '../heading-id.ts'
import { parseHtmlInline } from './utils.ts'
import { textContent } from 'comark/utils'
@@ -28,7 +23,7 @@ type ProcessorResult = {
export interface ProcessorOptions {
preservePositions?: boolean
- headingIds?: boolean
+ headingIds?: HeadingIdsOption
startLine?: number
}
@@ -46,11 +41,10 @@ type HtmlOpenFrame = {
/** Shared mutable context for a whole document walk. */
interface ProcessState {
preservePositions: boolean
- headingIds: boolean
+ /** `undefined` when auto-generated heading ids are disabled */
+ headingIds?: HeadingIdTracker
startLine: number
- headingSlugCounts: Map
- headingStack: Array<{ level: number; id: string }>
- /** Unclosed HTML open tags, outer → inner. Lives across blocks like headingStack. */
+ /** Unclosed HTML open tags, outer → inner. Lives across blocks like the heading id stack. */
htmlStack: HtmlOpenFrame[]
/**
* > 0 while building a nested markdown container (list, blockquote, table…).
@@ -372,27 +366,6 @@ function codeBlockProcessor(tokens: Token[], start: number, _state: ProcessState
}
}
-function uniqueSlug(slug: string, level: number, state: ProcessState): string {
- while (state.headingStack.length > 0 && state.headingStack[state.headingStack.length - 1].level >= level) {
- state.headingStack.pop()
- }
- // Use parent's full ID as prefix (h1 doesn't prefix children). Skip the
- // composition when either side is empty, so a symbol-only parent or child
- // never yields a degenerate id like "-café" or "setup-".
- if (state.headingStack.length > 0) {
- const parent = state.headingStack[state.headingStack.length - 1]
- if (parent.level >= 2 && parent.id && slug) {
- slug = parent.id + '-' + slug
- }
- }
-
- state.headingStack.push({ level, id: slug })
-
- const count = state.headingSlugCounts.get(slug) ?? 0
- state.headingSlugCounts.set(slug, count + 1)
- return count === 0 ? slug : `${slug}-${count}`
-}
-
function singleToken(fn: (token: Token) => Node) {
return (tokens: Token[], start: number): ProcessorResult =>
processPossibleAttributesSyntax(tokens, { nextIndex: start + 1, node: fn(tokens[start]) })
@@ -488,11 +461,10 @@ const processors: Record = {
if (state.headingIds) {
const level = Number.parseInt((tokens[start].tag || 'h1').replace('h', ''), 10) || 1
- const _textContent = textContent(node)
- const headingId = uniqueSlug(slugify(_textContent), level, state)
- // An empty slug is recorded but never a usable anchor, and each further duplicate
- // of it comes back as "-1", "-2", … — none of which is either.
- if (headingId && !/^-\d+$/.test(headingId)) {
+ const headingId = nextHeadingId(textContent(node), level, state.headingIds)
+ // User-supplied `id` wins over the auto-generated one.
+ reserveHeadingId(state.headingIds, (node[1] as Record).id)
+ if (headingId) {
;(node as ElementNode)[1] = { id: headingId, ...(node[1] as Record) }
}
}
@@ -663,10 +635,8 @@ const processors: Record = {
export function tokenListToTree(tokens: Token[], options: ProcessorOptions = {}): Node[] {
const state: ProcessState = {
preservePositions: options.preservePositions ?? false,
- headingIds: options.headingIds ?? true,
+ headingIds: createHeadingIdTracker(options.headingIds ?? true),
startLine: options.startLine ?? 0,
- headingSlugCounts: new Map(),
- headingStack: [],
htmlStack: [],
insideMarkdownContainer: 0,
}
diff --git a/packages/comark/src/internal/stringify/handlers/heading.ts b/packages/comark/src/internal/stringify/handlers/heading.ts
index 24955961..1b678dc0 100644
--- a/packages/comark/src/internal/stringify/handlers/heading.ts
+++ b/packages/comark/src/internal/stringify/handlers/heading.ts
@@ -1,7 +1,30 @@
import type { State } from 'comark/render'
-import type { ElementNode } from 'comark'
+import type { ElementNode, HeadingIdsOption } from 'comark'
+import { textContent } from '../../../utils/index.ts'
+import { createHeadingIdTracker, nextHeadingId, reserveHeadingId, type HeadingIdTracker } from '../../heading-id.ts'
import { comarkAttributes } from '../attributes.ts'
+// Replays the parser's id generation across one render so each heading's
+// implicit id is known, keyed by render state (`null` when ids are disabled).
+const trackers = new WeakMap()
+
+function autoHeadingId(node: ElementNode, level: number, state: State, id: unknown): string | undefined {
+ let tracker = trackers.get(state)
+ if (tracker === undefined) {
+ tracker = createHeadingIdTracker(state.context.headingIds as HeadingIdsOption | undefined) ?? null
+ trackers.set(state, tracker)
+ }
+ if (!tracker) return undefined
+ const text = node
+ .slice(2)
+ .map((child) => textContent(child as ElementNode))
+ .join('')
+ const autoId = nextHeadingId(text, level, tracker)
+ // Mirror the parser: an explicit id is reserved so later headings skip it.
+ reserveHeadingId(tracker, id)
+ return autoId
+}
+
// h1, h2, h3, h4, h5, h6
export async function heading(node: ElementNode, state: State) {
const [tag] = node
@@ -10,9 +33,10 @@ export async function heading(node: ElementNode, state: State) {
const content = await state.flow(node, state)
- // The auto-generated id is implicit in `# Heading` markdown — don't echo it.
- const { id: _id, ...rest } = node[1] as Record
- const attrs = comarkAttributes(rest)
+ // The auto-generated id is implicit in `# Heading` markdown — only echo a custom one.
+ const { id, ...rest } = node[1] as Record
+ const autoId = autoHeadingId(node, level, state, id)
+ const attrs = comarkAttributes(id === undefined || id === autoId ? rest : { id, ...rest })
const suffix = attrs ? ` ${attrs}` : ''
return '#'.repeat(level) + ' ' + content + suffix + state.context.blockSeparator
diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts
index 5694c872..162f1234 100644
--- a/packages/comark/src/types.ts
+++ b/packages/comark/src/types.ts
@@ -222,6 +222,11 @@ export type State = {
/**
* The context of the renderer
*/
+/**
+ * Auto-generated heading id mode. `true` is an alias for `'flat'`.
+ */
+export type HeadingIdsOption = boolean | 'nested' | 'flat'
+
export interface RendererOptions {
/**
* Additional node handlers to pass to the renderer
@@ -257,6 +262,13 @@ export interface RenderMarkdownOptions extends RendererOptions {
* Defaults: indent=2, lineWidth=-1.
*/
frontmatterOptions?: DumpOptions
+ /**
+ * The `headingIds` mode the document was parsed with. Heading ids equal to
+ * the auto-generated one are implicit and omitted; any other id is written
+ * as `{#id}`. With `false`, every heading id is written.
+ * @default true
+ */
+ headingIds?: HeadingIdsOption
}
export interface NodeRenderData {
@@ -491,8 +503,16 @@ export interface ParserOptions[
linkify?: boolean
/**
- * Whether to auto-generate `id` attributes for `h1`–`h6` headings from their text content.
- * Set `false` to skip auto-generated ids; user-supplied `id` attributes are still preserved.
+ * How to auto-generate `id` attributes for `h1`–`h6` headings from their text content.
+ * User-supplied `id` attributes are always preserved.
+ *
+ * - `true` / `'flat'` — slug of the heading's own text only
+ * - `'nested'` — slug prefixed with the parent heading's id (h2+ parents)
+ * - `false` — no auto-generated ids
+ *
+ * Ids are unique within a document: a duplicate gets a numeric suffix
+ * (`options`, `options-1`, `options-2`, ...) in both modes, and a suffixed id
+ * never collides with a heading whose own slug is already `options-1`.
*
* Generated ids keep Unicode letters, marks, decimal digits, and letter numbers
* (`## Café` → `café`, `## 日本語` → `日本語`). Punctuation and symbols are dropped,
@@ -501,15 +521,15 @@ export interface ParserOptions[
*
* @default true
* @example
- * // With headingIds: true (default)
- * // # Hello World → ['h1', { id: 'hello-world' }, 'Hello World']
- * // # Café → ['h1', { id: 'café' }, 'Café']
+ * // ## Setup\n### Install
+ * // 'flat' → ['h2', { id: 'setup' }, ...], ['h3', { id: 'install' }, ...]
+ * // 'nested' → ['h2', { id: 'setup' }, ...], ['h3', { id: 'setup-install' }, ...]
+ * // false → ['h2', {}, ...], ['h3', {}, ...]
*
- * // With headingIds: false
- * // # Hello World → ['h1', {}, 'Hello World']
- * // # Hello {id="custom"} → ['h1', { id: 'custom' }, 'Hello']
+ * // # Café → ['h1', { id: 'café' }, 'Café']
+ * // # Hello {id="custom"} → ['h1', { id: 'custom' }, 'Hello'] (any mode, including false)
*/
- headingIds?: boolean
+ headingIds?: HeadingIdsOption
/**
* Whether to register the built-in default plugins
diff --git a/packages/comark/test/heading-ids.test.ts b/packages/comark/test/heading-ids.test.ts
index 964b9f6c..91000655 100644
--- a/packages/comark/test/heading-ids.test.ts
+++ b/packages/comark/test/heading-ids.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { parseMarkdown } from '../src/parse'
+import { renderMarkdown } from '../src/render'
describe('headingIds option', () => {
it('generates id attributes by default', async () => {
@@ -20,20 +21,76 @@ describe('headingIds option', () => {
expect(tree.nodes).toEqual([['h1', { id: 'custom' }, 'Hello']])
})
- it('still generates hierarchical ids when headingIds is true', async () => {
- const tree = await parseMarkdown('# Title\n\n## Section')
+ it('uses flat ids by default', async () => {
+ const tree = await parseMarkdown('## Setup\n\n### Install')
- expect(tree.nodes).toEqual([
- ['h1', { id: 'title' }, 'Title'],
- ['h2', { id: 'section' }, 'Section'],
- ])
+ const ids = tree.nodes.map((n: any) => n[1].id)
+ expect(ids).toEqual(['setup', 'install'])
+ })
+
+ it('treats headingIds: true the same as flat', async () => {
+ const tree = await parseMarkdown('## Setup\n\n### Install', { headingIds: true })
+
+ const ids = tree.nodes.map((n: any) => n[1].id)
+ expect(ids).toEqual(['setup', 'install'])
+ })
+
+ it('prefixes parent ids in nested mode', async () => {
+ const tree = await parseMarkdown('# Title\n\n## Setup\n\n### Install', { headingIds: 'nested' })
+
+ const ids = tree.nodes.map((n: any) => n[1].id)
+ expect(ids).toEqual(['title', 'setup', 'setup-install'])
+ })
+
+ it('deduplicates flat ids with a numeric suffix', async () => {
+ const tree = await parseMarkdown('## Options\n\n## Options\n\n## Options')
+
+ const ids = tree.nodes.map((n: any) => n[1].id)
+ expect(ids).toEqual(['options', 'options-1', 'options-2'])
+ })
+
+ it('deduplicates flat ids across different heading levels and sections', async () => {
+ const tree = await parseMarkdown('# Install\n\n## Npm\n\n### Install\n\n## Yarn\n\n### Install\n\n#### Npm')
+
+ const ids = tree.nodes.map((n: any) => n[1].id)
+ expect(ids).toEqual(['install', 'npm', 'install-1', 'yarn', 'install-2', 'npm-1'])
+ })
+
+ it('deduplicates ids from different heading text that slugify the same', async () => {
+ const tree = await parseMarkdown('## Hello World\n\n## hello-world\n\n## Hello, World!')
+
+ const ids = tree.nodes.map((n: any) => n[1].id)
+ expect(ids).toEqual(['hello-world', 'hello-world-1', 'hello-world-2'])
})
- it('deduplicates hierarchical ids with a numeric suffix', async () => {
- const tree = await parseMarkdown('## Options\n\n## Options')
+ it('deduplicates hierarchical ids with a numeric suffix in nested mode', async () => {
+ const tree = await parseMarkdown('## Setup\n\n### Install\n\n## Setup\n\n### Install', { headingIds: 'nested' })
const ids = tree.nodes.map((n: any) => n[1].id)
- expect(ids).toEqual(['options', 'options-1'])
+ expect(ids).toEqual(['setup', 'setup-install', 'setup-1', 'setup-install-1'])
+ })
+
+ it('never reuses an id when a suffixed duplicate collides with a literal slug', async () => {
+ const flat = await parseMarkdown('## Foo\n\n### Foo\n\n### Foo-1')
+ expect(flat.nodes.map((n: any) => n[1].id)).toEqual(['foo', 'foo-1', 'foo-1-1'])
+
+ const nested = await parseMarkdown('# Bar\n\n# Bar\n\n# Bar-1', { headingIds: 'nested' })
+ expect(nested.nodes.map((n: any) => n[1].id)).toEqual(['bar', 'bar-1', 'bar-1-1'])
+
+ const reversed = await parseMarkdown('# Foo-1\n\n# Foo\n\n# Foo')
+ expect(reversed.nodes.map((n: any) => n[1].id)).toEqual(['foo-1', 'foo', 'foo-2'])
+ })
+
+ it('does not reuse an explicit id for a later generated one', async () => {
+ const source = '# Foo {#foo-1}\n\n# Foo\n\n# Bar\n\n# Baz {#bar-1}\n\n# Bar'
+ const tree = await parseMarkdown(source)
+ expect(tree.nodes.map((n: any) => n[1].id)).toEqual(['foo-1', 'foo-2', 'bar', 'bar-1', 'bar-2'])
+
+ // The stringifier replays the same reservation, so the roundtrip is stable.
+ const markdown = await renderMarkdown(tree)
+ expect(markdown.trim()).toBe(source)
+ const reparsed = await parseMarkdown(markdown)
+ expect(reparsed.nodes).toEqual(tree.nodes)
})
describe('slug text extraction', () => {
@@ -102,28 +159,28 @@ describe('headingIds option', () => {
})
it('keeps the parent prefix for a non-ASCII child heading', async () => {
- const tree = await parseMarkdown('## Setup\n\n### Café')
+ const tree = await parseMarkdown('## Setup\n\n### Café', { headingIds: 'nested' })
const ids = tree.nodes.map((n: any) => n[1].id)
expect(ids).toEqual(['setup', 'setup-café'])
})
it('does not prefix a child with an empty parent id', async () => {
- const tree = await parseMarkdown('## 🚀\n\n### Café')
+ const tree = await parseMarkdown('## 🚀\n\n### Café', { headingIds: 'nested' })
const ids = tree.nodes.map((n: any) => n[1].id)
expect(ids).toEqual([undefined, 'café'])
})
it('omits the id for a symbol-only child of a prefixed parent', async () => {
- const tree = await parseMarkdown('## Setup\n\n### 🚀')
+ const tree = await parseMarkdown('## Setup\n\n### 🚀', { headingIds: 'nested' })
const ids = tree.nodes.map((n: any) => n[1].id)
expect(ids).toEqual(['setup', undefined])
})
it('omits the id for nested symbol-only headings', async () => {
- const tree = await parseMarkdown('## 🚀\n\n### 🚀')
+ const tree = await parseMarkdown('## 🚀\n\n### 🚀', { headingIds: 'nested' })
const ids = tree.nodes.map((n: any) => n[1].id)
expect(ids).toEqual([undefined, undefined])
diff --git a/packages/comark/test/index.test.ts b/packages/comark/test/index.test.ts
index 128c1fa3..ae5c9623 100644
--- a/packages/comark/test/index.test.ts
+++ b/packages/comark/test/index.test.ts
@@ -15,7 +15,7 @@ import minLight from 'shiki/dist/themes/min-light.mjs'
import nord from 'shiki/dist/themes/nord.mjs'
import rustLanguage from 'shiki/dist/langs/rust.mjs'
import goLanguage from 'shiki/dist/langs/go.mjs'
-import type { ParserOptions } from '../src/types'
+import type { HeadingIdsOption, ParserOptions } from '../src/types'
import type { ShikiTransformer } from 'shiki'
import { renderHtmlForTest } from './utils/render-html'
import { Binding as HTMLBiniding } from '../../comark-html/src/plugins/binding'
@@ -56,6 +56,7 @@ interface TestCase {
autoUnwrap?: boolean
maxInlineAttributes?: number
blockAttributesStyle?: 'frontmatter' | 'codeblock'
+ headingIds?: HeadingIdsOption
}
skip?: boolean
}
@@ -252,6 +253,10 @@ describe('Comark Tests', () => {
autoUnwrap: testCase.options?.autoUnwrap === false ? false : true,
}
+ if (testCase.options?.headingIds !== undefined) {
+ parseOptions.headingIds = testCase.options.headingIds
+ }
+
if (plugins.length > 0) {
parseOptions.plugins = plugins
}
diff --git a/test/bundle.test.ts b/test/bundle.test.ts
index c0e22f1f..7da943b9 100644
--- a/test/bundle.test.ts
+++ b/test/bundle.test.ts
@@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => {
"@comark/react": "38.0k (76 files)",
"@comark/svelte": "45.8k (84 files)",
"@comark/vue": "56.2k (80 files)",
- "comark": "381k (150 files)",
+ "comark": "384k (152 files)",
}
`)
})