create-zudo-doc 0.2.22 → 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.
- package/dist/compose.d.ts +6 -3
- package/dist/compose.js +6 -4
- package/dist/features/image-enlarge.d.ts +15 -13
- package/dist/features/image-enlarge.js +16 -192
- package/dist/scaffold.d.ts +11 -0
- package/dist/scaffold.js +36 -7
- package/dist/settings-gen.js +4 -0
- package/package.json +1 -1
- package/templates/base/pages/_mdx-components.ts +64 -185
- package/templates/base/pages/index.tsx +1 -1
- package/templates/base/pages/lib/_extract-headings.ts +21 -295
- package/templates/base/pages/lib/_math-block.tsx +4 -63
- package/templates/base/src/components/content/code-group.tsx +3 -76
- package/templates/base/src/components/content/content-admonition.tsx +4 -56
- package/templates/base/src/config/settings-types.ts +34 -172
- package/templates/base/src/styles/global.css +35 -8
- package/templates/features/i18n/files/pages/[locale]/index.tsx +1 -1
- package/templates/base/pages/404.tsx +0 -61
- package/templates/base/pages/robots.txt.tsx +0 -5
- package/templates/base/pages/sitemap.xml.tsx +0 -59
- package/templates/base/plugins/copy-public-plugin.mjs +0 -58
- package/templates/base/src/components/site-tree-nav.tsx +0 -6
- package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +0 -59
- package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +0 -39
- package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +0 -43
- package/templates/features/docTags/files/pages/docs/tags/index.tsx +0 -20
- package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +0 -48
- package/templates/features/versioning/files/pages/docs/versions.tsx +0 -20
|
@@ -1,117 +1,52 @@
|
|
|
1
|
-
//
|
|
2
|
-
// `<entry.Content components={...} />`.
|
|
1
|
+
// Project MDX-components assembly — thin wrapper over the package factory.
|
|
3
2
|
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
* configured site base path (settings.base). Without this, an MDX image like
|
|
61
|
-
* `` 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 `` 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
|
-
*
|
|
96
|
-
*
|
|
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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
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
|
-
*
|
|
139
|
-
*
|
|
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 export — so each render gets the locale-correct map.
|
|
152
69
|
*/
|
|
153
70
|
export function createMdxComponents(lang: Locale | string = defaultLocale) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 "
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 (``)
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
102
|
-
* `
|
|
103
|
-
*
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
|
|
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
|
}
|