create-zudo-doc 0.2.22 → 1.0.1

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 (28) 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 +15 -13
  4. package/dist/features/image-enlarge.js +16 -192
  5. package/dist/scaffold.d.ts +11 -0
  6. package/dist/scaffold.js +52 -8
  7. package/dist/settings-gen.js +4 -0
  8. package/package.json +1 -1
  9. package/templates/base/pages/_mdx-components.ts +64 -185
  10. package/templates/base/pages/index.tsx +1 -1
  11. package/templates/base/pages/lib/_extract-headings.ts +21 -295
  12. package/templates/base/pages/lib/_math-block.tsx +4 -63
  13. package/templates/base/src/components/content/code-group.tsx +3 -76
  14. package/templates/base/src/components/content/content-admonition.tsx +4 -56
  15. package/templates/base/src/config/settings-types.ts +34 -172
  16. package/templates/base/src/styles/global.css +35 -8
  17. package/templates/features/i18n/files/pages/[locale]/index.tsx +1 -1
  18. package/templates/base/pages/404.tsx +0 -61
  19. package/templates/base/pages/robots.txt.tsx +0 -5
  20. package/templates/base/pages/sitemap.xml.tsx +0 -59
  21. package/templates/base/plugins/copy-public-plugin.mjs +0 -58
  22. package/templates/base/src/components/site-tree-nav.tsx +0 -6
  23. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +0 -59
  24. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +0 -39
  25. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +0 -43
  26. package/templates/features/docTags/files/pages/docs/tags/index.tsx +0 -20
  27. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +0 -48
  28. package/templates/features/versioning/files/pages/docs/versions.tsx +0 -20
@@ -1,117 +1,52 @@
1
- // Shared MDX-component bag used by every doc-route page that renders
2
- // `<entry.Content components={...} />`.
1
+ // Project MDX-components assembly thin wrapper over the package factory.
3
2
  //
4
- // ## Why a shared helper
3
+ // The bulk of the components bag (htmlOverrides, admonitions, CodeGroup,
4
+ // Tabs/TabItem, MathBlock, the img base-rewrite, the enlargeable-`p` override)
5
+ // now ships from `@takazudo/zudo-doc/mdx-components` (package-first migration,
6
+ // epic #2321, S8 #2332). This file keeps ONLY what is genuinely project-bound:
5
7
  //
6
- // Pre-S4e the page-side `components` map only carried `htmlOverrides` plus
7
- // `HtmlPreview`, because the zfb content bridge wasn't installed and every
8
- // `<entry.Content>` call took the raw-markdown `<pre data-zfb-content-fallback>`
9
- // path. Now that the bridge IS installed (zudo-doc#506), the compiled
10
- // MDXContent functions fire for every entry and the MDX emitter wraps
11
- // every named-tag access with:
12
- //
13
- // const CategoryNav2 = _components.CategoryNav ?? components.CategoryNav;
14
- // if (!CategoryNav2) throw new Error("MDX requires `CategoryNav` to be passed via the `components` prop");
15
- //
16
- // So any tag the MDX corpus uses but the page omits → 500 at render time.
17
- //
18
- // ## Strategy
19
- //
20
- // This module ships stub bindings for tags not yet ported to `@takazudo/zudo-doc`
21
- // (render nothing), and real Preact bindings for tags whose ports are complete.
22
- // As real components land, they replace their stub here and propagate to every page automatically.
23
- //
24
- // `htmlOverrides` (basic typography — h2/h3/h4/p/a/ul/ol/blockquote/strong/table)
25
- // and `HtmlPreview: HtmlPreviewWrapper` (Island wrapper) stay in their
26
- // non-stub form because their Preact bindings already exist.
27
- //
28
- // ## Locale-aware bindings (createMdxComponents factory)
29
- //
30
- // CategoryNav, CategoryTreeNav, SiteTreeNav, and SiteTreeNavDemo resolve nav
31
- // tree data at render time. Since the same MDX content is rendered for both
32
- // default-locale and non-default-locale pages, these components need to know
33
- // which locale to use when building the nav tree.
34
- //
35
- // The `createMdxComponents(lang)` factory returns a components map with
36
- // locale-bound wrappers for these nav components. Page modules should call it
37
- // with the active locale instead of using the static `mdxComponents` export.
38
- // The static export still exists for backward compatibility (using defaultLocale).
8
+ // - the 4 locale-bound nav wrappers (CategoryNav / CategoryTreeNav /
9
+ // SiteTreeNav / SiteTreeNavDemo) they depend on the project's content
10
+ // collections + nav-tree utilities, so they cannot live in the package.
11
+ // They are passed into the factory as `navData`; the factory injects the
12
+ // active `locale` so `/ja` resolves the JA collection (the load-bearing
13
+ // locale thread — a static global mdx-components slot would render the EN
14
+ // tree on every `/ja` page, which is why this stays a per-call factory).
15
+ // - project `extras` spread last: HtmlPreview (with the host's global
16
+ // config), Details, the Island SSR pass-through, the PresetGenerator SSR
17
+ // shell, and the pure-showcase stubs (Avatar/Button/Card/MyComponent/
18
+ // PageLayout) that appear only as illustrative MDX prose.
39
19
 
