From 6debde5d853c2ff2377e9b59ff58ec6ae52f1a72 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Wed, 30 Sep 2026 14:00:01 +0200 Subject: [PATCH 1/6] feat: preserve custom heading ids in markdown and add flat headingIds mode --- AGENTS.md | 1 + docs/content/2.syntax/3.attributes.md | 12 +++ docs/content/5.reference/0.render.md | 1 + docs/content/5.reference/1.parse.md | 2 +- docs/content/5.reference/3.reference.md | 2 +- .../comark/references/markdown-syntax.md | 2 +- .../COMARK/heading-attributes-disabled.md | 50 +++++++++ .../SPEC/COMARK/heading-attributes-flat.md | 100 ++++++++++++++++++ .../comark/SPEC/COMARK/heading-attributes.md | 98 +++++++++++++++++ packages/comark/src/internal/heading-id.ts | 75 +++++++++++++ .../src/internal/parse/token-processor.ts | 64 ++--------- .../internal/stringify/handlers/heading.ts | 29 ++++- packages/comark/src/types.ts | 34 ++++-- packages/comark/test/index.test.ts | 7 +- 14 files changed, 403 insertions(+), 74 deletions(-) create mode 100644 packages/comark/SPEC/COMARK/heading-attributes-disabled.md create mode 100644 packages/comark/SPEC/COMARK/heading-attributes-flat.md create mode 100644 packages/comark/SPEC/COMARK/heading-attributes.md create mode 100644 packages/comark/src/internal/heading-id.ts diff --git a/AGENTS.md b/AGENTS.md index 595ef91e..4ec85bc4 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/3.attributes.md b/docs/content/2.syntax/3.attributes.md index 6654e833..6503c159 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 c1b4bb6e..58e23317 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` headings. Set `false` to disable | +| `headingIds` | `boolean \| 'nested' \| 'flat'` | `true` | Auto-generate `id` attributes for `h1`–`h6` headings. `true`/`'nested'` prefixes each id with its parent heading's id (`setup-install`); `'flat'` uses the heading's own text only (`install`); `false` disables auto ids. Duplicates get a `-1`, `-2`, ... suffix | | `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 7132dcae..b32ba385 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 + headingIds?: boolean | 'nested' | 'flat' // default: true ('nested') 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 6bbcf57e..46e5876d 100644 --- a/docs/skills/comark/references/markdown-syntax.md +++ b/docs/skills/comark/references/markdown-syntax.md @@ -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 `

`). Set `headingIds: false` in parse options to disable auto-generated ids. +**Note:** All headings automatically get ID attributes generated from their content for linking (e.g., `# Hello World` becomes `

