diff --git a/LICENSE-THIRD-PARTY.md b/LICENSE-THIRD-PARTY.md index ec3f1f3bff..4fef3cdda8 100644 --- a/LICENSE-THIRD-PARTY.md +++ b/LICENSE-THIRD-PARTY.md @@ -34,8 +34,29 @@ sources, and the licenses they ship under. All entries are MIT-compatible. `packages/components/src/ui/` but are required by the AI Elements layer. - **Local modifications:** import path rewrites only (see above). +## github-slugger + +- **Upstream:** +- **License:** ISC (c) Dan Flettre. MIT-compatible. +- **Vendored into:** `scripts/github-slug.mjs` +- **What:** the generated character table `slug()` strips, plus the `slug()` and + duplicate-suffix (`-1`/`-2`) logic built on it. +- **Rationale:** `scripts/check-doc-links.mjs` resolves heading anchors, so it + needs the exact rule both renderers use — and it is one of the gates a + workflow runs BEFORE `pnpm install`, so it may not import a package. The + table is copied rather than re-derived because a Unicode property escape is + not equivalent to it; the vendored file's header has the measurement. +- **Local modifications:** ESM export shape only — the class is a named export + and drops the unused `maintainCase` / `reset()` surface. The character table + is byte-for-byte upstream. +- **Re-sync command:** copy the `regex` export out of the installed + `github-slugger/regex.js` (already a dependency of `@object-ui/plugin-markdown` + and of `fumadocs-core`), then run + `pnpm exec vitest run scripts/__tests__/check-doc-links.test.ts` — it pins this + copy against the real package over every code point. + --- If you add another upstream-sourced file under `packages/`, append it to this list with the same shape: upstream link, license, files affected, and any -local edits. +local edits. The same applies to repo tooling under `scripts/`. diff --git a/docs/audits/2026-08-zod-to-json-schema-fidelity.md b/docs/audits/2026-08-zod-to-json-schema-fidelity.md index 0887b5def6..f25121883e 100644 --- a/docs/audits/2026-08-zod-to-json-schema-fidelity.md +++ b/docs/audits/2026-08-zod-to-json-schema-fidelity.md @@ -24,7 +24,7 @@ artifact are proposed or added by it. The fork stays open. | Added to the published package | **+21.5 %** of `dist` pretty, **+12.2 %** minified | | Is the artifact closed? | **No.** 276 of 333 objects are open; every one of the 105 node roots is open; `.strict()` is called **0** times in the mirrors | | Is it stricter than zod anywhere? | **No — 0 / 475** corpus files. It is a strict *relaxation* | -| Would objectui#5127's skipped files gain judgement? | **Partly, and not because of the artifact** — see [Q3](#q3-the-5127-judgement-surface) | +| Would objectui#5127's skipped files gain judgement? | **Partly, and not because of the artifact** — see [Q3](#q3-the-objectui5127-judgement-surface) | **The headline.** The generated artifact is a **strict relaxation** of the zod source: over the repo's 475 root-`type` JSON files it agrees with diff --git a/package.json b/package.json index a5031c9886..769ba7e69a 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "react-dom": "19.2.8", "react-i18next": "^17.0.11", "react-router-dom": "^7.18.2", + "remark-mdx": "^3.1.1", "rollup-plugin-visualizer": "^7.1.1", "tailwindcss": "^4.3.3", "tslib": "^2.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b9636067b..ed985a61a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: react-router-dom: specifier: ^7.18.2 version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + remark-mdx: + specifier: ^3.1.1 + version: 3.1.1 rollup-plugin-visualizer: specifier: ^7.1.1 version: 7.1.1(rolldown@1.2.7)(rollup@4.62.2) diff --git a/scripts/__tests__/check-doc-links.test.ts b/scripts/__tests__/check-doc-links.test.ts index 8c45c105e4..2f7b4f1458 100644 --- a/scripts/__tests__/check-doc-links.test.ts +++ b/scripts/__tests__/check-doc-links.test.ts @@ -5,18 +5,23 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + import { SCAN_ROOTS, collectBrokenLinks, collectFiles, collectSiteRoutes, diskPathExists, + headingAnchors, routeExists, selfRepoPath, siteAbsoluteRoute, siteUrlExists, stripCode, } from '../check-doc-links.mjs'; +import { GithubSlugger as VendoredSlugger, slug as vendoredSlug } from '../github-slug.mjs'; /** * objectui#3479 — the behaviour test for `scripts/check-doc-links.mjs`. @@ -311,14 +316,26 @@ describe('relative hrefs are resolved — the hole objectui#3479 closed', () => ).toEqual(['./b.md']); }); - it('ignores the fragment and query when resolving', () => { + it('resolves the fragment against the target document, and ignores the query', () => { + // Was 'ignores the fragment and query when resolving' — the fragment half + // is objectui#7644's, and this fixture used to pass BECAUSE `#a-section` + // was never looked at. `b.md` now has the heading it names. expect( brokenHrefs({ 'guide/a.md': '[b](./b.md#a-section) and [c](./b.md?x=1)', - 'guide/b.md': '# B', + 'guide/b.md': '# B\n\n## A section', }), ).toEqual([]); }); + + it('reports a cross-file fragment naming a heading the target does not have', () => { + expect( + brokenHrefs({ + 'guide/a.md': '[b](./b.md#a-section)', + 'guide/b.md': '# B\n\n## Another section', + }), + ).toEqual(['./b.md#a-section']); + }); }); describe('absolute /docs hrefs stay strict — they are routes, not files', () => { @@ -362,10 +379,15 @@ describe('absolute /docs hrefs stay strict — they are routes, not files', () = }); describe('external and non-path hrefs are left alone', () => { - it('skips schemes, in-page anchors and empty targets', () => { + it('skips schemes and empty targets, and resolves an in-page anchor', () => { + // Was 'skips schemes, in-page anchors and empty targets'. Anchors stopped + // being skipped in objectui#7644; the other three shapes are unchanged, and + // the heading this one names is now in the fixture. expect( brokenHrefs({ 'guide/a.md': [ + '# A', + '## Section', '[web](https://example.com/nope)', '[mail](mailto:a@b.c)', '[tel](tel:+1000)', @@ -374,6 +396,12 @@ describe('external and non-path hrefs are left alone', () => { }), ).toEqual([]); }); + + it('reports an in-page anchor with no matching heading — the objectui#7644 hole', () => { + expect( + brokenHrefs({ 'guide/a.md': '# A\n\n## Section\n\n[anchor](#renamed-section)' }), + ).toEqual(['#renamed-section']); + }); }); describe('code is stripped before scanning — markdown syntax quoted in code is not a link', () => { @@ -855,14 +883,21 @@ describe('examples/** and the root README are scanned as DISK paths — objectui ).toEqual([['/packages/core', 'example-absolute']]); }); - it('leaves external URLs and in-page anchors alone here too', () => { + it('leaves external URLs alone here too, and resolves the in-page anchor', () => { expect( rejections({ - 'examples/README.md': '[web](https://example.com/nope) [mail](mailto:a@b.c) [top](#quick-start)', + 'examples/README.md': + '# Examples\n\n## Quick start\n\n[web](https://example.com/nope) [mail](mailto:a@b.c) [top](#quick-start)', }), ).toEqual([]); }); + it('reports a dead in-page anchor on a disk surface too', () => { + expect(rejections({ 'examples/README.md': '# Examples\n\n## Quick start\n\n[top](#quickstart)' })).toEqual([ + ['#quickstart', 'anchor'], + ]); + }); + it('scans every markdown file under examples/, not only README.md', () => { expect(rejections({ 'examples/hello-world/NOTES.md': '[gone](./nowhere.md)' })).toEqual([ ['./nowhere.md', 'example-relative'], @@ -1022,10 +1057,23 @@ describe('CONTRIBUTING.md, ROADMAP.md and docs/** joined the disk surface — ob '[Commit Guidelines](#commit-guidelines)', '[Conventional Commits](https://www.conventionalcommits.org/)', '[Changesets](https://github.com/changesets/changesets)', + '## Getting Started', + '## Commit Guidelines', ].join('\n\n'), }), ).toEqual([]); }); + + it("resolves this surface's table-of-contents anchors rather than skipping them", () => { + // The two headings above are what makes the case above pass now. Drop one + // and the link to it is reported — which is the whole point of the check + // objectui#7644 added, on the surface that is mostly such links. + expect( + rejections({ + 'CONTRIBUTING.md': ['[Getting Started](#getting-started)', '## Commit Guidelines'].join('\n\n'), + }), + ).toEqual([['#getting-started', 'anchor']]); + }); }); describe("this repo's own GitHub blob/tree URLs are resolved offline — objectui#3536 extension 2", () => { @@ -1067,9 +1115,11 @@ describe("this repo's own GitHub blob/tree URLs are resolved offline — objectu ).toEqual([]); }); - it('ignores the fragment and the query — anchors are out of scope', () => { - // `#L42` / `?plain=1` would need the target parsed, which is a different - // gate. The path in front of them is still checked. + it('resolves a markdown fragment, keeps ignoring the query, and still checks the path', () => { + // Was 'ignores the fragment and the query — anchors are out of scope'. The + // "different gate" that comment deferred to is objectui#7644, and it is + // this one: a fragment on one of these URLs is resolved whenever the path + // names markdown in this tree. `?plain=1` is still ignored. expect( rejections({ ...SITE_FIXTURE, @@ -1078,7 +1128,7 @@ describe("this repo's own GitHub blob/tree URLs are resolved offline — objectu '[raw](https://github.com/objectstack-ai/objectui/blob/main/packages/core/README.md?plain=1)', '[dead](https://github.com/objectstack-ai/objectui/blob/main/packages/gone/README.md#install)', ].join('\n\n'), - 'packages/core/README.md': '# Core', + 'packages/core/README.md': '# Core\n\n## Install', }), ).toEqual([['https://github.com/objectstack-ai/objectui/blob/main/packages/gone/README.md#install', 'self-repo-url']]); }); @@ -1160,6 +1210,7 @@ describe('every failure carries the reason that rejected it', () => { .matchAll(/^\s{2}'?([a-z-]+)'?:/gm); expect([...hinted].map((match) => match[1]).sort()).toEqual([ + 'anchor', 'docs-route', 'escapes-collection', 'example-absolute', @@ -1269,10 +1320,15 @@ describe("this site's own absolute URLs are resolved as internal routes — obje ).toEqual([]); }); - it('ignores the fragment and the query on a site URL — anchors are out of scope', () => { + it('resolves the fragment on a site URL too, and still ignores the query', () => { + // Was 'anchors are out of scope'. The origin is stripped, the route is + // resolved to its file, and objectui#7644 then reads that file's headings — + // so this shape is judged exactly as the origin-less spelling is. expect( brokenHrefs({ 'guide/a.md': [ + '# A', + '## Install', '[live](https://www.objectui.org/docs/guide/a#install)', '[live q](https://www.objectui.org/docs/guide/a?x=1)', '[dead](https://www.objectui.org/docs/gone#install)', @@ -1281,6 +1337,14 @@ describe("this site's own absolute URLs are resolved as internal routes — obje ).toEqual(['https://www.objectui.org/docs/gone#install']); }); + it('reports a site URL whose route resolves but whose anchor does not', () => { + expect( + brokenHrefs({ + 'guide/a.md': '# A\n\n## Install\n\n[moved](https://www.objectui.org/docs/guide/a#installation)', + }), + ).toEqual(['https://www.objectui.org/docs/guide/a#installation']); + }); + it('exposes siteAbsoluteRoute: the path it extracts, and the shapes it declines', () => { expect(siteAbsoluteRoute('https://www.objectui.org/docs/guide/plugins')).toBe('/docs/guide/plugins'); expect(siteAbsoluteRoute('https://objectui.org/api/core')).toBe('/api/core'); @@ -1850,3 +1914,248 @@ describe('objectui#6026 — the nested READMEs', () => { expect(rowsByFile.size, 'floor under the floor: nothing scanned would make the line above vacuous').toBeGreaterThanOrEqual(250); }); }); + +/** + * objectui#7644 — the `#fragment`, which nothing had ever resolved. + * + * The gap was measured as a two-arm control on `content/docs/api/schema-reference.md`: + * the same invocation exited 1 on a broken file path and 0 on a broken in-page + * anchor. These pin the second arm, in every shape that reaches the check, plus + * the thing the check is only as good as — its slug rule. + */ +describe('in-page and cross-document anchors are resolved — objectui#7644', () => { + it('resolves a duplicate heading the way the slugger disambiguates it', () => { + expect( + brokenHrefs({ + 'guide/a.md': ['# A', '## Options', '## Options', '[first](#options)', '[second](#options-1)'].join('\n\n'), + }), + ).toEqual([]); + }); + + it('reports the suffix the slugger never handed out', () => { + expect( + brokenHrefs({ 'guide/a.md': ['# A', '## Options', '[third](#options-2)'].join('\n\n') }), + ).toEqual(['#options-2']); + }); + + it('does not take a heading out of a fenced code block', () => { + expect( + brokenHrefs({ + 'guide/a.md': ['# A', FENCE + 'md', '## Fenced heading', FENCE, '[x](#fenced-heading)'].join('\n'), + }), + ).toEqual(['#fenced-heading']); + }); + + it('does not take a heading out of YAML frontmatter, whose comments start with #', () => { + expect( + brokenHrefs({ + 'guide/a.md': ['---', 'title: A', '# just a yaml comment', '---', '# Real', '[x](#just-a-yaml-comment)'].join( + '\n', + ), + }), + ).toEqual(['#just-a-yaml-comment']); + }); + + it("slugs a heading's inline code as literal text, angle brackets included", () => { + // `### \`objectui add \`` — seven headings in this repo have this + // shape, and stripping tag-shaped text inside a code span would break them. + expect( + brokenHrefs({ + 'guide/a.md': ['# A', '## `objectui add `', '[x](#objectui-add-component)'].join('\n\n'), + }), + ).toEqual([]); + }); + + it('honours fumadocs `[#custom-id]` inside content/docs, and not on a disk surface', () => { + // fumadocs' `remarkHeading` reads the override off the heading's last text + // node; GitHub has no such syntax, so on a README the brackets are just text. + expect(brokenHrefs({ 'guide/a.md': ['# A', '## Long Title [#short]', '[x](#short)'].join('\n\n') })).toEqual([]); + expect( + rejections({ 'docs/a.md': ['# A', '## Long Title [#short]', '[x](#short)'].join('\n\n') }), + ).toEqual([['#short', 'anchor']]); + }); + + it('decodes a percent-encoded fragment before comparing', () => { + expect(brokenHrefs({ 'guide/a.md': ['# A', '## Café', '[x](#caf%C3%A9)'].join('\n\n') })).toEqual([]); + }); + + it('judges nothing when the href names no anchor at all', () => { + expect(brokenHrefs({ 'guide/a.md': '# A\n\n[x](./b.md#)', 'guide/b.md': '# B' })).toEqual([]); + }); + + it('resolves a fragment on this repo’s own blob URL, and skips a line reference', () => { + // The objectui#3536 waiver this retires said anchors here were "a different + // gate". Three such links are live in this tree and all three resolve. + expect( + rejections({ + ...SITE_FIXTURE, + 'content/docs/guide/a.md': [ + '[ok](https://github.com/objectstack-ai/objectui/blob/main/packages/core/README.md#install)', + '[line](https://github.com/objectstack-ai/objectui/blob/main/packages/core/README.md#L42)', + '[gone](https://github.com/objectstack-ai/objectui/blob/main/packages/core/README.md#uninstall)', + ].join('\n\n'), + 'packages/core/README.md': '# Core\n\n## Install', + }).map(([href, reason]) => [href.split('#')[1], reason]), + ).toEqual([['uninstall', 'anchor']]); + }); + + it('has no opinion on a fragment whose target is not markdown it can read', () => { + // A site route outside the collection is TSX, and a non-markdown file has no + // headings. Neither is a pass dressed as one — the gate simply cannot decide. + expect( + rejections({ + ...SITE_FIXTURE, + 'content/docs/guide/a.md': '[play](/playground#tab-two)', + 'examples/demo/README.md': '[src](./App.tsx#L10) and [img](../../apps/site/public/img/guide/shot.png#x)', + 'examples/demo/App.tsx': 'export default null', + }), + ).toEqual([]); + }); + + it('checks the DOCUMENT before the anchor — a dead path is still a dead path', () => { + expect(brokenHrefs({ 'guide/a.md': '[x](./gone.md#section)' })).toEqual(['./gone.md#section']); + }); +}); + +describe('the anchor rule is the renderers’ own, not this gate’s — objectui#7644', () => { + // `scripts/github-slug.mjs` is a COPY of github-slugger, because this gate runs + // before `pnpm install` and may not import a package (see + // `check-pre-install-import-graph.mjs --list`). A copy is only safe while + // something proves it still equals the original — that is what this block is. + // Both truth sources are resolved from the workspace package that DECLARES + // them, so neither is a phantom dependency of the repo root: + // fumadocs-core — declared by `apps/site`, the docs renderer itself + // github-slugger — declared by `@object-ui/plugin-markdown` + const siteRequire = createRequire(path.join(repoRoot, 'apps/site/package.json')); + const markdownRequire = createRequire(path.join(repoRoot, 'packages/plugin-markdown/package.json')); + + const loadToc = async (): Promise<(content: string, plugins: unknown[]) => Promise<{ url: string; title: string }[]>> => + (await import(pathToFileURL(siteRequire.resolve('fumadocs-core/content/toc')).href)).getTableOfContents; + const loadSlugger = async (): Promise { slug(value: string): string }> => + (await import(pathToFileURL(markdownRequire.resolve('github-slugger')).href)).default; + const loadMdx = async (): Promise => (await import('remark-mdx')).default; + + /** The document body fumadocs-mdx hands the remark pipeline: frontmatter gone. */ + function documentBody(raw: string): string { + const lines = raw.split('\n'); + if (!/^---\s*$/.test(lines[0] ?? '')) return raw; + for (let index = 1; index < lines.length; index += 1) { + // Blank the frontmatter rather than dropping it, so line numbers survive. + if (/^(?:---|\.\.\.)\s*$/.test(lines[index])) return '\n'.repeat(index + 1) + lines.slice(index + 1).join('\n'); + } + return raw; + } + + it('reproduces github-slugger exactly, over every non-surrogate code point', async () => { + // The tempting shortcut — `/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu` — is not + // equivalent, and this is the assertion that says so: it disagrees on the + // `No` digits (`²`, `¹`, `¼`) and on every letter Unicode has added since + // the table was generated. Character by character, no room for a near-miss. + const Slugger = await loadSlugger(); + const differing: string[] = []; + let compared = 0; + for (let point = 0; point <= 0x10ffff; point += 1) { + if (point >= 0xd800 && point <= 0xdfff) continue; // lone surrogates are not characters + const char = String.fromCodePoint(point); + compared += 1; + if (vendoredSlug(char) !== new Slugger().slug(char)) { + differing.push(`U+${point.toString(16).toUpperCase()}`); + if (differing.length > 8) break; + } + } + expect(differing).toEqual([]); + expect(compared).toBe(1_112_064); // a lit instrument, not an empty loop + }); + + it('reproduces the duplicate-heading counter, which is what makes `-1` resolvable', async () => { + const Slugger = await loadSlugger(); + const upstream = new Slugger(); + const vendored = new VendoredSlugger(); + const headings = ['Options', 'Options', 'options', 'Options!', 'Options 1', 'Options', 'Other']; + expect(headings.map((heading) => vendored.slug(heading))).toEqual(headings.map((heading) => upstream.slug(heading))); + }); + + it('agrees with the real renderers on every heading in the scan surface', async () => { + // The corpus check, and the reason the hand-written flattener in + // `check-doc-links.mjs` is safe to hand-write. Truth per rule: + // docs — fumadocs' own `getTableOfContents`, MDX-parsed for `.mdx` exactly + // as `fumadocs-mdx` compiles it. Without `remark-mdx` a JSX element + // followed by a heading with no blank line between them is one HTML + // block, and four real files here have that shape. + // disk — GitHub, i.e. the real `github-slugger` run over the same flattened + // heading titles, in document order. + const [getTableOfContents, Slugger, remarkMdx] = await Promise.all([loadToc(), loadSlugger(), loadMdx()]); + const disagreeing: string[] = []; + let docsFiles = 0; + let diskFiles = 0; + + for (const row of SCAN_ROOTS as ScanRoot[]) { + const files = collectFiles( + path.join(repoRoot, row.path), + row.exclude ? new Set(row.exclude) : undefined, + row.collect ? new Set(row.collect) : undefined, + ) as string[]; + for (const file of files) { + const raw = fs.readFileSync(file, 'utf8'); + const toc = await getTableOfContents(documentBody(raw), file.endsWith('.mdx') ? [remarkMdx] : []); + let expected: Set; + if (row.rule === 'docs') { + docsFiles += 1; + expected = new Set(toc.map((item) => item.url.slice(1))); + } else { + diskFiles += 1; + const slugger = new Slugger(); + expected = new Set(toc.map((item) => slugger.slug(item.title))); + } + const actual = headingAnchors(raw, { customId: row.rule === 'docs' }) as Set; + const only = [...expected].filter((id) => !actual.has(id)).concat([...actual].filter((id) => !expected.has(id))); + if (only.length > 0) disagreeing.push(`${path.relative(repoRoot, file)}: ${only.join(', ')}`); + } + } + + expect(disagreeing).toEqual([]); + // A zero above is only a reading if something was actually compared. + expect(docsFiles).toBeGreaterThan(150); + expect(diskFiles).toBeGreaterThan(50); + }); + + it('has no setext headings, which the ATX-only scan would not see', async () => { + // `Title` over `---` is a heading too, and `headingAnchors()` does not read + // one. Nothing in this tree writes them; this is what says so out loud, so + // the day someone does the answer is a red test rather than a missing anchor. + const getTableOfContents = await loadToc(); + const remarkMdx = await loadMdx(); + const setext: string[] = []; + for (const row of SCAN_ROOTS as ScanRoot[]) { + const files = collectFiles( + path.join(repoRoot, row.path), + row.exclude ? new Set(row.exclude) : undefined, + row.collect ? new Set(row.collect) : undefined, + ) as string[]; + for (const file of files) { + const raw = fs.readFileSync(file, 'utf8'); + const body = documentBody(raw); + const lines = body.split('\n'); + const toc = await getTableOfContents(body, file.endsWith('.mdx') ? [remarkMdx] : []); + // Every heading fumadocs found must have been written in ATX form; the + // TOC carries no position, so compare counts against the ATX scan. + const atx = lines.filter((line, index) => /^#{1,6}\s+\S/.test(line) && !inFence(lines, index)).length; + if (toc.length > atx) setext.push(`${path.relative(repoRoot, file)}: ${toc.length} headings, ${atx} ATX`); + } + } + expect(setext).toEqual([]); + }); +}); + +/** Is `lines[target]` inside a fenced code block? */ +function inFence(lines: string[], target: number): boolean { + let fence: string | null = null; + for (let index = 0; index < target; index += 1) { + const match = /^\s{0,3}(`{3,}|~{3,})/.exec(lines[index]); + if (!match) continue; + const marker = match[1][0]; + if (fence === null) fence = marker; + else if (marker === fence) fence = null; + } + return fence !== null; +} diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index 644aeeba59..f7e6a71192 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -174,9 +174,11 @@ * - **Only `main`.** `blob/v1.2.0/...` or `blob//...` names a different * ref; the working tree cannot answer for it. * - **Only this repo.** Other repos' GitHub URLs stay external (lychee's job). - * - **Only the path.** `#fragment` (`#L42`, `#readme`) is out of scope, and - * stripped before the check — resolving an anchor means parsing the target, - * which is a different gate. + * - **Only the path** — WAS. Anchors were out of scope when this bullet was + * written, "because resolving an anchor means parsing the target, which is + * a different gate". objectui#7644 built that gate, so a `#fragment` on one + * of these URLs IS resolved now, whenever the path names a markdown file in + * this tree; a `#L42` line reference still is not. See that card's section. * - **Only `blob|tree`.** `https://github.com/objectstack-ai/objectui/issues` * and the `workflows//badge.svg` badge URLs in the root README are * github.com web routes, not paths in this tree, and stay skipped. @@ -493,6 +495,88 @@ * one today; the exclusion is about what the name means (changesets output, * never authored prose), not about where the file sits. * + * ## Why this file changed again (objectui#7644): the `#fragment`, at last + * + * Everything above resolves the DOCUMENT an href names. Nothing resolved the + * ANCHOR inside it. `hrefPath()` and `routeExists()` both opened by cutting the + * href at its `#`, and `EXTERNAL_HREF_RE` waved a pure `#anchor` through by + * shape, so `[label](#some-heading)` was never judged by anything at all. + * + * The consequence is the failure this whole file exists to prevent, one level + * in: rename a heading and every cross-reference to it is orphaned silently, + * with this gate green. It is not a projection — objectui#7643 renamed + * `### KanbanSchema` to `### DeclarativeKanbanSchema` and the + * `[…](#kanbanschema)` one screen away had to be corrected BY HAND, because no + * gate in the farm could see it. + * + * objectui#7644 measured the hole as a two-arm control on one file, and the + * control arm is what makes the zero a reading rather than a dead instrument: + * the SAME invocation, on the SAME file, exited 1 on a broken file path + * (`Relative links must name the target FILE`) and 0 on a broken in-page + * anchor. Reproduced on this branch before the fix, with each mutation proved + * on disk by anchored count plus `git hash-object` off the HEAD blob. + * + * ### The slug rule is the renderers', and it is COPIED rather than re-derived + * + * An anchor set is only as good as the slug function that builds it — a rule + * that disagrees with the renderer is a false red on a correct link, which is + * worse than the hole. Both renderers this repository publishes through use + * `github-slugger`, one instance per document, walked over the headings in + * order so a repeated heading's `-1`/`-2` suffix lines up: + * + * - `content/docs` (`docs` rule) is fumadocs, whose `remarkHeading` slugs + * `flattenNode(heading)` and additionally honours an explicit + * `## Heading [#custom-id]` override on the heading's last text node. No + * file in this tree uses that syntax today; it is implemented anyway, + * because the first author who does would otherwise get a false red. + * - every `disk` surface is GitHub, which is what `github-slugger` exists to + * reproduce, and which has no `[#custom-id]` syntax — so that branch is + * applied to the `docs` rule only. + * + * `scripts/github-slug.mjs` holds the rule, vendored, and its own header says + * why a copy rather than an import: this gate runs BEFORE `pnpm install` (see + * `scripts/check-pre-install-import-graph.mjs --list`), so a package import + * would stop it running on exactly the markdown-only pull requests it exists + * for. The equivalence is not asserted by reading — the pin test compares this + * file's anchor sets against the REAL fumadocs `remarkHeading` and the REAL + * `github-slugger`, over every file in the scan surface. Measured when this + * landed: 273 files, 184 `docs` and 89 `disk`, ZERO disagreements. That corpus + * check is the thing that keeps the copy honest, and it is why the inline + * flattener's one non-obvious rule — a `*`/`_` run is emphasis only when it is + * NOT intraword — is written the way it is rather than the obvious way. The + * obvious way (strip them all) disagreed with mdast on two real headings here, + * `NON_GRID_ROW_CEILING` and a `new_window` in `ROADMAP.md`. + * + * ### What is decided, and what is left alone + * + * A fragment is judged when, and only when, the document it names is a markdown + * file in this tree — same page, another page, or a package README reached + * through this repo's own blob URL. That last one retires a waiver written four + * sections up: the objectui#3536 bullet said a self-repo blob URL's `#fragment` + * was "out of scope … resolving an anchor means parsing the target, which is a + * different gate". This is that gate, so the bullet no longer holds and has + * been corrected in place. Three such links exist today and all three resolve; + * a `#L42`-shaped line reference is still skipped, because it names a line in + * the raw view rather than a heading. + * + * Left undecidable, deliberately: a fragment on a site route outside the docs + * collection (`apps/site/app` is TSX, not headings), on a non-markdown target, + * and on any other origin (lychee's problem). Those return "no opinion", never + * a pass dressed as one. + * + * ### The backlog: counted, and it was one + * + * Turning a check on usually means paying its accumulated red first, and the + * card was dispatched expecting a shrink-only baseline. Counted from the tree + * rather than assumed: 143 hrefs carry a non-empty fragment, 142 of them + * decidable here, and exactly ONE was dead — a `#q3-the-5127-judgement-surface` + * in `docs/audits/2026-08-zod-to-json-schema-fidelity.md` whose heading reads + * `## Q3: the objectui#5127 judgement surface` and therefore slugs to + * `q3-the-objectui5127-judgement-surface`. It is repaired in the same commit, + * so this check lands FULLY STRICT with no baseline row — the objectui#3572 + * shape, and the better outcome of the two the card's ruling allowed for. A + * baseline is a ratchet, not a prize; there was nothing to ratchet. + * * ## Code spans are stripped before scanning * * Required, not tidiness. Extending the scan to relative hrefs turns markdown's @@ -513,6 +597,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { GithubSlugger } from './github-slug.mjs'; const DOCS_ROUTE_PREFIX = '/docs'; const MARKDOWN_LINK_RE = /\[[^\]]+\]\(([^)]+)\)/g; @@ -943,6 +1028,237 @@ export function siteUrlExists(pathname, site) { return site.routes.some((pattern) => matchesPattern(segments, pattern)); } +/** + * A heading line, ATX form. Setext headings (`Title` over `---`) are markdown + * too, but this tree has none — asserted over the whole scan surface by the pin + * test, so the day one is written the test says so rather than this gate + * silently missing its anchor. + */ +const ATX_HEADING_RE = /^(#{1,6})\s+(.*?)\s*$/; +/** A closing sequence of `#`s, which is decoration and not part of the text. */ +const CLOSING_HASHES_RE = /\s+#+\s*$/; +/** fumadocs' explicit heading id: `## Heading [#custom-id]`. */ +const CUSTOM_HEADING_ID_RE = /\s*\[#([^\]]+)\]\s*$/; +/** A document whose headings this gate can read. */ +const MARKDOWN_FILE_RE = /\.mdx?$/i; +/** GitHub's raw-view line reference — names a line, never a heading. */ +const LINE_REF_RE = /^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$/; +/** A letter or digit either side of a `*`/`_` run makes it intraword, not emphasis. */ +const WORD_CHAR_RE = /[\p{L}\p{N}]/u; + +/** + * One heading's inline markdown reduced to the plain text the sluggers see — + * fumadocs' `flattenNode()` over the mdast heading, and the text GitHub renders. + * + * Written by hand for the same reason `github-slug.mjs` is vendored: this file + * may not import a parser. The pin test holds it to the real thing over every + * heading in the repository, which is what makes hand-writing it safe. + * + * Code spans win over every other construct (CommonMark reads them first), and + * their content is LITERAL — `### \`objectui add \`` slugs through + * the angle brackets, so stripping tag-shaped text inside a span would be + * wrong. Seven headings in this tree depend on that. + */ +export function flattenInline(text) { + let out = ''; + let index = 0; + while (index < text.length) { + const char = text[index]; + + if (char === '`') { + let run = 0; + while (text[index + run] === '`') run += 1; + const marker = '`'.repeat(run); + const close = text.indexOf(marker, index + run); + if (close === -1) { + out += marker; // an unclosed run is literal backticks + index += run; + continue; + } + out += text.slice(index + run, close); + index = close + run; + continue; + } + + if (char === '!' && text[index + 1] === '[') { + const image = /^!\[[^\]]*\]\([^)]*\)/.exec(text.slice(index)); + if (image) { + index += image[0].length; // an image node carries no `value` to flatten + continue; + } + } + + if (char === '[') { + const link = /^\[([^\]]*)\]\([^)]*\)/.exec(text.slice(index)); + if (link) { + out += flattenInline(link[1]); // a link flattens to its own text + index += link[0].length; + continue; + } + } + + if (char === '<') { + const tag = /^<[^>]*>/.exec(text.slice(index)); + if (tag) { + index += tag[0].length; + continue; + } + } + + if (char === '*' || char === '_') { + let run = 0; + while (text[index + run] === char) run += 1; + // Intraword runs are not emphasis delimiters — `NON_GRID_ROW_CEILING` and + // `new_window` keep their underscores, and `github-slugger` keeps `_` + // (it is connector punctuation) while dropping `*`. Getting this wrong is + // silent: both spellings still produce A slug, just not the renderer's. + if (WORD_CHAR_RE.test(text[index - 1] ?? '') && WORD_CHAR_RE.test(text[index + run] ?? '')) { + out += char.repeat(run); + } + index += run; + continue; + } + + out += char; + index += 1; + } + return out; +} + +/** + * Every anchor one markdown document offers, in the renderer's own spelling. + * + * @param {string} source the file's raw text, frontmatter included + * @param {{ customId?: boolean }} [options] `customId` enables fumadocs' + * `[#explicit-id]` override; GitHub has no such syntax, so `disk` + * surfaces pass it false. + */ +export function headingAnchors(source, { customId = false } = {}) { + const ids = new Set(); + const slugger = new GithubSlugger(); + const lines = source.split('\n'); + + // YAML frontmatter is not document body, and its comments start with `#` — + // reading it as markdown invents an h1 out of `# some comment`. + let start = 0; + if (/^---\s*$/.test(lines[0] ?? '')) { + for (let index = 1; index < lines.length; index += 1) { + if (/^(?:---|\.\.\.)\s*$/.test(lines[index])) { + start = index + 1; + break; + } + } + } + + let fence = null; + for (let index = start; index < lines.length; index += 1) { + const line = lines[index]; + const fenceMatch = FENCE_RE.exec(line); + if (fenceMatch) { + const marker = fenceMatch[1][0]; + if (fence === null) fence = marker; + else if (marker === fence) fence = null; + continue; + } + if (fence !== null) continue; + + const heading = ATX_HEADING_RE.exec(line); + if (!heading) continue; + const text = heading[2].replace(CLOSING_HASHES_RE, ''); + + if (customId) { + const explicit = CUSTOM_HEADING_ID_RE.exec(text); + // An explicit id does NOT advance the slugger — fumadocs only calls it + // when the heading has no id yet, so the duplicate counter must skip too. + if (explicit) { + ids.add(explicit[1]); + continue; + } + } + + ids.add(slugger.slug(flattenInline(text).trim())); + } + return ids; +} + +/** + * The markdown document a `docs`-rule href names, or `null` when nothing in + * this tree can answer for it. + * + * This is `routeExists()`'s file-producing half, factored out so the two cannot + * drift: that function is now this one plus the site-route table. + */ +export function resolveDocsFile(href, { fromFile, docsRoot }) { + let cleanHref = href.split('#')[0].split('?')[0].trim(); + if (!cleanHref) return fromFile; // a pure in-page anchor names THIS document + try { + cleanHref = decodeURI(cleanHref); + } catch { + /* keep the raw form — a malformed escape is checked as written */ + } + + if (cleanHref === DOCS_ROUTE_PREFIX || cleanHref.startsWith(`${DOCS_ROUTE_PREFIX}/`)) { + const routePath = cleanHref.slice(DOCS_ROUTE_PREFIX.length).replace(/^\//, ''); + return routeCandidates(routePath ? path.join(docsRoot, routePath) : docsRoot).find(isFile) ?? null; + } + // An absolute non-`/docs` href is a site route; `siteUrlExists()` judges it, + // and `apps/site/app` holds TSX rather than headings. + if (cleanHref.startsWith('/')) return null; + + const base = path.resolve(path.dirname(fromFile), cleanHref); + if (base !== docsRoot && !base.startsWith(docsRoot + path.sep)) return null; + return [base, ...routeCandidates(base)].find(isFile) ?? null; +} + +/** The file a `disk`-rule href names, or `null` — `diskPathExists()`'s half. */ +export function resolveDiskFile(href, { fromFile, repoRoot }) { + const cleanHref = hrefPath(href); + if (!cleanHref) return fromFile; // a pure in-page anchor names THIS document + if (cleanHref.startsWith('/')) return null; // github.com's root, not ours + const target = path.resolve(path.dirname(fromFile), cleanHref); + return isInside(repoRoot, target) && isFile(target) ? target : null; +} + +/** The anchor an href asks for, decoded, or `null` when it names none. */ +export function fragmentOf(href) { + const hash = href.indexOf('#'); + if (hash === -1) return null; + const raw = href.slice(hash + 1).trim(); + if (!raw) return null; + try { + return decodeURIComponent(raw); + } catch { + return raw; // a malformed escape is checked as written + } +} + +/** + * Does the anchor an href asks for exist in the document it names? + * + * Returns `'anchor'` when it demonstrably does not, and `null` both when it + * does and when nothing here can decide — the two are different facts, and the + * undecidable ones are enumerated in the header rather than left to inference. + * + * @param {string} href + * @param {string | null} targetFile the document, from one of the resolvers + * @param {{ customId: boolean, anchorCache?: Map> }} options + */ +function judgeFragment(href, targetFile, { customId, anchorCache }) { + const fragment = fragmentOf(href); + if (fragment === null) return null; // no anchor asked for + if (targetFile === null) return null; // no document here to ask + if (!MARKDOWN_FILE_RE.test(targetFile)) return null; // no headings to read + if (LINE_REF_RE.test(fragment)) return null; // a raw-view line, not a heading + + const key = `${customId ? 'custom' : 'plain'}|${targetFile}`; + let anchors = anchorCache?.get(key); + if (!anchors) { + anchors = headingAnchors(readFileSync(targetFile, 'utf8'), { customId }); + anchorCache?.set(key, anchors); + } + return anchors.has(fragment) ? null : 'anchor'; +} + /** * Resolves one href. * @@ -958,18 +1274,12 @@ export function routeExists(href, { fromFile, docsRoot, site }) { /* keep the raw form — a malformed escape is checked as written */ } - if (cleanHref === DOCS_ROUTE_PREFIX || cleanHref.startsWith(`${DOCS_ROUTE_PREFIX}/`)) { - const routePath = cleanHref.slice(DOCS_ROUTE_PREFIX.length).replace(/^\//, ''); - // Route form only — see the header. A `/docs/...` href carrying a file - // extension is a 404 on the site even when that file exists on disk. - return routeCandidates(routePath ? path.join(docsRoot, routePath) : docsRoot).some(isFile); - } - // Site routes outside this docs collection — checked against the enumerated // router + `public/` tree (objectui#3490). No table means the caller has not // supplied a truth source; failing loudly beats silently waving 404s through, - // which is precisely how the 18 links in #3490 accumulated. - if (cleanHref.startsWith('/')) { + // which is precisely how the 18 links in #3490 accumulated. This is the one + // branch `resolveDocsFile()` cannot answer, because a route is not a file. + if (cleanHref.startsWith('/') && !(cleanHref === DOCS_ROUTE_PREFIX || cleanHref.startsWith(`${DOCS_ROUTE_PREFIX}/`))) { if (!site) { throw new Error( `routeExists() needs a site route table to judge the absolute href "${href}". ` + @@ -979,16 +1289,12 @@ export function routeExists(href, { fromFile, docsRoot, site }) { return siteUrlExists(cleanHref, site); } - const base = path.resolve(path.dirname(fromFile), cleanHref); - // A relative href must stay inside the collection: fumadocs' page index is - // the only thing that can resolve one, and it holds nothing else. Resolving - // on disk is not enough — `../../../packages/x/README.md` is a real file and - // still a 404 on the site. See the header. - if (base !== docsRoot && !base.startsWith(docsRoot + path.sep)) return false; - - // File form first (what fumadocs' `resolveHref` keys on), then the - // extensionless-route spellings. - return [base, ...routeCandidates(base)].some(isFile); + // Everything else resolves to a FILE, and `resolveDocsFile()` is the single + // implementation of which one (objectui#7644): route form only under `/docs`, + // no leaving the collection, file spelling before the extensionless route + // spellings. It has to exist for the anchor check, and two copies of that + // ladder is how a gate starts disagreeing with itself. + return resolveDocsFile(href, { fromFile, docsRoot }) !== null; } /** Which of the four checks rejected this href — drives the hint printed below. */ @@ -1015,10 +1321,19 @@ function judgeHref(href, context) { // The two external-LOOKING shapes this script can actually decide run FIRST, // and in every rule: both must be reached before the by-scheme skip below // waves them through (objectui#3536, objectui#3603). + // + // Each branch answers the SAME two questions in order (objectui#7644): does + // the document resolve, and — only once it does — does the anchor inside it. + // The `customId` each passes is the renderer the READER lands in, which is + // not always the rule the linking file was scanned under: a github.com blob + // URL is read on GitHub even when it was written inside `content/docs`, and + // an `objectui.org/docs/...` URL is read in fumadocs even when it was written + // in a package README. const selfPath = selfRepoPath(href); if (selfPath !== null) { const target = path.resolve(context.repoRoot, selfPath); - return isInside(context.repoRoot, target) && pathExists(target) ? null : 'self-repo-url'; + if (!isInside(context.repoRoot, target) || !pathExists(target)) return 'self-repo-url'; + return judgeFragment(href, target, { ...context, customId: false }); } // A URL on this site is an internal route wearing an origin. Strip the origin // and judge what is left with the same `routeExists()` every absolute href @@ -1026,14 +1341,25 @@ function judgeHref(href, context) { // here too, on every surface, `content/docs` included. const siteRoute = siteAbsoluteRoute(href); if (siteRoute !== null) { - return routeExists(siteRoute, context) ? null : 'site-absolute-url'; + if (!routeExists(siteRoute, context)) return 'site-absolute-url'; + // `href` keeps the fragment the origin-stripped route dropped, and + // `fragmentOf()` reads only what follows the `#`, so the raw href is the + // right thing to hand on here. + return judgeFragment(href, resolveDocsFile(siteRoute, context), { ...context, customId: true }); + } + if (EXTERNAL_HREF_RE.test(href)) { + // The one shape this regex catches that names a document we hold: a pure + // in-page anchor, whose document is the file it was written in. + if (!href.startsWith('#')) return null; + return judgeFragment(href, context.fromFile, { ...context, customId: context.rule === 'docs' }); } - if (EXTERNAL_HREF_RE.test(href)) return null; if (context.rule === 'disk') { - return diskPathExists(href, context) ? null : classifyBrokenDisk(href); + if (!diskPathExists(href, context)) return classifyBrokenDisk(href); + return judgeFragment(href, resolveDiskFile(href, context), { ...context, customId: false }); } - return routeExists(href, context) ? null : classifyBroken(href, context); + if (!routeExists(href, context)) return classifyBroken(href, context); + return judgeFragment(href, resolveDocsFile(href, context), { ...context, customId: true }); } /** @@ -1050,6 +1376,11 @@ export function collectBrokenLinks(repoRoot) { const broken = []; const docsRoot = path.join(repoRoot, 'content', 'docs'); const site = collectSiteRoutes(path.join(repoRoot, 'apps', 'site')); + // One anchor set per (document, renderer) per scan, not per href: the + // `**Related:**` lines this card came from point many links at one page. + // Scoped to this call rather than the module, so a caller scanning two + // trees (the pin tests do) can never read the other one's headings. + const anchorCache = new Map(); for (const scanRoot of SCAN_ROOTS) { const exclude = scanRoot.exclude ? new Set(scanRoot.exclude) : undefined; @@ -1061,7 +1392,7 @@ export function collectBrokenLinks(repoRoot) { while ((match = MARKDOWN_LINK_RE.exec(source)) !== null) { const href = match[1].trim(); - const reason = judgeHref(href, { fromFile: file, docsRoot, repoRoot, site, rule: scanRoot.rule }); + const reason = judgeHref(href, { fromFile: file, docsRoot, repoRoot, site, anchorCache, rule: scanRoot.rule }); if (reason === null) continue; broken.push({ file, href, line: source.slice(0, match.index).split('\n').length, reason }); @@ -1115,6 +1446,14 @@ const HINTS = { ' drop the link if no such page exists. Inside content/docs prefer the' + ' origin-less form (`/docs/guide/plugins`): it survives a domain change,' + ' and both spellings are checked identically.', + anchor: + 'The link resolves to the right DOCUMENT but names an `#anchor` that document' + + ' does not have. Heading anchors are derived exactly as the renderer derives' + + ' them (`scripts/github-slug.mjs`), so the usual cause is a heading that was' + + ' RENAMED without its cross-references — the failure objectui#7644 added this' + + ' check for. Fix the fragment to the heading\'s current slug, or restore the' + + ' heading. Inside content/docs an explicit `## Heading [#stable-id]` pins an' + + ' anchor so a future rename cannot break it.', 'self-repo-url': 'A `https://github.com/objectstack-ai/objectui/(blob|tree)/main/...` URL' + ' points into this repository, so its path is checked against the working' + diff --git a/scripts/github-slug.mjs b/scripts/github-slug.mjs new file mode 100644 index 0000000000..386a5bc2dc --- /dev/null +++ b/scripts/github-slug.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * GitHub's heading-anchor slug rule, vendored — the derivation both renderers + * this repository publishes through already use, reproduced here WITHOUT a + * package import. + * + * ## Why a copy rather than `import GithubSlugger from 'github-slugger'` + * + * `scripts/check-doc-links.mjs` is one of the gates `.github/workflows/` runs + * BEFORE `pnpm install` (see `scripts/check-pre-install-import-graph.mjs`, whose + * `--list` names it): its whole static import graph must be node builtins plus + * repo-relative modules, or the gate stops running on exactly the markdown-only + * pull requests it exists for. A `github-slugger` import would be an + * `ERR_MODULE_NOT_FOUND` in that job, and `docs-links.yml` has no install step + * to add one to without giving up that property. + * + * ## Why the table is copied verbatim and not re-derived + * + * The tempting shortcut is a Unicode property escape — + * `/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu` — which is three orders of magnitude + * shorter. It is NOT equivalent, and the difference is silent: compared over all + * 1,112,064 non-surrogate code points it disagrees on the `No` digits + * (`\xB2` `²`, `\xB9` `¹`, `\xBC` `¼`, …), which github-slugger strips and + * `\p{N}` keeps, and on every letter Unicode has added since this table was + * generated (`\u0870`–`\u088F` Arabic Extended-B, `\u0897`–`\u0899`, …), which + * the running Node build classifies as letters and the table does not. Either + * direction produces a WRONG anchor set: a heading whose slug this gate computes + * differently from the renderer is a false red on a correct link, or a false + * green on a dead one. + * + * So the rule is the upstream artefact, byte for byte, and + * `scripts/__tests__/check-doc-links.test.ts` pins it against the REAL + * `github-slugger` — that test runs after an install, so it can import the + * package this file may not. It compares every non-surrogate code point plus a + * corpus of headings; a dependency bump that changes the table turns it red + * instead of quietly changing what this gate believes an anchor is. + * + * Vendored from github-slugger 2.0.0 (ISC © Dan Flettre), which is already a + * dependency of `@object-ui/plugin-markdown` and of `fumadocs-core`. Recorded in + * `LICENSE-THIRD-PARTY.md`. Re-sync: copy the `regex` export out of that + * package's `regex.js` and re-run the pin test. + */ + +/* eslint-disable no-control-regex, no-misleading-character-class, no-useless-escape */ +/** Every character github-slugger deletes. Generated upstream; do not hand-edit. */ +const NON_SLUG_CHARS = /[\0-\x1F!-,\.\/:-@\[-\^`\{-\xA9\xAB-\xB4\xB6-\xB9\xBB-\xBF\xD7\xF7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u02FF\u0375\u0378\u0379\u037E\u0380-\u0385\u0387\u038B\u038D\u03A2\u03F6\u0482\u0530\u0557\u0558\u055A-\u055F\u0589-\u0590\u05BE\u05C0\u05C3\u05C6\u05C8-\u05CF\u05EB-\u05EE\u05F3-\u060F\u061B-\u061F\u066A-\u066D\u06D4\u06DD\u06DE\u06E9\u06FD\u06FE\u0700-\u070F\u074B\u074C\u07B2-\u07BF\u07F6-\u07F9\u07FB\u07FC\u07FE\u07FF\u082E-\u083F\u085C-\u085F\u086B-\u089F\u08B5\u08C8-\u08D2\u08E2\u0964\u0965\u0970\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09F2-\u09FB\u09FD\u09FF\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF0-\u0AF8\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B54\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B70\u0B72-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BF0-\u0BFF\u0C0D\u0C11\u0C29\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5B-\u0C5F\u0C64\u0C65\u0C70-\u0C7F\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0CFF\u0D0D\u0D11\u0D45\u0D49\u0D4F-\u0D53\u0D58-\u0D5E\u0D64\u0D65\u0D70-\u0D79\u0D80\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DE5\u0DF0\u0DF1\u0DF4-\u0E00\u0E3B-\u0E3F\u0E4F\u0E5A-\u0E80\u0E83\u0E85\u0E8B\u0EA4\u0EA6\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F01-\u0F17\u0F1A-\u0F1F\u0F2A-\u0F34\u0F36\u0F38\u0F3A-\u0F3D\u0F48\u0F6D-\u0F70\u0F85\u0F98\u0FBD-\u0FC5\u0FC7-\u0FFF\u104A-\u104F\u109E\u109F\u10C6\u10C8-\u10CC\u10CE\u10CF\u10FB\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u1360-\u137F\u1390-\u139F\u13F6\u13F7\u13FE-\u1400\u166D\u166E\u1680\u169B-\u169F\u16EB-\u16ED\u16F9-\u16FF\u170D\u1715-\u171F\u1735-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17D4-\u17D6\u17D8-\u17DB\u17DE\u17DF\u17EA-\u180A\u180E\u180F\u181A-\u181F\u1879-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191F\u192C-\u192F\u193C-\u1945\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DA-\u19FF\u1A1C-\u1A1F\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1AA6\u1AA8-\u1AAF\u1AC1-\u1AFF\u1B4C-\u1B4F\u1B5A-\u1B6A\u1B74-\u1B7F\u1BF4-\u1BFF\u1C38-\u1C3F\u1C4A-\u1C4C\u1C7E\u1C7F\u1C89-\u1C8F\u1CBB\u1CBC\u1CC0-\u1CCF\u1CD3\u1CFB-\u1CFF\u1DFA\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FBD\u1FBF-\u1FC1\u1FC5\u1FCD-\u1FCF\u1FD4\u1FD5\u1FDC-\u1FDF\u1FED-\u1FF1\u1FF5\u1FFD-\u203E\u2041-\u2053\u2055-\u2070\u2072-\u207E\u2080-\u208F\u209D-\u20CF\u20F1-\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F-\u215F\u2189-\u24B5\u24EA-\u2BFF\u2C2F\u2C5F\u2CE5-\u2CEA\u2CF4-\u2CFF\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D70-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E00-\u2E2E\u2E30-\u3004\u3008-\u3020\u3030\u3036\u3037\u303D-\u3040\u3097\u3098\u309B\u309C\u30A0\u30FB\u3100-\u3104\u3130\u318F-\u319F\u31C0-\u31EF\u3200-\u33FF\u4DC0-\u4DFF\u9FFD-\u9FFF\uA48D-\uA4CF\uA4FE\uA4FF\uA60D-\uA60F\uA62C-\uA63F\uA673\uA67E\uA6F2-\uA716\uA720\uA721\uA789\uA78A\uA7C0\uA7C1\uA7CB-\uA7F4\uA828-\uA82B\uA82D-\uA83F\uA874-\uA87F\uA8C6-\uA8CF\uA8DA-\uA8DF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA954-\uA95F\uA97D-\uA97F\uA9C1-\uA9CE\uA9DA-\uA9DF\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A-\uAA5F\uAA77-\uAA79\uAAC3-\uAADA\uAADE\uAADF\uAAF0\uAAF1\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F\uAB5B\uAB6A-\uAB6F\uABEB\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uD7FF\uE000-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB29\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBB2-\uFBD2\uFD3E-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFC-\uFDFF\uFE10-\uFE1F\uFE30-\uFE32\uFE35-\uFE4C\uFE50-\uFE6F\uFE75\uFEFD-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF3E\uFF40\uFF5B-\uFF65\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFFF]|\uD800[\uDC0C\uDC27\uDC3B\uDC3E\uDC4E\uDC4F\uDC5E-\uDC7F\uDCFB-\uDD3F\uDD75-\uDDFC\uDDFE-\uDE7F\uDE9D-\uDE9F\uDED1-\uDEDF\uDEE1-\uDEFF\uDF20-\uDF2C\uDF4B-\uDF4F\uDF7B-\uDF7F\uDF9E\uDF9F\uDFC4-\uDFC7\uDFD0\uDFD6-\uDFFF]|\uD801[\uDC9E\uDC9F\uDCAA-\uDCAF\uDCD4-\uDCD7\uDCFC-\uDCFF\uDD28-\uDD2F\uDD64-\uDDFF\uDF37-\uDF3F\uDF56-\uDF5F\uDF68-\uDFFF]|\uD802[\uDC06\uDC07\uDC09\uDC36\uDC39-\uDC3B\uDC3D\uDC3E\uDC56-\uDC5F\uDC77-\uDC7F\uDC9F-\uDCDF\uDCF3\uDCF6-\uDCFF\uDD16-\uDD1F\uDD3A-\uDD7F\uDDB8-\uDDBD\uDDC0-\uDDFF\uDE04\uDE07-\uDE0B\uDE14\uDE18\uDE36\uDE37\uDE3B-\uDE3E\uDE40-\uDE5F\uDE7D-\uDE7F\uDE9D-\uDEBF\uDEC8\uDEE7-\uDEFF\uDF36-\uDF3F\uDF56-\uDF5F\uDF73-\uDF7F\uDF92-\uDFFF]|\uD803[\uDC49-\uDC7F\uDCB3-\uDCBF\uDCF3-\uDCFF\uDD28-\uDD2F\uDD3A-\uDE7F\uDEAA\uDEAD-\uDEAF\uDEB2-\uDEFF\uDF1D-\uDF26\uDF28-\uDF2F\uDF51-\uDFAF\uDFC5-\uDFDF\uDFF7-\uDFFF]|\uD804[\uDC47-\uDC65\uDC70-\uDC7E\uDCBB-\uDCCF\uDCE9-\uDCEF\uDCFA-\uDCFF\uDD35\uDD40-\uDD43\uDD48-\uDD4F\uDD74\uDD75\uDD77-\uDD7F\uDDC5-\uDDC8\uDDCD\uDDDB\uDDDD-\uDDFF\uDE12\uDE38-\uDE3D\uDE3F-\uDE7F\uDE87\uDE89\uDE8E\uDE9E\uDEA9-\uDEAF\uDEEB-\uDEEF\uDEFA-\uDEFF\uDF04\uDF0D\uDF0E\uDF11\uDF12\uDF29\uDF31\uDF34\uDF3A\uDF45\uDF46\uDF49\uDF4A\uDF4E\uDF4F\uDF51-\uDF56\uDF58-\uDF5C\uDF64\uDF65\uDF6D-\uDF6F\uDF75-\uDFFF]|\uD805[\uDC4B-\uDC4F\uDC5A-\uDC5D\uDC62-\uDC7F\uDCC6\uDCC8-\uDCCF\uDCDA-\uDD7F\uDDB6\uDDB7\uDDC1-\uDDD7\uDDDE-\uDDFF\uDE41-\uDE43\uDE45-\uDE4F\uDE5A-\uDE7F\uDEB9-\uDEBF\uDECA-\uDEFF\uDF1B\uDF1C\uDF2C-\uDF2F\uDF3A-\uDFFF]|\uD806[\uDC3B-\uDC9F\uDCEA-\uDCFE\uDD07\uDD08\uDD0A\uDD0B\uDD14\uDD17\uDD36\uDD39\uDD3A\uDD44-\uDD4F\uDD5A-\uDD9F\uDDA8\uDDA9\uDDD8\uDDD9\uDDE2\uDDE5-\uDDFF\uDE3F-\uDE46\uDE48-\uDE4F\uDE9A-\uDE9C\uDE9E-\uDEBF\uDEF9-\uDFFF]|\uD807[\uDC09\uDC37\uDC41-\uDC4F\uDC5A-\uDC71\uDC90\uDC91\uDCA8\uDCB7-\uDCFF\uDD07\uDD0A\uDD37-\uDD39\uDD3B\uDD3E\uDD48-\uDD4F\uDD5A-\uDD5F\uDD66\uDD69\uDD8F\uDD92\uDD99-\uDD9F\uDDAA-\uDEDF\uDEF7-\uDFAF\uDFB1-\uDFFF]|\uD808[\uDF9A-\uDFFF]|\uD809[\uDC6F-\uDC7F\uDD44-\uDFFF]|[\uD80A\uD80B\uD80E-\uD810\uD812-\uD819\uD824-\uD82B\uD82D\uD82E\uD830-\uD833\uD837\uD839\uD83D\uD83F\uD87B-\uD87D\uD87F\uD885-\uDB3F\uDB41-\uDBFF][\uDC00-\uDFFF]|\uD80D[\uDC2F-\uDFFF]|\uD811[\uDE47-\uDFFF]|\uD81A[\uDE39-\uDE3F\uDE5F\uDE6A-\uDECF\uDEEE\uDEEF\uDEF5-\uDEFF\uDF37-\uDF3F\uDF44-\uDF4F\uDF5A-\uDF62\uDF78-\uDF7C\uDF90-\uDFFF]|\uD81B[\uDC00-\uDE3F\uDE80-\uDEFF\uDF4B-\uDF4E\uDF88-\uDF8E\uDFA0-\uDFDF\uDFE2\uDFE5-\uDFEF\uDFF2-\uDFFF]|\uD821[\uDFF8-\uDFFF]|\uD823[\uDCD6-\uDCFF\uDD09-\uDFFF]|\uD82C[\uDD1F-\uDD4F\uDD53-\uDD63\uDD68-\uDD6F\uDEFC-\uDFFF]|\uD82F[\uDC6B-\uDC6F\uDC7D-\uDC7F\uDC89-\uDC8F\uDC9A-\uDC9C\uDC9F-\uDFFF]|\uD834[\uDC00-\uDD64\uDD6A-\uDD6C\uDD73-\uDD7A\uDD83\uDD84\uDD8C-\uDDA9\uDDAE-\uDE41\uDE45-\uDFFF]|\uD835[\uDC55\uDC9D\uDCA0\uDCA1\uDCA3\uDCA4\uDCA7\uDCA8\uDCAD\uDCBA\uDCBC\uDCC4\uDD06\uDD0B\uDD0C\uDD15\uDD1D\uDD3A\uDD3F\uDD45\uDD47-\uDD49\uDD51\uDEA6\uDEA7\uDEC1\uDEDB\uDEFB\uDF15\uDF35\uDF4F\uDF6F\uDF89\uDFA9\uDFC3\uDFCC\uDFCD]|\uD836[\uDC00-\uDDFF\uDE37-\uDE3A\uDE6D-\uDE74\uDE76-\uDE83\uDE85-\uDE9A\uDEA0\uDEB0-\uDFFF]|\uD838[\uDC07\uDC19\uDC1A\uDC22\uDC25\uDC2B-\uDCFF\uDD2D-\uDD2F\uDD3E\uDD3F\uDD4A-\uDD4D\uDD4F-\uDEBF\uDEFA-\uDFFF]|\uD83A[\uDCC5-\uDCCF\uDCD7-\uDCFF\uDD4C-\uDD4F\uDD5A-\uDFFF]|\uD83B[\uDC00-\uDDFF\uDE04\uDE20\uDE23\uDE25\uDE26\uDE28\uDE33\uDE38\uDE3A\uDE3C-\uDE41\uDE43-\uDE46\uDE48\uDE4A\uDE4C\uDE50\uDE53\uDE55\uDE56\uDE58\uDE5A\uDE5C\uDE5E\uDE60\uDE63\uDE65\uDE66\uDE6B\uDE73\uDE78\uDE7D\uDE7F\uDE8A\uDE9C-\uDEA0\uDEA4\uDEAA\uDEBC-\uDFFF]|\uD83C[\uDC00-\uDD2F\uDD4A-\uDD4F\uDD6A-\uDD6F\uDD8A-\uDFFF]|\uD83E[\uDC00-\uDFEF\uDFFA-\uDFFF]|\uD869[\uDEDE-\uDEFF]|\uD86D[\uDF35-\uDF3F]|\uD86E[\uDC1E\uDC1F]|\uD873[\uDEA2-\uDEAF]|\uD87A[\uDFE1-\uDFFF]|\uD87E[\uDE1E-\uDFFF]|\uD884[\uDF4B-\uDFFF]|\uDB40[\uDC00-\uDCFF\uDDF0-\uDFFF]/g; + +/** + * One heading's slug, with no duplicate tracking — github-slugger's `slug()`. + * Lowercase, drop everything in the table above, spaces to hyphens. + */ +export function slug(value) { + if (typeof value !== 'string') return ''; + NON_SLUG_CHARS.lastIndex = 0; + return value.toLowerCase().replace(NON_SLUG_CHARS, '').replace(/ /g, '-'); +} + +/** + * github-slugger's stateful class: the SECOND heading that slugs to `foo` gets + * `foo-1`, the third `foo-2`. Both renderers walk a document's headings in + * order through one instance, so reproducing the counter is what makes a link + * to a repeated heading resolvable at all. + */ +export class GithubSlugger { + constructor() { + this.occurrences = Object.create(null); + } + + slug(value) { + const original = slug(value); + let result = original; + while (Object.hasOwnProperty.call(this.occurrences, result)) { + this.occurrences[original] += 1; + result = `${original}-${this.occurrences[original]}`; + } + this.occurrences[result] = 0; + return result; + } +}