40
20
  import type { ComponentChildren } from "preact";
41
- // @slot:mdx-components:enlarge-imports
42
- import { htmlOverrides } from "@takazudo/zudo-doc/content";
21
+ import { createMdxComponents as createMdxComponentsBase } from "@takazudo/zudo-doc/mdx-components";
43
22
  import { HtmlPreviewWrapper, type HtmlPreviewWrapperProps } from "@takazudo/zudo-doc/html-preview-wrapper";
44
- import { Tabs } from "@takazudo/zudo-doc/code-syntax";
45
- import { TabItem } from "@takazudo/zudo-doc/tab-item";
46
23
  import { defaultLocale, type Locale } from "@/config/i18n";
47
24
  import { settings } from "@/config/settings";
48
- import { withBase } from "@/utils/base";
49
25
  import { CategoryNavWrapper } from "./lib/_category-nav";
50
26
  import { CategoryTreeNavWrapper } from "./lib/_category-tree-nav";
51
27
  import { SiteTreeNavWrapper } from "./lib/_site-tree-nav";
52
28
  import { DetailsWrapper } from "./lib/_details";
53
29
  import { PresetGeneratorFallback } from "./lib/_preset-generator";
54
- import { MathBlock } from "./lib/_math-block";
55
- import { CodeGroup } from "@/components/content/code-group";
56
- import { makeAdmonition } from "@/components/content/content-admonition";
57
30
 
58
- /**
59
- * MDX `<img>` override rewrites root-relative src attributes to include the
60
- * configured site base path (settings.base). Without this, an MDX image like
61
- * `![alt](/img/foo.webp)` emits `src="/img/foo.webp"` which 404s when the
62
- * site is deployed under a sub-path prefix (e.g. /my-docs/).
63
- *
64
- * Only root-relative paths (starting with "/") are rewritten; external URLs,
65
- * protocol-relative URLs ("//…"), and data URIs pass through unchanged. The
66
- * withBase() call is generic — it reads settings.base at build time and applies
67
- * whatever prefix is configured.
68
- *
69
- * Note: `srcset` attributes are NOT rewritten here because the current MDX
70
- * corpus does not use srcset (standard markdown `![alt](src)` syntax produces
71
- * only `src`). If srcset with root-relative URLs is ever introduced, extend
72
- * this override to rewrite each srcset candidate URL as well.
73
- */
74
- function ContentImg(props: Record<string, unknown>) {
75
- const src = props.src;
76
- const rewrittenSrc =
77
- typeof src === "string" && src.startsWith("/") && !src.startsWith("//")
78
- ? withBase(src)
79
- : src;
80
- // Strip the "no-enlarge" sentinel from the rendered DOM — it is read by the
81
- // p-override before ContentImg is called (the VNode is still unlaunched at
82
- // that point), so we must delete it here to avoid leaking the sentinel into
83
- // the img title attribute.
84
- const { title, ...restProps } = props;
85
- const finalTitle = title === "no-enlarge" ? undefined : title;
86
- const mergedProps: Record<string, unknown> = { ...restProps, src: rewrittenSrc };
87
- if (finalTitle !== undefined) {
88
- mergedProps.title = finalTitle;
89
- }
90
- return { type: "img", props: mergedProps, key: null, constructor: undefined };
91
- }
31
+ const HtmlPreviewWithGlobalConfig = (props: HtmlPreviewWrapperProps) =>
32
+ HtmlPreviewWrapper({ globalConfig: settings.htmlPreview ?? null, ...props });
92
33
 
