From d707e3f49e829d5e60065f785e4a3e2b025ba851 Mon Sep 17 00:00:00 2001
From: tubarao312
Date: Wed, 21 Oct 2026 18:31:55 +0000
Subject: [PATCH] feat: expose canonical heading id generator for tree
transforms
---
docs/content/5.reference/1.parse.md | 17 +++++++++++++
packages/comark/src/internal/heading-id.ts | 15 +++++++++++
packages/comark/src/utils/index.ts | 2 ++
.../comark/test/heading-id-generator.test.ts | 25 +++++++++++++++++++
4 files changed, 59 insertions(+)
create mode 100644 packages/comark/test/heading-id-generator.test.ts
diff --git a/docs/content/5.reference/1.parse.md b/docs/content/5.reference/1.parse.md
index b853574ec..3c175d6a0 100644
--- a/docs/content/5.reference/1.parse.md
+++ b/docs/content/5.reference/1.parse.md
@@ -355,6 +355,23 @@ console.timeEnd('createMarkdownParser x1000')
## Options
+### Heading IDs after tree transforms
+
+Parse with `headingIds: false` when a transform removes headings. After selecting the output,
+use `createHeadingIdGenerator` from `comark/utils` in document order. It shares the parser's
+slug, nesting, and duplicate rules. Pass any authored ID as the third argument; that ID wins
+and is reserved against later collisions. Create one generator per document and pass only
+headings that participate in the output. The generator does not traverse the tree or assign
+IDs to raw HTML or code samples.
+
+```ts
+import { createHeadingIdGenerator } from 'comark/utils'
+
+const nextId = createHeadingIdGenerator('nested')
+nextId('Setup', 2) // 'setup'
+nextId('Install', 3) // 'setup-install'
+```
+
Both `parseMarkdown()` and `createMarkdownParser()` accept the same `ParserOptions`:
| Option | Type | Default | Description |
diff --git a/packages/comark/src/internal/heading-id.ts b/packages/comark/src/internal/heading-id.ts
index ed7b00433..b7639751b 100644
--- a/packages/comark/src/internal/heading-id.ts
+++ b/packages/comark/src/internal/heading-id.ts
@@ -1,5 +1,20 @@
import type { HeadingIdsOption } from 'comark'
+/**
+ * Generate heading ids in document order using the parser's slug, hierarchy, and collision rules.
+ * Use with trees parsed with `headingIds: false`; pass authored ids to reserve their destinations.
+ * Each generator owns one document. Call it only for headings participating in the output.
+ */
+export function createHeadingIdGenerator(option: HeadingIdsOption = true) {
+ const tracker = createHeadingIdTracker(option)
+ return (text: string, level: number, authoredId?: string): string | undefined => {
+ if (!tracker) return authoredId
+ const generated = nextHeadingId(text, level, tracker)
+ reserveHeadingId(tracker, authoredId)
+ return authoredId ?? generated
+ }
+}
+
/**
* 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
diff --git a/packages/comark/src/utils/index.ts b/packages/comark/src/utils/index.ts
index 9c2484baf..4a24658bf 100644
--- a/packages/comark/src/utils/index.ts
+++ b/packages/comark/src/utils/index.ts
@@ -354,3 +354,5 @@ export type { ResolveAttributesOptions } from '../internal/stringify/attributes.
// The security plugin's frontmatter rules, for objects the parser never sees.
export { sanitizeData } from '../internal/sanitize.ts'
export type { SanitizeDataOptions } from '../internal/sanitize.ts'
+
+export { createHeadingIdGenerator } from '../internal/heading-id.ts'
diff --git a/packages/comark/test/heading-id-generator.test.ts b/packages/comark/test/heading-id-generator.test.ts
new file mode 100644
index 000000000..eb6adb42e
--- /dev/null
+++ b/packages/comark/test/heading-id-generator.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+import { parseMarkdown } from '../src/parse.ts'
+import { createHeadingIdGenerator, textContent } from '../src/utils/index.ts'
+import type { ElementNode, HeadingIdsOption } from '../src/types.ts'
+
+describe('heading id generator', () => {
+ it.each([true, false, 'nested'])('matches parser allocation with mode %s', async (mode) => {
+ const source = '# Title\n\n## Café {#reserved}\n\n### Child\n\n## Café\n\n## Café-1\n\n## !!!\n\n## 123'
+ const parsed = await parseMarkdown(source, { headingIds: mode })
+ const authored = await parseMarkdown(source, { headingIds: false })
+ const nextId = createHeadingIdGenerator(mode)
+ const ids = authored.nodes.map((node) => {
+ const heading = node as ElementNode
+ return nextId(textContent(heading), Number(heading[0].slice(1)), heading[1].id as string | undefined)
+ })
+ expect(ids).toEqual(parsed.nodes.map((node) => (node as ElementNode)[1].id))
+ })
+
+ it('allocates only selected headings and keeps document state isolated', () => {
+ const nextId = createHeadingIdGenerator('nested')
+ expect(nextId('Visible', 3)).toBe('visible')
+ expect(nextId('Visible', 3)).toBe('visible-1')
+ expect(createHeadingIdGenerator('nested')('Visible', 3)).toBe('visible')
+ })
+})