`). Nested headings are prefixed with their parent's id by default (`setup-install`); set `headingIds: 'flat'` in parse options for plain slugs, or `headingIds: false` to disable auto-generated ids. ### Text Formatting 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.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..24528f09 --- /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 {#npm .highlight} + +### Yarn + +## Usage {.lead data-level="2"} + +## Installation +``` diff --git a/packages/comark/src/internal/heading-id.ts b/packages/comark/src/internal/heading-id.ts new file mode 100644 index 00000000..35b8486b --- /dev/null +++ b/packages/comark/src/internal/heading-id.ts @@ -0,0 +1,75 @@ +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 + 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 !== 'flat', slugCounts: new Map(), stack: [] } +} + +/** + * Convert text to a slug for heading IDs + * Example: "Hello World" -> "hello-world" + * Example: "1. Introduction" -> "_1-introduction" + */ +export function slugify(text: string): string { + let slug = text + .toLowerCase() + .trim() + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/[^\w-]+/g, '') // Remove non-word chars (except hyphens) + .replace(/-{2,}/g, '-') // Replace multiple hyphens with single hyphen + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens + + // Prefix with underscore if starts with a digit (HTML IDs can't start with numbers) + if (/^\d/.test(slug)) { + slug = '_' + slug + } + + return slug +} + +/** + * 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). + */ +export function nextHeadingId(text: string, level: number, tracker: HeadingIdTracker): string { + let slug = slugify(text) + const { stack, slugCounts } = 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) + if (stack.length > 0) { + const parent = stack[stack.length - 1] + if (parent.level >= 2) { + slug = parent.id + '-' + slug + } + } + + // Push onto stack for child headings to reference + stack.push({ level, id: slug }) + } + + const count = slugCounts.get(slug) ?? 0 + slugCounts.set(slug, count + 1) + return count === 0 ? slug : `${slug}-${count}` +} diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index e8c3e001..168b4e7d 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -1,6 +1,7 @@ -import type { ElementNode, Node } from 'comark' +import type { ElementNode, HeadingIdsOption, Node } from 'comark' import { textContent } from 'comark/utils' import { htmlToNodes, parseInlineHtmlTag } from './html/index.ts' +import { createHeadingIdTracker, nextHeadingId, type HeadingIdTracker } from '../heading-id.ts' // `::tag` components that should fold into a single same-tagged child. const WRAPPER_TAGS = new Set(['ul', 'ol', 'table', 'blockquote', 'pre']) @@ -29,10 +30,9 @@ const INLINE_TAG_MAP: Record = { } interface ProcessState { - headingSlugCounts: Map - headingStack: Array<{ level: number; id: string }> + /** `undefined` when auto-generated heading ids are disabled */ + headingIds?: HeadingIdTracker preservePositions: boolean - headingIds: boolean } // ─── main entry point ─────────────────────────────────────────────────────── @@ -40,7 +40,7 @@ interface ProcessState { interface TokenProcessorOptions { startLine?: number preservePositions?: boolean - headingIds?: boolean + headingIds?: HeadingIdsOption } /** @@ -49,10 +49,8 @@ interface TokenProcessorOptions { export function marmdownItTokensToMarkdownDocument(tokens: any[], opts?: TokenProcessorOptions): Node[] { const options = { startLine: 0, preservePositions: false, headingIds: true, ...opts } const state: ProcessState = { - headingSlugCounts: new Map(), - headingStack: [], + headingIds: createHeadingIdTracker(options.headingIds), preservePositions: options.preservePositions, - headingIds: options.headingIds ?? true, } const nodes: Node[] = [] @@ -414,7 +412,7 @@ function processBlockToken( let attrs: Record if (state?.headingIds) { const text = children.nodes.map((n) => textContent(n)).join('') - const headingId = uniqueSlug(slugify(text), level, state) + const headingId = nextHeadingId(text, level, state.headingIds) // Merge user-supplied attrs with the auto-generated id; user `id` wins. attrs = { id: headingId, ...userAttrs } } else { @@ -622,54 +620,6 @@ function mergeAdjacentTextNodes(nodes: Node[]): Node[] { return merged } -/** - * Convert text to a slug for heading IDs - * Example: "Hello World" -> "hello-world" - * Example: "1. Introduction" -> "_1-introduction" - */ -function slugify(text: string): string { - let slug = text - .toLowerCase() - .trim() - .replace(/\s+/g, '-') // Replace spaces with hyphens - .replace(/[^\w-]+/g, '') // Remove non-word chars (except hyphens) - .replace(/-{2,}/g, '-') // Replace multiple hyphens with single hyphen - .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens - - // Prefix with underscore if starts with a digit (HTML IDs can't start with numbers) - if (/^\d/.test(slug)) { - slug = '_' + slug - } - - return slug -} - -/** - * Return a unique slug by appending a numeric suffix for duplicates - */ -function uniqueSlug(slug: string, level: number, state?: ProcessState): string { - if (!state) return slug - // Build hierarchical ID: pop headings at same or deeper level, then prefix with parent's ID - // Pop headings at same level or deeper - 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) - if (state.headingStack.length > 0) { - const parent = state.headingStack[state.headingStack.length - 1] - if (parent.level >= 2) { - slug = parent.id + '-' + slug - } - } - - // Push onto stack for child headings to reference - 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}` -} - export function processInlineTokens(tokens: any[], inHeading: boolean = false): Node[] { const nodes: Node[] = [] let i = 0 diff --git a/packages/comark/src/internal/stringify/handlers/heading.ts b/packages/comark/src/internal/stringify/handlers/heading.ts index 24955961..f974134b 100644 --- a/packages/comark/src/internal/stringify/handlers/heading.ts +++ b/packages/comark/src/internal/stringify/handlers/heading.ts @@ -1,7 +1,27 @@ 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, 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): 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('') + return nextHeadingId(text, level, tracker) +} + // h1, h2, h3, h4, h5, h6 export async function heading(node: ElementNode, state: State) { const [tag] = node @@ -10,9 +30,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) + 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 50ceeae9..f4d7504d 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -221,6 +221,11 @@ export type State = { /** * The context of the renderer */ +/** + * Auto-generated heading id mode. `true` is an alias for `'nested'`. + */ +export type HeadingIdsOption = boolean | 'nested' | 'flat' + export interface RendererOptions { /** * Additional node handlers to pass to the renderer @@ -256,6 +261,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 { @@ -485,19 +497,23 @@ 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` / `'nested'` — slug prefixed with the parent heading's id (h2+ parents) + * - `'flat'` — slug of the heading's own text only + * - `false` — no auto-generated ids + * + * Duplicate ids get a numeric suffix (`-1`, `-2`, ...) in both modes. * * @default true * @example - * // With headingIds: true (default) - * // # Hello World → ['h1', { id: 'hello-world' }, 'Hello World'] - * - * // With headingIds: false - * // # Hello World → ['h1', {}, 'Hello World'] - * // # Hello {id="custom"} → ['h1', { id: 'custom' }, 'Hello'] + * // ## Setup\n### Install + * // 'nested' → ['h2', { id: 'setup' }, ...], ['h3', { id: 'setup-install' }, ...] + * // 'flat' → ['h2', { id: 'setup' }, ...], ['h3', { id: 'install' }, ...] + * // false → ['h2', {}, ...], ['h3', {}, ...] */ - headingIds?: boolean + headingIds?: HeadingIdsOption /** * Whether to register the built-in default plugins diff --git a/packages/comark/test/index.test.ts b/packages/comark/test/index.test.ts index e2d16478..f8498221 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 } From 9b592cfc123c1622d3de2750f76ded96c81d4485 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Wed, 30 Sep 2026 14:15:59 +0200 Subject: [PATCH 2/6] fix: avoid heading id collisions between suffixed duplicates and literal slugs --- packages/comark/src/internal/heading-id.ts | 16 ++++++++++++---- packages/comark/test/heading-ids.test.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/comark/src/internal/heading-id.ts b/packages/comark/src/internal/heading-id.ts index 35b8486b..eecd0e28 100644 --- a/packages/comark/src/internal/heading-id.ts +++ b/packages/comark/src/internal/heading-id.ts @@ -9,6 +9,8 @@ import type { HeadingIdsOption } from 'comark' 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 }> } @@ -18,7 +20,7 @@ export interface HeadingIdTracker { */ export function createHeadingIdTracker(option: HeadingIdsOption = true): HeadingIdTracker | undefined { if (option === false) return undefined - return { nested: option !== 'flat', slugCounts: new Map(), stack: [] } + return { nested: option !== 'flat', slugCounts: new Map(), usedIds: new Set(), stack: [] } } /** @@ -50,7 +52,7 @@ export function slugify(text: string): string { */ export function nextHeadingId(text: string, level: number, tracker: HeadingIdTracker): string { let slug = slugify(text) - const { stack, slugCounts } = tracker + const { stack, slugCounts, usedIds } = tracker if (tracker.nested) { // Pop headings at same level or deeper @@ -69,7 +71,13 @@ export function nextHeadingId(text: string, level: number, tracker: HeadingIdTra stack.push({ level, id: slug }) } - const count = slugCounts.get(slug) ?? 0 + 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) - return count === 0 ? slug : `${slug}-${count}` + usedIds.add(id) + return id } diff --git a/packages/comark/test/heading-ids.test.ts b/packages/comark/test/heading-ids.test.ts index b4730bab..6311dc4a 100644 --- a/packages/comark/test/heading-ids.test.ts +++ b/packages/comark/test/heading-ids.test.ts @@ -36,6 +36,24 @@ describe('headingIds option', () => { expect(ids).toEqual(['options', 'options-1']) }) + it('does not prefix parent ids in flat mode', async () => { + const tree = await parseMarkdown('## Setup\n\n### Install', { headingIds: 'flat' }) + + const ids = tree.nodes.map((n: any) => n[1].id) + expect(ids).toEqual(['setup', 'install']) + }) + + 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', { headingIds: 'flat' }) + 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') + 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', { headingIds: 'flat' }) + expect(reversed.nodes.map((n: any) => n[1].id)).toEqual(['foo-1', 'foo', 'foo-2']) + }) + describe('slug text extraction', () => { it('does not leak tag names from bold text', async () => { const tree = await parseMarkdown('## 1. Never let an LLM **speak** for you') From 3961b555b40161525b815c1695d6de140f3c3ef8 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Wed, 30 Sep 2026 14:18:05 +0200 Subject: [PATCH 3/6] test: add SPEC fixtures for heading id collisions --- .../heading-attributes-flat-collision.md | 100 ++++++++++++++++ .../SPEC/COMARK/headings-ids-collision.md | 107 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 packages/comark/SPEC/COMARK/heading-attributes-flat-collision.md create mode 100644 packages/comark/SPEC/COMARK/headings-ids-collision.md 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/headings-ids-collision.md b/packages/comark/SPEC/COMARK/headings-ids-collision.md new file mode 100644 index 00000000..5ce352c6 --- /dev/null +++ b/packages/comark/SPEC/COMARK/headings-ids-collision.md @@ -0,0 +1,107 @@ +## 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 +``` From a2c753f86ab55f4483123bf5ad07d565f2fc37e0 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Wed, 14 Oct 2026 10:42:48 +0200 Subject: [PATCH 4/6] feat!: default headingIds to flat mode --- docs/content/2.syntax/1.markdown.md | 20 +++++- docs/content/5.reference/1.parse.md | 2 +- docs/content/5.reference/3.reference.md | 2 +- .../comark/references/markdown-syntax.md | 2 +- .../comark/SPEC/COMARK/attributes/headings.md | 16 ++--- .../comark/SPEC/COMARK/heading-attributes.md | 4 +- .../SPEC/COMARK/headings-ids-collision.md | 5 ++ packages/comark/SPEC/COMARK/headings-ids.md | 5 ++ .../comark/SPEC/common-mark/headings-id.md | 16 ++--- packages/comark/SPEC/common-mark/headings.md | 16 ++--- packages/comark/SPEC/common-mark/xyz.md | 8 +-- packages/comark/src/internal/heading-id.ts | 2 +- packages/comark/src/types.ts | 12 ++-- packages/comark/test/heading-ids.test.ts | 64 +++++++++++++------ 14 files changed, 113 insertions(+), 61 deletions(-) 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/5.reference/1.parse.md b/docs/content/5.reference/1.parse.md index c36470a4..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 \| 'nested' \| 'flat'` | `true` | Auto-generate `id` attributes for `h1`–`h6` from visible heading text (not tag or component names). `true`/`'nested'` prefixes each id with its parent heading's id (`setup-install`); `'flat'` uses the heading's own text only (`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. Duplicates get a `-1`, `-2`, ... suffix. An explicit `id` attribute wins. 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 91274a46..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 | 'nested' | 'flat' // default: true ('nested') — 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 f318cf98..0351df0a 100644 --- a/docs/skills/comark/references/markdown-syntax.md +++ b/docs/skills/comark/references/markdown-syntax.md @@ -32,7 +32,7 @@ The standard features: ``` -**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` (`setup-install`); set `headingIds: 'flat'` in parse options for plain slugs. 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.md b/packages/comark/SPEC/COMARK/heading-attributes.md index 24528f09..0103b656 100644 --- a/packages/comark/SPEC/COMARK/heading-attributes.md +++ b/packages/comark/SPEC/COMARK/heading-attributes.md @@ -88,9 +88,9 @@ ## Installation -### Npm {#npm .highlight} +### Npm {.highlight} -### Yarn +### Yarn {#installation-yarn} ## Usage {.lead data-level="2"} diff --git a/packages/comark/SPEC/COMARK/headings-ids-collision.md b/packages/comark/SPEC/COMARK/headings-ids-collision.md index 5ce352c6..6099a5fb 100644 --- a/packages/comark/SPEC/COMARK/headings-ids-collision.md +++ b/packages/comark/SPEC/COMARK/headings-ids-collision.md @@ -1,3 +1,8 @@ +--- +options: + headingIds: nested +--- + ## Input ```md 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 index 885cd109..579c16c8 100644 --- a/packages/comark/src/internal/heading-id.ts +++ b/packages/comark/src/internal/heading-id.ts @@ -20,7 +20,7 @@ export interface HeadingIdTracker { */ export function createHeadingIdTracker(option: HeadingIdsOption = true): HeadingIdTracker | undefined { if (option === false) return undefined - return { nested: option !== 'flat', slugCounts: new Map(), usedIds: new Set(), stack: [] } + return { nested: option === 'nested', slugCounts: new Map(), usedIds: new Set(), stack: [] } } /** diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts index f1e3eb14..162f1234 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -223,7 +223,7 @@ export type State = { * The context of the renderer */ /** - * Auto-generated heading id mode. `true` is an alias for `'nested'`. + * Auto-generated heading id mode. `true` is an alias for `'flat'`. */ export type HeadingIdsOption = boolean | 'nested' | 'flat' @@ -506,11 +506,13 @@ export interface ParserOptions[ * How to auto-generate `id` attributes for `h1`–`h6` headings from their text content. * User-supplied `id` attributes are always preserved. * - * - `true` / `'nested'` — slug prefixed with the parent heading's id (h2+ parents) - * - `'flat'` — slug of the heading's own text only + * - `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 * - * Duplicate ids get a numeric suffix (`-1`, `-2`, ...) in both modes. + * 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, @@ -520,8 +522,8 @@ export interface ParserOptions[ * @default true * @example * // ## Setup\n### Install - * // 'nested' → ['h2', { id: 'setup' }, ...], ['h3', { id: 'setup-install' }, ...] * // 'flat' → ['h2', { id: 'setup' }, ...], ['h3', { id: 'install' }, ...] + * // 'nested' → ['h2', { id: 'setup' }, ...], ['h3', { id: 'setup-install' }, ...] * // false → ['h2', {}, ...], ['h3', {}, ...] * * // # Café → ['h1', { id: 'café' }, 'Café'] diff --git a/packages/comark/test/heading-ids.test.ts b/packages/comark/test/heading-ids.test.ts index 9cce36a6..4b0163ac 100644 --- a/packages/comark/test/heading-ids.test.ts +++ b/packages/comark/test/heading-ids.test.ts @@ -20,37 +20,63 @@ 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('deduplicates hierarchical ids with a numeric suffix', async () => { - const tree = await parseMarkdown('## Options\n\n## Options') + 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(['options', 'options-1']) + expect(ids).toEqual(['setup', 'install']) }) - it('does not prefix parent ids in flat mode', async () => { - const tree = await parseMarkdown('## Setup\n\n### Install', { headingIds: 'flat' }) + 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(['setup', 'install']) + 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 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(['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', { headingIds: 'flat' }) + 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') + 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', { headingIds: 'flat' }) + 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']) }) @@ -120,28 +146,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]) From 2f1f5bf84333b5524020fe94a4466d3e149d45e1 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Wed, 14 Oct 2026 10:50:42 +0200 Subject: [PATCH 5/6] fix: reserve explicit heading ids when generating new ones --- packages/comark/src/internal/heading-id.ts | 9 +++++++++ packages/comark/src/internal/parse/tree.ts | 3 ++- .../src/internal/stringify/handlers/heading.ts | 11 +++++++---- packages/comark/test/heading-ids.test.ts | 13 +++++++++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/comark/src/internal/heading-id.ts b/packages/comark/src/internal/heading-id.ts index 579c16c8..ed7b0043 100644 --- a/packages/comark/src/internal/heading-id.ts +++ b/packages/comark/src/internal/heading-id.ts @@ -57,6 +57,15 @@ export function slugify(text: string): string { 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 diff --git a/packages/comark/src/internal/parse/tree.ts b/packages/comark/src/internal/parse/tree.ts index bcea4fe4..943fbbfe 100644 --- a/packages/comark/src/internal/parse/tree.ts +++ b/packages/comark/src/internal/parse/tree.ts @@ -1,7 +1,7 @@ import { Token } from 'markdown-exit' import type { CommentNode, ElementNode, ElementNodeAttributes, HeadingIdsOption, Node } from 'comark' import { extractAttributes, mergeAdjacentTextNodes, parseCodeblockInfo, processAttributes } from './tree-utils.ts' -import { createHeadingIdTracker, nextHeadingId, type HeadingIdTracker } from '../heading-id.ts' +import { createHeadingIdTracker, nextHeadingId, reserveHeadingId, type HeadingIdTracker } from '../heading-id.ts' import { parseHtmlInline } from './utils.ts' import { textContent } from 'comark/utils' @@ -463,6 +463,7 @@ const processors: Record = { const level = Number.parseInt((tokens[start].tag || 'h1').replace('h', ''), 10) || 1 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) } } diff --git a/packages/comark/src/internal/stringify/handlers/heading.ts b/packages/comark/src/internal/stringify/handlers/heading.ts index f974134b..1b678dc0 100644 --- a/packages/comark/src/internal/stringify/handlers/heading.ts +++ b/packages/comark/src/internal/stringify/handlers/heading.ts @@ -1,14 +1,14 @@ import type { State } from 'comark/render' import type { ElementNode, HeadingIdsOption } from 'comark' import { textContent } from '../../../utils/index.ts' -import { createHeadingIdTracker, nextHeadingId, type HeadingIdTracker } from '../../heading-id.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): string | undefined { +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 @@ -19,7 +19,10 @@ function autoHeadingId(node: ElementNode, level: number, state: State): string | .slice(2) .map((child) => textContent(child as ElementNode)) .join('') - return nextHeadingId(text, level, tracker) + 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 @@ -32,7 +35,7 @@ export async function heading(node: ElementNode, state: State) { // 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) + const autoId = autoHeadingId(node, level, state, id) const attrs = comarkAttributes(id === undefined || id === autoId ? rest : { id, ...rest }) const suffix = attrs ? ` ${attrs}` : '' diff --git a/packages/comark/test/heading-ids.test.ts b/packages/comark/test/heading-ids.test.ts index 4b0163ac..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 () => { @@ -80,6 +81,18 @@ describe('headingIds option', () => { 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', () => { it('does not leak tag names from bold text', async () => { const tree = await parseMarkdown('## 1. Never let an LLM **speak** for you') From 3b7a887785270ac66f4f333b4830a19ec5ec7361 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:15:20 +0000 Subject: [PATCH 6/6] test: update bundle size snapshot --- test/bundle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)", } `) })