93
34
  /**
94
- * MDX-tag stub: renders nothing. Returning `null` keeps the rendered
95
- * tree intact (Preact's null-vnode path) without leaking placeholder
96
- * markup into the SSR output.
35
+ * MDX-tag stub: renders nothing. Returning `null` keeps the rendered tree
36
+ * intact (Preact's null-vnode path) without leaking placeholder markup into
37
+ * the SSR output.
97
38
  */
98
39
  const MdxStub = (_props: unknown) => null;
99
40
 
100
41
  /**
101
42
  * SSR-pass-through wrapper for `<Island when="load|idle|visible">`.
102
43
  *
103
- * In the zfb build the zfb `<Island>` component is unavailable, so the
104
- * MDX corpus tags resolve to this binding instead. Rendering the
105
- * children directly ensures that any server-renderable content nested
106
- * inside `<Island>` (headings, paragraphs, etc.) appears in the SSR
107
- * HTML. Client-only inner components that are themselves wrapped in an
108
- * SSR-skip placeholder will emit their own placeholder markup; this
109
- * wrapper does not suppress them.
110
- *
111
- * The `when` prop is intentionally ignored at render time — it is only
112
- * meaningful to the zfb hydration runtime on the client, which reads
113
- * the `data-when` attribute on the inner SSR-skip placeholder div (if
114
- * present) rather than on this wrapper.
44
+ * In the zfb build the zfb `<Island>` component is unavailable here, so the
45
+ * MDX corpus tag resolves to this binding instead. Rendering the children
46
+ * directly ensures server-renderable content nested inside `<Island>`
47
+ * (headings, paragraphs, etc.) appears in the SSR HTML. The `when` prop is
48
+ * ignored at render time it is only meaningful to the zfb hydration runtime
49
+ * on the client, which reads `data-when` on the inner SSR-skip placeholder.
115
50
  */
