create-zudo-doc 0.2.21 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/compose.d.ts +6 -3
  2. package/dist/compose.js +6 -4
  3. package/dist/features/image-enlarge.d.ts +22 -12
  4. package/dist/features/image-enlarge.js +23 -191
  5. package/dist/scaffold.d.ts +11 -0
  6. package/dist/scaffold.js +51 -24
  7. package/dist/settings-gen.js +4 -0
  8. package/dist/zfb-config-gen.d.ts +16 -10
  9. package/dist/zfb-config-gen.js +34 -239
  10. package/package.json +1 -1
  11. package/templates/base/pages/_mdx-components.ts +64 -185
  12. package/templates/base/pages/index.tsx +1 -1
  13. package/templates/base/pages/lib/_body-end-islands.tsx +6 -13
  14. package/templates/base/pages/lib/_category-nav.tsx +30 -115
  15. package/templates/base/pages/lib/_category-tree-nav.tsx +38 -65
  16. package/templates/base/pages/lib/_compose-meta-title.ts +2 -6
  17. package/templates/base/pages/lib/_doc-body-end.tsx +3 -35
  18. package/templates/base/pages/lib/_doc-content-header.tsx +10 -111
  19. package/templates/base/pages/lib/_doc-history-area.tsx +19 -211
  20. package/templates/base/pages/lib/_doc-metainfo-area.tsx +10 -113
  21. package/templates/base/pages/lib/_doc-page-renderer.tsx +22 -183
  22. package/templates/base/pages/lib/_doc-page-shell.tsx +17 -251
  23. package/templates/base/pages/lib/_doc-pager.tsx +4 -74
  24. package/templates/base/pages/lib/_doc-route-entries.ts +43 -177
  25. package/templates/base/pages/lib/_doc-route-paths.ts +16 -101
  26. package/templates/base/pages/lib/_doc-tags-area.tsx +14 -77
  27. package/templates/base/pages/lib/_extract-headings.ts +21 -295
  28. package/templates/base/pages/lib/_footer-with-defaults.tsx +37 -225
  29. package/templates/base/pages/lib/_frontmatter-preview-data.ts +5 -31
  30. package/templates/base/pages/lib/_head-with-defaults.tsx +13 -138
  31. package/templates/base/pages/lib/_header-with-defaults.tsx +24 -324
  32. package/templates/base/pages/lib/_inline-version-switcher.tsx +16 -78
  33. package/templates/base/pages/lib/_math-block.tsx +4 -63
  34. package/templates/base/pages/lib/_nav-data-prep.ts +32 -51
  35. package/templates/base/pages/lib/_nav-source-cache.ts +12 -57
  36. package/templates/base/pages/lib/_nav-source-docs.ts +33 -233
  37. package/templates/base/pages/lib/_search-widget-script.ts +2 -470
  38. package/templates/base/pages/lib/_search-widget.tsx +9 -195
  39. package/templates/base/pages/lib/_sidebar-prepaint.tsx +6 -59
  40. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +16 -118
  41. package/templates/base/pages/lib/_site-tree-nav.tsx +29 -80
  42. package/templates/base/pages/lib/doc-page-props.ts +26 -44
  43. package/templates/base/pages/lib/locale-merge.ts +32 -145
  44. package/templates/base/pages/lib/route-enumerators.ts +52 -286
  45. package/templates/base/src/components/ai-chat-modal.tsx +9 -8
  46. package/templates/base/src/components/content/code-group.tsx +3 -76
  47. package/templates/base/src/components/content/content-admonition.tsx +4 -56
  48. package/templates/base/src/components/doc-history.tsx +9 -8
  49. package/templates/base/src/components/image-enlarge.tsx +11 -8
  50. package/templates/base/src/components/sidebar-toggle.tsx +6 -170
  51. package/templates/base/src/components/sidebar-tree.tsx +6 -548
  52. package/templates/base/src/config/color-scheme-utils.ts +34 -158
  53. package/templates/base/src/config/i18n.ts +9 -0
  54. package/templates/base/src/config/settings-types.ts +34 -172
  55. package/templates/base/src/config/z-index-tokens.ts +5 -4
  56. package/templates/base/src/styles/global.css +40 -587
  57. package/templates/base/src/utils/base.ts +1 -1
  58. package/templates/base/src/utils/docs.ts +47 -16
  59. package/templates/base/src/utils/github.ts +12 -9
  60. package/templates/base/src/utils/nav-scope.ts +13 -42
  61. package/templates/base/src/utils/sidebar.ts +18 -86
  62. package/templates/base/src/utils/slug.ts +10 -53
  63. package/templates/base/src/utils/smart-break.tsx +12 -120
  64. package/templates/base/src/utils/tags.ts +25 -68
  65. package/templates/features/bodyFootUtil/files/src/utils/github.ts +12 -9
  66. package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +13 -39
  67. package/templates/features/docHistory/files/src/components/doc-history.tsx +8 -636
  68. package/templates/features/docHistory/files/src/types/doc-history.ts +7 -23
  69. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +35 -201
  70. package/templates/features/i18n/files/pages/[locale]/index.tsx +1 -1
  71. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +8 -269
  72. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +6 -99
  73. package/templates/features/tagGovernance/files/scripts/tags-audit.ts +67 -515
  74. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +21 -73
  75. package/templates/base/pages/404.tsx +0 -61
  76. package/templates/base/pages/robots.txt.tsx +0 -29
  77. package/templates/base/pages/sitemap.xml.tsx +0 -59
  78. package/templates/base/plugins/connect-adapter.mjs +0 -169
  79. package/templates/base/plugins/copy-public-plugin.mjs +0 -58
  80. package/templates/base/plugins/search-index-plugin.mjs +0 -66
  81. package/templates/base/scripts/gen-z-index.mjs +0 -157
  82. package/templates/base/src/components/mermaid-enlarge.tsx +0 -490
  83. package/templates/base/src/components/site-tree-nav.tsx +0 -220
  84. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +0 -47
  85. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +0 -111
  86. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +0 -59
  87. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +0 -39
  88. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +0 -43
  89. package/templates/features/docTags/files/pages/docs/tags/index.tsx +0 -20
  90. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +0 -93
  91. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +0 -48
  92. package/templates/features/versioning/files/pages/docs/versions.tsx +0 -20
