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: