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.
- package/dist/compose.d.ts +6 -3
- package/dist/compose.js +6 -4
- package/dist/features/image-enlarge.d.ts +22 -12
- package/dist/features/image-enlarge.js +23 -191
- package/dist/scaffold.d.ts +11 -0
- package/dist/scaffold.js +51 -24
- package/dist/settings-gen.js +4 -0
- package/dist/zfb-config-gen.d.ts +16 -10
- package/dist/zfb-config-gen.js +34 -239
- 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/_body-end-islands.tsx +6 -13
- package/templates/base/pages/lib/_category-nav.tsx +30 -115
- package/templates/base/pages/lib/_category-tree-nav.tsx +38 -65
- package/templates/base/pages/lib/_compose-meta-title.ts +2 -6
- package/templates/base/pages/lib/_doc-body-end.tsx +3 -35
- package/templates/base/pages/lib/_doc-content-header.tsx +10 -111
- package/templates/base/pages/lib/_doc-history-area.tsx +19 -211
- package/templates/base/pages/lib/_doc-metainfo-area.tsx +10 -113
- package/templates/base/pages/lib/_doc-page-renderer.tsx +22 -183
- package/templates/base/pages/lib/_doc-page-shell.tsx +17 -251
- package/templates/base/pages/lib/_doc-pager.tsx +4 -74
- package/templates/base/pages/lib/_doc-route-entries.ts +43 -177
- package/templates/base/pages/lib/_doc-route-paths.ts +16 -101
- package/templates/base/pages/lib/_doc-tags-area.tsx +14 -77
- package/templates/base/pages/lib/_extract-headings.ts +21 -295
- package/templates/base/pages/lib/_footer-with-defaults.tsx +37 -225
- package/templates/base/pages/lib/_frontmatter-preview-data.ts +5 -31
- package/templates/base/pages/lib/_head-with-defaults.tsx +13 -138
- package/templates/base/pages/lib/_header-with-defaults.tsx +24 -324
- package/templates/base/pages/lib/_inline-version-switcher.tsx +16 -78
- package/templates/base/pages/lib/_math-block.tsx +4 -63
- package/templates/base/pages/lib/_nav-data-prep.ts +32 -51
- package/templates/base/pages/lib/_nav-source-cache.ts +12 -57
- package/templates/base/pages/lib/_nav-source-docs.ts +33 -233
- package/templates/base/pages/lib/_search-widget-script.ts +2 -470
- package/templates/base/pages/lib/_search-widget.tsx +9 -195
- package/templates/base/pages/lib/_sidebar-prepaint.tsx +6 -59
- package/templates/base/pages/lib/_sidebar-with-defaults.tsx +16 -118
- package/templates/base/pages/lib/_site-tree-nav.tsx +29 -80
- package/templates/base/pages/lib/doc-page-props.ts +26 -44
- package/templates/base/pages/lib/locale-merge.ts +32 -145
- package/templates/base/pages/lib/route-enumerators.ts +52 -286
- package/templates/base/src/components/ai-chat-modal.tsx +9 -8
- 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/components/doc-history.tsx +9 -8
- package/templates/base/src/components/image-enlarge.tsx +11 -8
- package/templates/base/src/components/sidebar-toggle.tsx +6 -170
- package/templates/base/src/components/sidebar-tree.tsx +6 -548
- package/templates/base/src/config/color-scheme-utils.ts +34 -158
- package/templates/base/src/config/i18n.ts +9 -0
- package/templates/base/src/config/settings-types.ts +34 -172
- package/templates/base/src/config/z-index-tokens.ts +5 -4
- package/templates/base/src/styles/global.css +40 -587
- package/templates/base/src/utils/base.ts +1 -1
- package/templates/base/src/utils/docs.ts +47 -16
- package/templates/base/src/utils/github.ts +12 -9
- package/templates/base/src/utils/nav-scope.ts +13 -42
- package/templates/base/src/utils/sidebar.ts +18 -86
- package/templates/base/src/utils/slug.ts +10 -53
- package/templates/base/src/utils/smart-break.tsx +12 -120
- package/templates/base/src/utils/tags.ts +25 -68
- package/templates/features/bodyFootUtil/files/src/utils/github.ts +12 -9
- package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +13 -39
- package/templates/features/docHistory/files/src/components/doc-history.tsx +8 -636
- package/templates/features/docHistory/files/src/types/doc-history.ts +7 -23
- package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +35 -201
- package/templates/features/i18n/files/pages/[locale]/index.tsx +1 -1
- package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +8 -269
- package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +6 -99
- package/templates/features/tagGovernance/files/scripts/tags-audit.ts +67 -515
- package/templates/features/versioning/files/pages/lib/_versions-page.tsx +21 -73
- package/templates/base/pages/404.tsx +0 -61
- package/templates/base/pages/robots.txt.tsx +0 -29
- package/templates/base/pages/sitemap.xml.tsx +0 -59
- package/templates/base/plugins/connect-adapter.mjs +0 -169
- package/templates/base/plugins/copy-public-plugin.mjs +0 -58
- package/templates/base/plugins/search-index-plugin.mjs +0 -66
- package/templates/base/scripts/gen-z-index.mjs +0 -157
- package/templates/base/src/components/mermaid-enlarge.tsx +0 -490
- package/templates/base/src/components/site-tree-nav.tsx +0 -220
- package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +0 -47
- package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +0 -111
- 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/llmsTxt/files/plugins/llms-txt-plugin.mjs +0 -93
- package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +0 -48
- package/templates/features/versioning/files/pages/docs/versions.tsx +0 -20
|
@@ -1,101 +1,16 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
//
|
|
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 {
|
|
26
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
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
|
}
|