@@ -1,101 +1,16 @@
1
- // Shared, pure prop-builder helpers for the 4 doc-route paths() functions.
2
- //
3
- // Extracted (#1917) from the near-verbatim paths() bodies of the 4 doc routes.
4
- // These are version- and i18n-AGNOSTIC: every URL is produced by an injected
5
- // `urlFor(slug) => string` closure, so the same code serves latest, locale,
6
- // versioned, and versioned-locale routes without branching on context. The
7
- // route file owns the closure (docsUrl vs versionedDocsUrl bound to its
8
- // version/locale), which is what guarantees a latest-page pagination override
9
- // is resolved + rewritten against the LATEST tree — it can never be rebound to
10
- // a /v/ URL because a latest route never receives a versioned closure
11
- // (#1916 correctness; see the behavioral tests).
12
-
13
- import { flattenTree, findNode, type NavNode } from "@/utils/docs";
14
-
15
- /** The two pagination-override fields read off entry frontmatter. */
16
- export interface PaginationOverrides {
17
- /** `undefined` = no override; `null` = suppress; string = target slug. */
18
- pagination_prev?: string | null;
19
- pagination_next?: string | null;
20
- }
21
-
22
- /**
23
- * Resolve prev/next nav nodes for an entry against the route's OWN nav tree.
24
- *
25
- * - Sequential prev/next come from the flattened sub-tree (`subtreeFlat`).
26
- * - Frontmatter `pagination_prev` / `pagination_next` overrides resolve via
27
- * `findNode(tree, …)` against the SAME `tree` the route built — never a
28
- * foreign tree. The caller passes its own version/locale-scoped tree, so a
29
- * `/v/` override resolves to a `/v/` node and a latest override to a latest
30
- * node. (#1916 — pagination-override must bind to the correct tree.)
31
- *
32
- * Returns the raw NavNodes (hrefs untouched). Callers that need versioned
33
- * hrefs run the result through `rewriteNavHref` with their `urlFor` closure.
34
- */
35
- export function resolveDocPrevNext(
36
- tree: NavNode[],
37
- subtreeFlat: NavNode[],
38
- slug: string,
39
- overrides: PaginationOverrides,
40
- ): { prev: NavNode | null; next: NavNode | null } {
41
- const idx = subtreeFlat.findIndex((n) => n.slug === slug);
42
-
43
- let prev = idx > 0 ? subtreeFlat[idx - 1] ?? null : null;
44
- let next = idx >= 0 && idx < subtreeFlat.length - 1 ? subtreeFlat[idx + 1] ?? null : null;
45
-
46
- if (overrides.pagination_prev !== undefined) {
47
- if (overrides.pagination_prev === null) {
48
- prev = null;
49
- } else {
50
- const found = findNode(tree, overrides.pagination_prev);
51
- prev = found ?? prev;
52
- }
53
- }
54
- if (overrides.pagination_next !== undefined) {
55
- if (overrides.pagination_next === null) {
56
- next = null;
57
- } else {
58
- const found = findNode(tree, overrides.pagination_next);
59
- next = found ?? next;
60
- }
61
- }
62
-
63
- return { prev, next };
64
- }
65
-
66
- /** Flatten the relevant sub-tree for an entry — convenience over flattenTree. */
67
- export function flattenSubtree(subtree: NavNode[]): NavNode[] {
68
- return flattenTree(subtree);
69
- }
70
-
71
- /**
72
- * Rewrite a single nav node's href via the route's `urlFor` closure.
73
- *
74
- * Returns `null` for a `null` node. Latest routes pass `undefined` (leave the
75
- * href as the latest `docsUrl` already baked into the node); versioned routes
76
- * pass `urlFor` so prev/next links point at the versioned URL.
77
- */
78
- export function rewriteNavHref(
79
- node: NavNode | null,
80
- urlFor: ((slug: string) => string) | undefined,
81
- ): NavNode | null {
82
- if (!node) return null;
83
- if (!urlFor) return node;
84
- return { ...node, href: urlFor(node.slug) };
85
- }
86
-
87
- /**
88
- * Remap an auto-index node's child-card hrefs via `urlFor`.
89
- *
90
- * #1916 #2: on versioned routes the children carry latest `docsUrl` hrefs
91
- * (every nav node does — see toNavNodes). They MUST be overridden to the
92
- * versioned URL ALWAYS, not only when `c.href` is missing. Passing `urlFor`
93
- * here does exactly that. Latest routes pass `undefined` to keep the original.
94
- */
95
- export function remapNavChildHrefs(
96
- children: NavNode[],
97
- urlFor: ((slug: string) => string) | undefined,
98
- ): NavNode[] {
99
- if (!urlFor) return children;
100
- return children.map((c) => ({ ...c, href: urlFor(c.slug) }));
101
- }
1
+ // Thin stub — doc-route-paths moved to the package (epic #2344, S6).
2
+ // Re-exports the pure prop-builder helpers from @takazudo/zudo-doc/doc-route-paths.
3
+
4
+ export type {
5
+ DocNavNode,
6
+ PaginationOverrides,
7
+ } from "@takazudo/zudo-doc/doc-route-paths";
8
+
9
+ export {
10
+ flattenTree,
11
+ findNode,
12
+ flattenSubtree,
13
+ resolveDocPrevNext,
14
+ rewriteNavHref,
15
+ remapNavChildHrefs,
16
+ } from "@takazudo/zudo-doc/doc-route-paths";
@@ -1,44 +1,15 @@
1
1
  /** @jsxRuntime automatic */
