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') + }) +})