116
51
  function IslandWrapper(props: {
117
52
  when?: "load" | "idle" | "visible" | "media";
@@ -120,104 +55,48 @@ function IslandWrapper(props: {
120
55
  return props.children ?? null;
121
56
  }
122
57
 
123
- // @slot:mdx-components:enlarge-defs
124
- const HtmlPreviewWithGlobalConfig = (props: HtmlPreviewWrapperProps) =>
125
- HtmlPreviewWrapper({ globalConfig: settings.htmlPreview ?? null, ...props });
126
-
127
58
  /**
128
59
  * Build a locale-aware MDX components map for the given locale.
129
60
  *
130
- * Nav components (CategoryNav, CategoryTreeNav, SiteTreeNav, SiteTreeNavDemo)
131
- * resolve nav tree data at render time and need the active locale so they
132
- * query the right collection. The factory closes over `lang` and returns
133
- * locale-bound wrapper functions.
134
- *
135
- * Page modules should call createMdxComponents(locale) instead of importing
136
- * the static mdxComponents export.
61
+ * Delegates the package-resident components to the `@takazudo/zudo-doc`
62
+ * factory and supplies the project-bound pieces:
63
+ * - `navData`: the 4 locale-aware nav wrappers (the factory injects `lang`).
64
+ * - `extras`: HtmlPreview (host-configured), Details, Island pass-through,
65
+ * PresetGenerator SSR shell, and the showcase stubs.
137
66
  *
138
- * Components map includes:
139
- * - `htmlOverrides`element-level overrides for native tags (h2..h4,
140
- * p, a, ul/ol, blockquote, strong, table). Defined in
141
- * `@takazudo/zudo-doc/content`.
142
- * - `HtmlPreview` — Island-wrapped preview component.
143
- * - Real Preact wrappers for CategoryNav, CategoryTreeNav, SiteTreeNav,
144
- * SiteTreeNavDemo, and Details.
145
- * - `Island` — SSR pass-through wrapper so children render server-side.
146
- * - `PresetGenerator` — SSR fallback shell that renders the 8 h3 sections;
147
- * interactive form hydrates client-side via SSR-skip placeholder.
148
- * - Stub bindings for every other custom tag the MDX corpus references.
149
- *
150
- * Keep this list in sync with the corpus when new MDX tags appear.
151
- * `pnpm exec grep -rohE '<[A-Z][a-zA-Z]+' src/content/` enumerates them.
67
+ * Page modules should call createMdxComponents(locale) — not the static
68
+ * mdxComponents exportso each render gets the locale-correct map.
152
69
  */
153
70
  export function createMdxComponents(lang: Locale | string = defaultLocale) {
154
- // Locale-bound wrappers — close over `lang` so each wrapper queries
155
- // the correct collection without needing a prop.
156
- const CategoryNavBound = (props: Record<string, unknown>) =>
157
- CategoryNavWrapper({ ...(props as Parameters<typeof CategoryNavWrapper>[0]), lang });
158
- const CategoryTreeNavBound = (props: Record<string, unknown>) =>
159
- CategoryTreeNavWrapper({ ...(props as Parameters<typeof CategoryTreeNavWrapper>[0]), lang });
160
- const SiteTreeNavBound = (props: Record<string, unknown>) =>
161
- SiteTreeNavWrapper({ ...(props as Parameters<typeof SiteTreeNavWrapper>[0]), lang });
162
-
163
- return {
164
- ...htmlOverrides,
165
- // img override: rewrites root-relative src to include the site base path.
166
- // Required when settings.base is a sub-path (e.g. /my-docs/) so that
167
- // MDX images like ![alt](/img/foo.webp) resolve correctly on the deployed
168
- // site. withBase() is generic any configured base value works.
169
- img: ContentImg,
170
- // @slot:mdx-components:enlarge-p-entry
171
- HtmlPreview: HtmlPreviewWithGlobalConfig,
172
- // Admonitions real typed Preact components (src/components/content/
173
- // content-admonition.tsx) emitting the `.admonition` / `data-admonition`
174
- // structure the design-system CSS targets. The `directives` map in
175
- // zfb.config.ts emits these tags from `:::note` directives; `<Note
176
- // title="…">` JSX form is also authored directly.
177
- Note: makeAdmonition("note"),
178
- Tip: makeAdmonition("tip"),
179
- Info: makeAdmonition("info"),
180
- Warning: makeAdmonition("warning"),
181
- Danger: makeAdmonition("danger"),
182
- // github-alerts [!IMPORTANT] and [!CAUTION] map to these variants.
183
- // Without these bindings, those two alert variants 500 the SSR render.
184
- Important: makeAdmonition("important"),
185
- Caution: makeAdmonition("caution"),
186
- // codeTabs Option A: zfb emits <CodeGroup tabs={[...]}> for :::code-group.
187
- // The framework does not ship this component; we implement it here and map
188
- // the tabs[] + <pre data-lang> children to the existing Tabs/TabItem UI.
189
- CodeGroup: CodeGroup as unknown as (props: Record<string, unknown>) => unknown,
190
- // Showcase / nav helpers — real Preact wrappers replacing MdxStub.
191
- CategoryNav: CategoryNavBound,
192
- CategoryTreeNav: CategoryTreeNavBound,
193
- SiteTreeNav: SiteTreeNavBound,
194
- SiteTreeNavDemo: SiteTreeNavBound,
195
- Details: DetailsWrapper,
196
- Tabs,
197
- TabItem,
198
- // Math rendering — KaTeX via server-side katex.renderToString().
199
- // The math-equations.mdx content files write <MathBlock> JSX directly
200
- // (instead of $$…$$) because the zfb Rust emitter does not yet support
201
- // remark-math math nodes (zudo-front-builder #93).
202
- MathBlock,
203
- SmartBreak: MdxStub,
204
- // Island: pass children through so server-renderable content nested
205
- // inside <Island> appears in SSR HTML. See IslandWrapper comment above.
206
- Island: IslandWrapper,
207
- // PresetGenerator: render the 8 section headings as static SSR HTML for
208
- // a11y/SEO section structure and no-JS layout stability. The interactive
209
- // form loads client-side via the SSR-skip placeholder inside
210
- // PresetGeneratorFallback (see pages/lib/_preset-generator.tsx).
211
- PresetGenerator: PresetGeneratorFallback,
212
- // Pure showcase placeholders (Avatar/Button/Card/MyComponent/PageLayout
213
- // appear only inside MDX prose as illustrative examples — never
214
- // implemented as real components).
215
- Avatar: MdxStub,
216
- Button: MdxStub,
217
- Card: MdxStub,
218
- MyComponent: MdxStub,
219
- PageLayout: MdxStub,
220
- };
71
+ return createMdxComponentsBase({
72
+ settings,
73
+ locale: lang,
74
+ navData: {
75
+ CategoryNav: CategoryNavWrapper as unknown as (props: Record<string, unknown>) => unknown,
76
+ CategoryTreeNav: CategoryTreeNavWrapper as unknown as (props: Record<string, unknown>) => unknown,
77
+ SiteTreeNav: SiteTreeNavWrapper as unknown as (props: Record<string, unknown>) => unknown,
78
+ },
79
+ extras: {
80
+ HtmlPreview: HtmlPreviewWithGlobalConfig,
81
+ Details: DetailsWrapper,
82
+ // SmartBreak: corpus tag with no visual rendering render nothing.
83
+ SmartBreak: MdxStub,
84
+ // Island: pass children through so server-renderable content nested
85
+ // inside <Island> appears in SSR HTML. See IslandWrapper comment above.
86
+ Island: IslandWrapper,
87
+ // PresetGenerator: SSR fallback shell that renders the 8 section headings;
88
+ // the interactive form hydrates client-side via the SSR-skip placeholder
89
+ // inside PresetGeneratorFallback (see pages/lib/_preset-generator.tsx).
90
+ PresetGenerator: PresetGeneratorFallback,
91
+ // Pure showcase placeholders appear only inside MDX prose as
92
+ // illustrative examples, never implemented as real components.
93
+ Avatar: MdxStub,
94
+ Button: MdxStub,
95
+ Card: MdxStub,
96
+ MyComponent: MdxStub,
97
+ PageLayout: MdxStub,
98
+ },
99
+ });
221
100
  }
222
101
 
223
102
  /**
@@ -27,7 +27,7 @@ import { DocLayoutWithDefaults } from "@takazudo/zudo-doc/doclayout";
27
27
  import type { JSX } from "preact";
28
28
  import type { VNode } from "preact";
29
29
  import { Island } from "@takazudo/zfb";
30
- import SiteTreeNav from "@/components/site-tree-nav";
30
+ import { SiteTreeNav } from "@takazudo/zudo-doc/site-tree-nav-island";
31
31
  import { FooterWithDefaults } from "./lib/_footer-with-defaults";
32
32
  import { HeaderWithDefaults } from "./lib/_header-with-defaults";
33
33
  import { HeadWithDefaults } from "./lib/_head-with-defaults";
@@ -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
  }