2
2
  /** @jsxImportSource preact */
3
- // Locale-aware DocTags area wrapper for the zfb doc pages.
4
- //
5
- // Renders the page-level tag chips (e.g. "Tags: #customization") between
6
- // the DocMetainfo block and the description paragraph (doc-tags placement
7
- // — after-title, between the date block and description paragraph).
8
- //
9
- // Restoration of a Astro→zfb migration regression: the DocTags component
10
- // was correctly ported into @takazudo/zudo-doc/metainfo/doc-tags.tsx
11
- // but no page template wired it up (#1658, closes #1508).
12
- //
13
- // tagHref logic: inlined from _footer-with-defaults.tsx (the `tagHref`
14
- // helper there). Extraction was considered but would cause ripple in the
15
- // footer file and its callers — per the spec's "no opportunistic refactor"
16
- // rule, a local copy is used here instead.
17
- //
18
- // i18n: both `doc.tags` and `doc.taggedWith` are confirmed present for all
19
- // project locales (en, ja, de) in src/config/i18n.ts — no fallback needed.
20
-
21
- import type { VNode } from "preact";
3
+ // Host thin-stub see @takazudo/zudo-doc/doc-tags-area (epic #2344, S7).
22
4
  import { settings } from "@/config/settings";
23
5
  import { defaultLocale, t } from "@/config/i18n";
24
6
  import { withBase } from "@/utils/base";
25
- import { resolvePageTags } from "@/utils/tags";
26
- import { DocTags } from "@takazudo/zudo-doc/metainfo";
27
-
28
- // ---------------------------------------------------------------------------
29
- // Internal helpers
30
- // ---------------------------------------------------------------------------
7
+ import { tagVocabulary } from "@/config/tag-vocabulary";
8
+ import { createDocTagsArea } from "@takazudo/zudo-doc/doc-tags-area";
31
9
 
32
- /**
33
- * Build the base-prefixed tag detail page href for the given locale.
34
- *
35
- * Inlined from _footer-with-defaults.tsx `tagHref` — not extracted to avoid
36
- * ripple (spec rule: no opportunistic refactor on tagHref extraction).
37
- *
38
- * The tag segment is URL-encoded at the href site only — route params stay
39
- * raw, so the built output dir keeps the raw tag name and the server decodes
40
- * the percent-encoded href back to it (e.g. "type:guide" → "type%3Aguide").
41
- */
10
+ // Inlined from the original _doc-tags-area.tsx `tagHref` helper.
11
+ // Builds the base-prefixed tag detail page href for the given locale.
12
+ // The tag segment is URL-encoded at the href site only.
42
13
  function tagHref(tag: string, locale: string): string {
43
14
  const encoded = encodeURIComponent(tag);
44
15
  const path =
@@ -48,46 +19,12 @@ function tagHref(tag: string, locale: string): string {
48
19
  return withBase(path);
49
20
  }
50
21
 
51
- // ---------------------------------------------------------------------------
52
- // Component
53
- // ---------------------------------------------------------------------------
54
-
55
- interface DocTagsAreaProps {
56
- /** Page slug, e.g. "guides/sidebar". */
57
- slug: string;
58
- /** Active locale string, e.g. "en", "ja". */
59
- locale: string;
60
- /** Raw tag strings from the page frontmatter (entry.data.tags). */
61
- tags: readonly string[] | undefined;
62
- }
63
-
64
- /**
65
- * Renders the page-level tag chip block when `settings.docTags` is enabled
66
- * and the page has at least one resolved (non-deprecated) tag.
67
- *
68
- * Returns null when `docTags` is disabled, the page has no tags, or all
69
- * raw tags resolve to deprecated entries.
70
- *
71
- * Placement is "after-title" (between the date block and description paragraph).
72
- */
73
- export function DocTagsArea({ locale, tags }: DocTagsAreaProps): VNode | null {
74
- if (!settings.docTags) return null;
75
-
76
- const rawTags = tags ?? [];
77
- const canonicalTags = resolvePageTags(rawTags);
78
- if (canonicalTags.length === 0) return null;
22
+ export type { DocTagsAreaProps } from "@takazudo/zudo-doc/doc-tags-area";
79
23
 
80
- const resolvedTags = canonicalTags.map((tag) => ({
81
- tag,
82
- href: tagHref(tag, locale),
83
- }));
84
-
85
- return (
86
- <DocTags
87
- placement="after-title"
88
- tags={resolvedTags}
89
- tagsLabel={t("doc.tags", locale)}
90
- taggedWithLabel={t("doc.taggedWith", locale)}
91
- />
92
- );
93
- }
24
+ export const DocTagsArea = createDocTagsArea({
25
+ settings,
26
+ defaultLocale,
27
+ tagVocabularyEntries: tagVocabulary,
28
+ tagHref,
29
+ t,
30
+ });
@@ -1,239 +1,25 @@
1
- // pages/lib/_extract-headings.ts extract TOC headings from a raw MDX body.
1
+ // Thin showcase wrapper around @takazudo/zudo-doc/extract-headings.
2
+ // The package-side extractHeadings takes settings as explicit params (no
3
+ // project singleton import). This wrapper re-reads the project settings and
4
+ // passes them through so call sites in _doc-route-entries.ts continue to
5
+ // call extractHeadings(body) without change.
2
6
  //
3
- // Shared helper called by all four catch-all `paths()` functions so each page
4
- // passes real heading data to `DocLayoutWithDefaults` rather than an empty
5
- // array. The result drops directly into the `headings` prop of `Toc` /
6
- // `MobileToc` — the shape is byte-aligned with `HeadingItem` in
7
- // `packages/zudo-doc/src/toc/types.ts`.
8
- //
9
- // Algorithm:
10
- // 1. Walk the body line-by-line looking for ATX-style markdown headings
11
- // (`## Text` through `###### Text`, h2–h6).
12
- // 2. Strip inline markdown markup (links, inline code, bold, italic) from the
13
- // heading text to get the plain visible text — matching what the renderer's
14
- // `extractText` HAST walker sees after MDX → HTML conversion.
15
- // 3. Compute a heading ID that matches what zfb's Rust `HeadingLinks` plugin
16
- // emits at render time, using the strategy in `settings.headingIdStrategy`
17
- // (single source of truth, also read by `zfb.config.ts`):
18
- // - `"flat"`: one dedup counter shared across ALL h2–h6 (even those not
19
- // emitted into the TOC), so TOC anchor hrefs match the rendered IDs.
20
- // - `"hierarchical"`: ancestor-prefixed IDs (`## Foo` / `### Moo` /
21
- // `#### Mew` → `foo`, `foo-moo`, `foo-moo-mew`), deduped on the full
22
- // path — see `SlugAllocator` below, a faithful mirror of zfb's Rust
23
- // allocator (upstream zfb#871).
24
- // Either way the allocator runs over ALL matched h2–h6 so its per-document
25
- // state (dedup counter + ancestor stack) stays in lockstep with the
26
- // renderer. h1 is NOT slugged — the renderer never assigns an id to h1.
27
- // 4. Return only depth 2–4 headings by default (h1 is the page title; h5–h6
28
- // are too granular). The window is configurable via `tocMinDepth` /
29
- // `tocMaxDepth` in settings (restriction-only: min 2, max 4).
30
- //
31
- // Caveats:
32
- // - This is a regex walk over raw text, not an AST parse. MDX JSX expressions
33
- // that contain `##` on their own line may be matched. In practice this is
34
- // rare.
35
- // - Lines inside code fences (``` … ``` or ~~~ … ~~~) are skipped to avoid
36
- // treating literal `## code` examples as real headings. Fence detection
37
- // uses `line.trimStart()` to handle indented fences correctly.
38
- // - Reference-style links (`[text][id]`) and image links (`![alt](url)`)
39
- // are not stripped — uncommon in headings, treated as plain text.
40
- // - The renderer slugs all h2–h6 regardless of `tocMinDepth`/`tocMaxDepth`, so
41
- // this extractor must also allocate over every matched heading (including
42
- // those outside the emit window) to keep the shared dedup counter / ancestor
43
- // stack in sync.
44
- // - Slug parity: we port zfb's exact Rust `slugify` (see `slugify` below)
45
- // instead of using npm `github-slugger`, because the two diverge on
46
- // punctuation (`.` → `-` vs removed, `/`/`—` collapsing, etc.) and that
47
- // divergence would desync the TOC anchor from the rendered heading id.
48
- // - Residual risk: text-extraction parity (inline JSX / reference links not
49
- // fully stripped) — the slug parity itself is now exact.
7
+ // Moved to the package as part of the package-first migration (#2321, S4 #2327).
50
8
 
51
9
  import { settings } from "../../src/config/settings";
52
-
53
- /** Heading-ID (anchor) strategy. Mirrors `settings.headingIdStrategy`. */
54
- export type HeadingIdStrategy = "flat" | "hierarchical";
55
-
56
- // Punctuation stripped (treated as a separator) by zfb's slugify — the ASCII
57
- // set from `crates/zfb-content/src/plugins/heading_links.rs::slugify`. Note `-`
58
- // and `_` are NOT here: they are kept verbatim.
59
- const SLUGIFY_STRIPPED = new Set(
60
- "!\"#$%&'()*+,./:;<=>?@[\\]^`{|}~".split(""),
61
- );
62
-
63
- /**
64
- * Slugify one heading's text — a byte-faithful TS port of zfb's Rust `slugify`
65
- * (`crates/zfb-content/src/plugins/heading_links.rs`). Using the exact upstream
66
- * algorithm (rather than npm `github-slugger`, which strips punctuation
67
- * differently) is what keeps the TOC anchor identical to the rendered `id`.
68
- *
69
- * Rule: walk code points; whitespace, ASCII control, or a {@link SLUGIFY_STRIPPED}
70
- * char collapses to a single `-` (runs coalesce); every other char is lowercased
71
- * and kept (Unicode letters/digits, `-`, `_` survive). A single trailing `-` is
72
- * dropped; a leading `-` is never emitted.
73
- */
74
- export function slugify(input: string): string {
75
- let out = "";
76
- let lastDash = true; // suppress a leading dash
77
- for (const ch of input) {
78
- const cp = ch.codePointAt(0) ?? 0;
79
- const isControl = cp <= 0x1f || (cp >= 0x7f && cp <= 0x9f);
80
- if (isControl || /\s/u.test(ch) || SLUGIFY_STRIPPED.has(ch)) {
81
- if (!lastDash) {
82
- out += "-";
83
- lastDash = true;
84
- }
85
- } else {
86
- out += ch.toLowerCase();
87
- lastDash = false;
88
- }
89
- }
90
- if (out.endsWith("-")) out = out.slice(0, -1);
91
- return out;
92
- }
93
-
94
- export interface HeadingItem {
95
- readonly depth: number;
96
- readonly slug: string;
97
- readonly text: string;
98
- }
10
+ export type { HeadingIdStrategy, HeadingItem } from "@takazudo/zudo-doc/extract-headings";
11
+ export { slugify } from "@takazudo/zudo-doc/extract-headings";
12
+ import { extractHeadings as _extractHeadings } from "@takazudo/zudo-doc/extract-headings";
13
+ import type { HeadingItem, HeadingIdStrategy } from "@takazudo/zudo-doc/extract-headings";
99
14
 
100
15
  /**
101
- * Per-document heading-ID allocator a faithful TS mirror of zfb's Rust
102
- * `SlugAllocator` (`crates/zfb-content/src/plugins/heading_links.rs`). Construct
103
- * one per document; call `allocate(depth, text)` for every matched h2–h6 in
104
- * document order (the result is the rendered heading `id`).
105
- *
106
- * Both strategies share one `slugify` (the exact zfb port) and one per-document
107
- * dedup counter:
16
+ * Extract TOC headings from a raw MDX/markdown body, using the project's
17
+ * configured `tocMinDepth`, `tocMaxDepth`, and `headingIdStrategy` from
18
+ * `src/config/settings`.
108
19
  *
109
- * - `"flat"`: `id = nextSlug(slugify(text))` one counter shared across all
110
- * levels (`overview`, `overview-1`, …). Byte-identical to zfb's flat path.
111
- * - `"hierarchical"`: `base = slugify(text)`; pop the ancestor stack while its
112
- * top is at or deeper than `depth`; `candidate = {parent.id}-{base}` (just
113
- * `base` at the top of the outline); `id = nextSlug(candidate)` (dedup on the
114
- * *full path*); push `(depth, id)`. A deduped parent therefore contributes its
115
- * FINAL id to children. Empty-text headings get the empty string and touch no
116
- * state (the renderer skips them entirely).
117
- */
118
- class SlugAllocator {
119
- private readonly strategy: HeadingIdStrategy;
120
- /** Dedup counter, keyed by the (possibly ancestor-prefixed) slug path. */
121
- private readonly seen = new Map<string, number>();
122
- /** Hierarchical ancestor stack of `{ depth, final id }` (unused when flat). */
123
- private readonly stack: { depth: number; id: string }[] = [];
124
-
125
- constructor(strategy: HeadingIdStrategy) {
126
- this.strategy = strategy;
127
- }
128
-
129
- allocate(depth: number, text: string): string {
130
- const base = slugify(text);
131
- if (this.strategy === "flat") {
132
- return this.nextSlug(base);
133
- }
134
- if (base === "") return "";
135
- // Pop ancestors at or below this depth so a sibling/shallower heading
136
- // re-roots the chain (h2 → h4 jumps nest under the nearest real ancestor).
137
- for (let top = this.stack.at(-1); top !== undefined && top.depth >= depth; top = this.stack.at(-1)) {
138
- this.stack.pop();
139
- }
140
- const parent = this.stack.at(-1);
141
- const candidate = parent !== undefined ? `${parent.id}-${base}` : base;
142
- const id = this.nextSlug(candidate);
143
- this.stack.push({ depth, id });
144
- return id;
145
- }
146
-
147
- /**
148
- * Repeat-numbering on an already-slugified candidate: first occurrence returns
149
- * `candidate`, later ones `candidate-1`, `candidate-2`, …. Mirrors zfb's
150
- * `next_slug` — including the empty-string short-circuit (an empty base never
151
- * advances the counter), so it does NOT re-slugify (the candidate is already a
152
- * valid slug path).
153
- */
154
- private nextSlug(candidate: string): string {
155
- if (candidate === "") return "";
156
- const count = this.seen.get(candidate) ?? 0;
157
- this.seen.set(candidate, count + 1);
158
- return count === 0 ? candidate : `${candidate}-${count}`;
159
- }
160
- }
161
-
162
- /**
163
- * Strip inline markdown markup from a heading line to obtain the plain visible
164
- * text that `rehype-heading-links` sees after MDX → HTML conversion.
165
- *
166
- * Strips (in order):
167
- * - Inline links: `[text](url)` → `text`
168
- * - Inline code spans: `` `code` `` → `code`
169
- * - Bold: `**text**` or `__text__` → `text`
170
- * - Italic: `*text*` or `_text_` → `text`
171
- *
172
- * Underscore emphasis (`__`/`_`) only fires at word boundaries, per CommonMark's
173
- * intraword rule: `_` inside a word is literal. Without this guard, identifiers
174
- * like `SKIP_DOC_HISTORY` or `DOCS_SITE_URL` lose their underscores here, and the
175
- * resulting slug diverges from the rendered heading `id` (the renderer keeps the
176
- * underscores). Asterisk emphasis (`*`) is left intraword-greedy — `*` does not
177
- * appear in identifiers and CommonMark does allow intraword `*`.
178
- */
179
- function stripInlineMarkdown(raw: string): string {
180
- return (
181
- raw
182
- // Inline links [text](url) — replace with link text only.
183
- // Must run before bold/italic to avoid mismatching `*` inside URLs.
184
- .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
185
- // Inline code spans `code` — replace with code text.
186
- .replace(/`([^`]+)`/g, "$1")
187
- // Bold **text** or __text__ (underscore form only at word boundaries)
188
- .replace(/\*\*([^*]+)\*\*/g, "$1")
189
- .replace(/(?<![\w])__([^_]+)__(?![\w])/g, "$1")
190
- // Italic *text* or _text_ (underscore form only at word boundaries)
191
- .replace(/\*([^*]+)\*/g, "$1")
192
- .replace(/(?<![\w])_([^_]+)_(?![\w])/g, "$1")
193
- .trim()
194
- );
195
- }
196
-
197
- /**
198
- * Resolve and clamp the depth window from raw (possibly invalid) inputs.
199
- *
200
- * Enforces `2 <= min <= max <= 4`. If either value is NaN or the chain breaks,
201
- * falls back to the full default window [2, 4].
202
- */
203
- function resolveDepthWindow(
204
- rawMin: unknown,
205
- rawMax: unknown,
206
- ): { lo: number; hi: number } {
207
- const min = Math.trunc(Number(rawMin));
208
- const max = Math.trunc(Number(rawMax));
209
- if (
210
- Number.isFinite(min) &&
211
- Number.isFinite(max) &&
212
- min >= 2 &&
213
- min <= max &&
214
- max <= 4
215
- ) {
216
- return { lo: min, hi: max };
217
- }
218
- return { lo: 2, hi: 4 };
219
- }
220
-
221
- /**
222
- * Extract TOC headings from a raw MDX/markdown body.
223
- *
224
- * Uses the same slugging algorithm as zfb's `HeadingLinks` plugin (selected by
225
- * `settings.headingIdStrategy`, or the `opts.strategy` override) so the
226
- * `href="#slug"` values in the TOC match the rendered heading element IDs.
227
- * Allocates over ALL matched h2–h6 (keeping the dedup counter and hierarchical
228
- * ancestor stack in sync with the renderer) but only pushes depth 2–4 items
229
- * into the result (configurable via settings). h1 is not matched — the renderer
230
- * does not assign ids to h1.
231
- *
232
- * @param body - Raw markdown body string (frontmatter already stripped).
233
- * @param opts - Optional overrides for the depth window and heading-ID
234
- * strategy (used by tests only; production call sites pass no arguments and
235
- * read from settings).
236
- * @returns Array of `{ depth, slug, text }` items in document order.
20
+ * Accepts the same optional `opts` overrides as the underlying package
21
+ * function (for tests that want to override the depth window or strategy
22
+ * without touching the global settings).
237
23
  */
238
24
  export function extractHeadings(
239
25
  body: string,
@@ -243,69 +29,9 @@ export function extractHeadings(
243
29
  strategy?: HeadingIdStrategy;
244
30
  },
245
31
  ): HeadingItem[] {
246
- const { lo, hi } = resolveDepthWindow(
247
- opts?.tocMinDepth ?? settings.tocMinDepth,
248
- opts?.tocMaxDepth ?? settings.tocMaxDepth,
249
- );
250
-
251
- const allocator = new SlugAllocator(opts?.strategy ?? settings.headingIdStrategy);
252
- const headings: HeadingItem[] = [];
253
-
254
- // Track the opening fence character and length so we correctly match the
255
- // closing fence. Markdown allows backtick and tilde fences (``` or ~~~),
256
- // and longer fences to nest shorter same-character ones.
257
- let codeFenceOpener: string | null = null;
258
- for (const line of body.split("\n")) {
259
- // Detect code fence open/close. A fence is 3+ backticks OR 3+ tildes,
260
- // optionally followed by a language specifier. The closing fence must use
261
- // the same character and match or exceed the opener's length.
262
- // Use trimStart() so indented fences (e.g. inside lists) are also detected.
263
- const trimmed = line.trimStart();
264
- const fenceMatch = /^([`~]{3,})/.exec(trimmed);
265
- if (fenceMatch) {
266
- const fence = fenceMatch[1];
267
- if (fence === undefined) continue;
268
- if (codeFenceOpener === null) {
269
- // Opening fence: record character + length.
270
- codeFenceOpener = fence;
271
- } else if (
272
- fence[0] === codeFenceOpener[0] &&
273
- fence.length >= codeFenceOpener.length
274
- ) {
275
- // Closing fence: must match opener's character and be at least as long.
276
- codeFenceOpener = null;
277
- }
278
- // Whether opening, closing, or a mismatched-character line (content inside
279
- // a fence), always skip — do not try to parse as a heading.
280
- continue;
281
- }
282
- if (codeFenceOpener !== null) continue;
283
-
284
- // Match ATX headings at depth h2–h6. The renderer's heading-links plugin
285
- // slugs h2–h6 only (h1 is never assigned an id — the frontmatter title is
286
- // the page's h1), so matching h1 here would advance the shared dedup counter
287
- // out of step with the renderer and break the TOC anchor for a same-text h2.
288
- // Allow one or more spaces/tabs after the hashes (both valid per CommonMark).
289
- const match = /^(#{2,6})[ \t]+(.+)$/.exec(line.trim());
290
- if (!match) continue;
291
-
292
- const hashes = match[1];
293
- const rawText = match[2];
294
- if (hashes === undefined || rawText === undefined) continue;
295
-
296
- const depth = hashes.length;
297
- // Strip inline markup to get the plain text the renderer sees, so the slug
298
- // matches the heading element's rendered id attribute.
299
- const text = stripInlineMarkdown(rawText.trim());
300
-
301
- // Always allocate (advancing the dedup counter and, in hierarchical mode,
302
- // the ancestor stack — maintaining parity with the renderer across all
303
- // h2–h6), but only push within the configured depth window.
304
- const slug = allocator.allocate(depth, text);
305
- if (depth >= lo && depth <= hi) {
306
- headings.push({ depth, slug, text });
307
- }
308
- }
309
-
310
- return headings;
32
+ return _extractHeadings(body, {
33
+ tocMinDepth: opts?.tocMinDepth ?? settings.tocMinDepth,
34
+ tocMaxDepth: opts?.tocMaxDepth ?? settings.tocMaxDepth,
35
+ strategy: opts?.strategy ?? settings.headingIdStrategy,
36
+ });
311
37
  }