create-zudo-doc 3.1.0 → 3.3.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/claude-md-gen.d.ts +8 -0
- package/dist/claude-md-gen.js +43 -26
- package/dist/compose.d.ts +14 -20
- package/dist/compose.js +15 -25
- package/dist/features/body-foot-util.d.ts +8 -4
- package/dist/features/body-foot-util.js +8 -4
- package/dist/features/claude-resources.d.ts +12 -0
- package/dist/features/claude-resources.js +13 -6
- package/dist/features/design-token-panel.d.ts +0 -12
- package/dist/features/design-token-panel.js +32 -93
- package/dist/features/doc-history.d.ts +18 -3
- package/dist/features/doc-history.js +45 -60
- package/dist/features/doc-tags.d.ts +9 -13
- package/dist/features/doc-tags.js +10 -26
- package/dist/features/dynamic-page-transition.d.ts +19 -30
- package/dist/features/dynamic-page-transition.js +21 -209
- package/dist/features/footer-taglist.d.ts +1 -1
- package/dist/features/footer-taglist.js +1 -1
- package/dist/features/footer.d.ts +3 -2
- package/dist/features/footer.js +3 -2
- package/dist/features/i18n.d.ts +13 -8
- package/dist/features/i18n.js +14 -9
- package/dist/features/image-enlarge.d.ts +7 -26
- package/dist/features/image-enlarge.js +7 -26
- package/dist/features/llms-txt.d.ts +3 -5
- package/dist/features/llms-txt.js +3 -5
- package/dist/features/search.d.ts +7 -3
- package/dist/features/search.js +7 -3
- package/dist/features/sidebar-resizer.d.ts +4 -8
- package/dist/features/sidebar-resizer.js +4 -8
- package/dist/features/sidebar-toggle.d.ts +7 -7
- package/dist/features/sidebar-toggle.js +7 -7
- package/dist/features/tag-governance.d.ts +14 -7
- package/dist/features/tag-governance.js +52 -8
- package/dist/features/tauri.d.ts +13 -10
- package/dist/features/tauri.js +16 -52
- package/dist/features/versioning.d.ts +12 -24
- package/dist/features/versioning.js +13 -39
- package/dist/index.js +2 -3
- package/dist/preset.d.ts +1 -0
- package/dist/preset.js +5 -0
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +1 -0
- package/dist/scaffold.d.ts +13 -6
- package/dist/scaffold.js +78 -59
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +14 -0
- package/dist/zfb-config-gen.d.ts +32 -20
- package/dist/zfb-config-gen.js +400 -53
- package/package.json +2 -2
- package/templates/base/pages/docs/[[...slug]].tsx +59 -0
- package/templates/base/pages/index.tsx +6 -41
- package/templates/base/scripts/setup-doc-skill.sh +261 -0
- package/templates/base/src/styles/global.css +15 -340
- package/templates/base/tsconfig.json +3 -26
- package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +88 -0
- package/templates/features/tagGovernance/files/scripts/tags-suggest.ts +3 -1
- package/dist/settings-gen.d.ts +0 -2
- package/dist/settings-gen.js +0 -319
- package/templates/base/.htmlvalidate.json +0 -5
- package/templates/base/.zfb/doc-history-meta.json +0 -1
- package/templates/base/pages/_data.ts +0 -160
- package/templates/base/pages/lib/_body-end-islands.tsx +0 -165
- package/templates/base/pages/lib/_chrome.ts +0 -167
- package/templates/base/pages/lib/_details.tsx +0 -29
- package/templates/base/pages/lib/_doc-route-entries.ts +0 -10
- package/templates/base/pages/lib/_extract-headings.ts +0 -37
- package/templates/base/pages/lib/_frontmatter-preview-data.ts +0 -27
- package/templates/base/pages/lib/_nav-source-cache.ts +0 -100
- package/templates/base/pages/lib/_nav-source-docs.ts +0 -17
- package/templates/base/pages/lib/_preset-generator.tsx +0 -81
- package/templates/base/pages/lib/_route-context.ts +0 -32
- package/templates/base/pages/lib/_search-widget.tsx +0 -17
- package/templates/base/pages/lib/doc-page-props.ts +0 -30
- package/templates/base/pages/lib/locale-merge.ts +0 -59
- package/templates/base/scripts/run-b4push.sh +0 -102
- package/templates/base/src/components/ai-chat-modal.tsx +0 -18
- package/templates/base/src/components/content/code-group.tsx +0 -3
- package/templates/base/src/components/content/content-admonition.tsx +0 -4
- package/templates/base/src/components/desktop-sidebar-toggle.tsx +0 -15
- package/templates/base/src/components/doc-history.tsx +0 -21
- package/templates/base/src/components/image-enlarge.tsx +0 -24
- package/templates/base/src/components/preset-generator.tsx +0 -14
- package/templates/base/src/components/sidebar-toggle.tsx +0 -6
- package/templates/base/src/components/sidebar-tree.tsx +0 -6
- package/templates/base/src/config/color-scheme-utils.ts +0 -69
- package/templates/base/src/config/color-schemes.ts +0 -165
- package/templates/base/src/config/docs-schema.ts +0 -95
- package/templates/base/src/config/frontmatter-preview-defaults.ts +0 -27
- package/templates/base/src/config/frontmatter-preview-renderers.tsx +0 -46
- package/templates/base/src/config/i18n.ts +0 -239
- package/templates/base/src/config/settings-types.ts +0 -45
- package/templates/base/src/config/sidebars.ts +0 -66
- package/templates/base/src/config/tag-vocabulary-types.ts +0 -4
- package/templates/base/src/config/tag-vocabulary.ts +0 -20
- package/templates/base/src/config/z-index-tokens.ts +0 -128
- package/templates/base/src/types/docs-entry.ts +0 -28
- package/templates/base/src/types/heading.ts +0 -5
- package/templates/base/src/types/locale.ts +0 -10
- package/templates/base/src/utils/base.ts +0 -164
- package/templates/base/src/utils/docs.ts +0 -446
- package/templates/base/src/utils/git-info.ts +0 -70
- package/templates/base/src/utils/github.ts +0 -22
- package/templates/base/src/utils/nav-scope.ts +0 -34
- package/templates/base/src/utils/sidebar.ts +0 -36
- package/templates/base/src/utils/slug.ts +0 -10
- package/templates/base/src/utils/smart-break.tsx +0 -12
- package/templates/base/src/utils/tags.ts +0 -83
- package/templates/base/zfb-shim.d.ts +0 -178
- package/templates/features/bodyFootUtil/files/src/utils/github.ts +0 -22
- package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/escape-for-mdx.test.ts +0 -42
- package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +0 -752
- package/templates/features/claudeResources/files/src/integrations/claude-resources/escape-for-mdx.ts +0 -97
- package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +0 -735
- package/templates/features/designTokenPanel/files/src/components/design-token-panel-bootstrap.tsx +0 -15
- package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +0 -435
- package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +0 -174
- package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +0 -30
- package/templates/features/docHistory/files/src/components/doc-history.tsx +0 -10
- package/templates/features/docHistory/files/src/types/doc-history.ts +0 -7
- package/templates/features/dynamicPageTransition/files/src/components/client-router-bootstrap.tsx +0 -72
- package/templates/features/i18n/files/pages/[locale]/index.tsx +0 -72
- package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +0 -11
- package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +0 -6
- package/templates/features/tauri/files/src/components/find-bar.tsx +0 -122
- package/templates/features/tauri/files/src/components/find-in-page-init.tsx +0 -59
- package/templates/features/tauri/files/src/utils/find-in-page.ts +0 -175
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { DocsData } from "@/config/docs-schema";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Concrete entry type for docs collections.
|
|
5
|
-
*
|
|
6
|
-
* Mirrors the public surface that pages consume from `getCollection(...)`.
|
|
7
|
-
* Originally this was structurally identical to Astro's `CollectionEntry`
|
|
8
|
-
* but is defined locally now that the project runs on the zfb content
|
|
9
|
-
* engine — collection-name-specific generics are not exposed by zfb, so
|
|
10
|
-
* pages cast collection entries to this shape via `pages/_data.ts`.
|
|
11
|
-
*
|
|
12
|
-
* `data` is typed as `DocsData` — the `z.infer`-derived type from
|
|
13
|
-
* `src/config/docs-schema.ts` — so the field set is maintained in one place.
|
|
14
|
-
*/
|
|
15
|
-
// Structural shape of zfb's optional rendered-content payload for a doc
|
|
16
|
-
// entry (kept loose to stay engine-agnostic — pages do not rely on the
|
|
17
|
-
// exact field set today).
|
|
18
|
-
type RenderedContent = unknown;
|
|
19
|
-
export interface DocsEntry {
|
|
20
|
-
id: string;
|
|
21
|
-
/** zfb content engine slug (filename without `.md`/`.mdx`; used by toRouteSlug). */
|
|
22
|
-
slug: string;
|
|
23
|
-
body?: string;
|
|
24
|
-
collection: string;
|
|
25
|
-
data: DocsData;
|
|
26
|
-
rendered?: RenderedContent;
|
|
27
|
-
filePath?: string;
|
|
28
|
-
}
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { settings } from "@/config/settings";
|
|
2
|
-
import { defaultLocale, locales, getLocaleLabel, type Locale } from "@/config/i18n";
|
|
3
|
-
import type { LocaleLink } from "@/types/locale";
|
|
4
|
-
|
|
5
|
-
/** Normalized base path with no trailing slash (empty string when "/"). */
|
|
6
|
-
export const normalizedBase = settings.base.replace(/\/+$/, "");
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Append a trailing slash to page URLs when `settings.trailingSlash` is true.
|
|
10
|
-
* Skips paths that already end with `/`, contain a file extension, or have a
|
|
11
|
-
* query string / fragment before the slash would be inserted.
|
|
12
|
-
*/
|
|
13
|
-
export function applyTrailingSlash(url: string): string {
|
|
14
|
-
if (!settings.trailingSlash) return url;
|
|
15
|
-
if (url.endsWith("/")) return url;
|
|
16
|
-
// Split off query string and fragment
|
|
17
|
-
const suffixIdx = url.search(/[?#]/);
|
|
18
|
-
const pathPart = suffixIdx >= 0 ? url.slice(0, suffixIdx) : url;
|
|
19
|
-
const suffix = suffixIdx >= 0 ? url.slice(suffixIdx) : "";
|
|
20
|
-
if (pathPart.endsWith("/")) return url;
|
|
21
|
-
// Check file extension on the last path segment only, requiring the extension
|
|
22
|
-
// to start with a letter to avoid false positives on version-like paths (e.g. /docs/v2.0)
|
|
23
|
-
const lastSegment = pathPart.split("/").pop() ?? "";
|
|
24
|
-
if (/\.[a-zA-Z]\w*$/.test(lastSegment)) return url;
|
|
25
|
-
return pathPart + "/" + suffix;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Prefix a path with the configured base directory. */
|
|
29
|
-
export function withBase(path: string): string {
|
|
30
|
-
const raw =
|
|
31
|
-
normalizedBase === ""
|
|
32
|
-
? path
|
|
33
|
-
: `${normalizedBase}${path.startsWith("/") ? path : `/${path}`}`;
|
|
34
|
-
return applyTrailingSlash(raw);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Strip the base prefix from a URL pathname. */
|
|
38
|
-
export function stripBase(path: string): string {
|
|
39
|
-
if (normalizedBase === "") return path;
|
|
40
|
-
// Require a segment boundary so base "/app" doesn't strip "/application/...".
|
|
41
|
-
if (path === normalizedBase) return "/";
|
|
42
|
-
return path.startsWith(`${normalizedBase}/`)
|
|
43
|
-
? path.slice(normalizedBase.length)
|
|
44
|
-
: path;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Build an absolute URL by joining `settings.siteUrl` (trailing slash stripped)
|
|
49
|
-
* with a base-prefixed page path. Returns `undefined` when `siteUrl` is unset
|
|
50
|
-
* (e.g. a freshly scaffolded project), so callers can skip emitting a useless
|
|
51
|
-
* relative canonical / og:image. Replaces the `siteUrl.replace(/\/$/, "") +
|
|
52
|
-
* pageUrl` pattern that was copy-pasted across the 4 doc routes and
|
|
53
|
-
* `_head-with-defaults.tsx` (#1917).
|
|
54
|
-
*/
|
|
55
|
-
export function absoluteUrl(pageUrl: string): string | undefined {
|
|
56
|
-
return settings.siteUrl ? settings.siteUrl.replace(/\/$/, "") + pageUrl : undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Build a docs URL for the given slug and lang. */
|
|
60
|
-
export function docsUrl(slug: string, lang: Locale | string = defaultLocale): string {
|
|
61
|
-
const path = lang === defaultLocale ? `/docs/${slug}` : `/${lang}/docs/${slug}`;
|
|
62
|
-
return withBase(path);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Check if a URL is external (starts with http:// or https://). */
|
|
66
|
-
export function isExternal(href: string): boolean {
|
|
67
|
-
return href.startsWith("http://") || href.startsWith("https://");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Resolve a href: external URLs pass through, internal ones get the base prefix. */
|
|
71
|
-
export function resolveHref(href: string): string {
|
|
72
|
-
return isExternal(href) ? href : withBase(href);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Build a localized, versioned nav href.
|
|
77
|
-
* Uses /v/{version}/{lang}/... ordering — the only shape the routing layer
|
|
78
|
-
* serves (pages/v/[version]/ja/docs/...), matching versionedDocsUrl().
|
|
79
|
-
* The /{lang}/v/{version}/... ordering has no route and 404s.
|
|
80
|
-
*/
|
|
81
|
-
export function navHref(
|
|
82
|
-
path: string,
|
|
83
|
-
lang: Locale | undefined,
|
|
84
|
-
currentVersion: string | undefined,
|
|
85
|
-
): string {
|
|
86
|
-
const isNonDefaultLocale = lang != null && lang !== defaultLocale;
|
|
87
|
-
const versionPrefix = currentVersion ? `/v/${currentVersion}` : "";
|
|
88
|
-
return withBase(
|
|
89
|
-
isNonDefaultLocale
|
|
90
|
-
? `${versionPrefix}/${lang}${path}`
|
|
91
|
-
: `${versionPrefix}${path}`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Split a leading /v/{version} prefix off a base-stripped path.
|
|
97
|
-
* Versioned routes nest the locale AFTER the version (/v/1.0/ja/docs/...),
|
|
98
|
-
* so locale stripping/prefixing must operate on the remainder only.
|
|
99
|
-
*/
|
|
100
|
-
function splitVersionPrefix(path: string): { versionPrefix: string; rest: string } {
|
|
101
|
-
const m = path.match(/^(\/v\/[^/]+)(\/.*|$)/);
|
|
102
|
-
return m ? { versionPrefix: m[1] ?? "", rest: m[2] ?? "/" } : { versionPrefix: "", rest: path };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Build a locale-switched path from the current page path. */
|
|
106
|
-
export function getPathForLocale(
|
|
107
|
-
path: string,
|
|
108
|
-
currentLang: Locale,
|
|
109
|
-
targetLang: Locale,
|
|
110
|
-
): string {
|
|
111
|
-
const { versionPrefix, rest } = splitVersionPrefix(stripBase(path));
|
|
112
|
-
let relativePath = rest;
|
|
113
|
-
if (currentLang !== defaultLocale) {
|
|
114
|
-
relativePath = relativePath.replace(new RegExp(`^/${currentLang}(?:/|$)`), "/");
|
|
115
|
-
}
|
|
116
|
-
if (targetLang !== defaultLocale) {
|
|
117
|
-
relativePath = `/${targetLang}${relativePath}`;
|
|
118
|
-
}
|
|
119
|
-
return withBase(`${versionPrefix}${relativePath}`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Build locale links for locale switcher UI components. */
|
|
123
|
-
export function buildLocaleLinks(currentPath: string, currentLang: Locale): LocaleLink[] {
|
|
124
|
-
let defaultLocalePath = splitVersionPrefix(stripBase(currentPath)).rest;
|
|
125
|
-
if (currentLang !== defaultLocale) {
|
|
126
|
-
defaultLocalePath = defaultLocalePath.replace(new RegExp(`^/${currentLang}(?:/|$)`), "/");
|
|
127
|
-
}
|
|
128
|
-
if (isDefaultLocaleOnlyPath(defaultLocalePath)) {
|
|
129
|
-
return [{
|
|
130
|
-
code: currentLang,
|
|
131
|
-
label: getLocaleLabel(currentLang),
|
|
132
|
-
href: getPathForLocale(currentPath, currentLang, currentLang),
|
|
133
|
-
active: true,
|
|
134
|
-
}];
|
|
135
|
-
}
|
|
136
|
-
return locales.map((code) => ({
|
|
137
|
-
code,
|
|
138
|
-
label: getLocaleLabel(code),
|
|
139
|
-
href: getPathForLocale(currentPath, currentLang, code),
|
|
140
|
-
active: code === currentLang,
|
|
141
|
-
}));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Returns true when the given default-locale-shaped path falls under one of
|
|
146
|
-
* the configured `defaultLocaleOnlyPrefixes`. Callers that work with
|
|
147
|
-
* locale-prefixed paths (e.g. `/ja/docs/...`) are responsible for stripping
|
|
148
|
-
* the locale segment before calling this function. The path is normalized to
|
|
149
|
-
* end with `/` before the comparison so the helper is robust to projects that
|
|
150
|
-
* disable `settings.trailingSlash` (where `docsUrl` returns slashless paths).
|
|
151
|
-
*/
|
|
152
|
-
export function isDefaultLocaleOnlyPath(path: string): boolean {
|
|
153
|
-
const stripped = stripBase(path);
|
|
154
|
-
const normalized = stripped.endsWith("/") ? stripped : `${stripped}/`;
|
|
155
|
-
return settings.defaultLocaleOnlyPrefixes.some((prefix) => normalized.startsWith(prefix));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Build a versioned docs URL for the given slug, version, and lang. */
|
|
159
|
-
export function versionedDocsUrl(slug: string, versionSlug: string, lang: Locale | string = defaultLocale): string {
|
|
160
|
-
const path = lang === defaultLocale
|
|
161
|
-
? `/v/${versionSlug}/docs/${slug}`
|
|
162
|
-
: `/v/${versionSlug}/${lang}/docs/${slug}`;
|
|
163
|
-
return withBase(path);
|
|
164
|
-
}
|
|
@@ -1,446 +0,0 @@
|
|
|
1
|
-
import type { DocsEntry } from "@/types/docs-entry";
|
|
2
|
-
import { docsUrl, withBase } from "@/utils/base";
|
|
3
|
-
import { defaultLocale, type Locale } from "@/config/i18n";
|
|
4
|
-
import {
|
|
5
|
-
buildSidebarTree,
|
|
6
|
-
type CategoryMeta,
|
|
7
|
-
type SidebarNode,
|
|
8
|
-
} from "@takazudo/zudo-doc/sidebar-tree";
|
|
9
|
-
|
|
10
|
-
/** Filter predicate: true when a doc should appear in navigation (sidebar, index, sitemap). */
|
|
11
|
-
export function isNavVisible(doc: DocsEntry): boolean {
|
|
12
|
-
return !doc.data.unlisted && !doc.data.standalone;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
// `_category_.json` loading + per-directory memoization live in the shared
|
|
16
|
-
// framework package — the host keeps no parallel copy (#2030 dedup). The
|
|
17
|
-
// package memoizes per resolved (absolute) contentDir, so the returned Map
|
|
18
|
-
// instance is stable per directory — which `stableMergeCategoryMeta` and the
|
|
19
|
-
// identity fast-path below rely on.
|
|
20
|
-
export { loadCategoryMeta } from "@takazudo/zudo-doc/sidebar-tree";
|
|
21
|
-
export type { CategoryMeta } from "@takazudo/zudo-doc/sidebar-tree";
|
|
22
|
-
|
|
23
|
-
export interface NavNode {
|
|
24
|
-
slug: string;
|
|
25
|
-
label: string;
|
|
26
|
-
description?: string;
|
|
27
|
-
position: number;
|
|
28
|
-
href?: string;
|
|
29
|
-
hasPage: boolean;
|
|
30
|
-
children: NavNode[];
|
|
31
|
-
sortOrder?: "asc" | "desc";
|
|
32
|
-
collapsed?: boolean;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Module-level cache — persists across all page renders during a single build.
|
|
36
|
-
//
|
|
37
|
-
// Bounded LRU, cap 64. A production build only ever needs a handful of
|
|
38
|
-
// distinct keys — one per (locale × version × categoryMeta-variant), single
|
|
39
|
-
// digits for this corpus — so 64 never evicts within one build. The cap
|
|
40
|
-
// exists for dev: every content edit under HMR produces a NEW content key
|
|
41
|
-
// (the key embeds nav-affecting frontmatter by design, so edits bust the
|
|
42
|
-
// cache), and before #2030 the Map grew unbounded across a long dev session,
|
|
43
|
-
// each entry holding an O(corpus) key string plus a full tree. With the cap,
|
|
44
|
-
// stale generations age out once 64 fresher keys land. (#1902's WeakMap
|
|
45
|
-
// identity fast-path keeps production hits off this Map entirely.)
|
|
46
|
-
const NAV_TREE_CACHE_MAX = 64;
|
|
47
|
-
const navTreeCache = new Map<string, NavNode[]>();
|
|
48
|
-
|
|
49
|
-
function navTreeCacheGet(key: string): NavNode[] | undefined {
|
|
50
|
-
const hit = navTreeCache.get(key);
|
|
51
|
-
if (hit !== undefined) {
|
|
52
|
-
// Refresh recency — Map preserves insertion order, so delete+set moves
|
|
53
|
-
// the entry to the "most recently used" end.
|
|
54
|
-
navTreeCache.delete(key);
|
|
55
|
-
navTreeCache.set(key, hit);
|
|
56
|
-
}
|
|
57
|
-
return hit;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function navTreeCacheSet(key: string, value: NavNode[]): void {
|
|
61
|
-
navTreeCache.delete(key);
|
|
62
|
-
navTreeCache.set(key, value);
|
|
63
|
-
if (navTreeCache.size > NAV_TREE_CACHE_MAX) {
|
|
64
|
-
const oldest = navTreeCache.keys().next().value;
|
|
65
|
-
if (oldest !== undefined) navTreeCache.delete(oldest);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Identity fast-path cache. Keyed on the docs-array reference: when nav-source
|
|
70
|
-
// loaders hand back the SAME stable array instance across the build's many
|
|
71
|
-
// `buildNavTree` calls (route paths() re-invoked per page, per-page sidebar +
|
|
72
|
-
// header), this lets us skip recomputing the O(n log n) stringify+sort key
|
|
73
|
-
// entirely. Anchored per (lang, categoryMeta) since the same array can be
|
|
74
|
-
// rendered under different locales / category metadata.
|
|
75
|
-
//
|
|
76
|
-
// This is ADDITIVE: a miss falls through to `navTreeCacheKey` + `navTreeCache`,
|
|
77
|
-
// which still catches content-equal arrays that lack reference identity (e.g.
|
|
78
|
-
// any caller that hasn't been routed through the stable loaders). HMR intent
|
|
79
|
-
// is preserved because a content edit produces a new snapshot → new stable
|
|
80
|
-
// array identity → fresh entry here AND a different content key downstream.
|
|
81
|
-
const navTreeByIdentity = new WeakMap<
|
|
82
|
-
DocsEntry[],
|
|
83
|
-
Array<{ lang: Locale; categoryMeta: Map<string, CategoryMeta> | undefined; tree: NavNode[] }>
|
|
84
|
-
>();
|
|
85
|
-
|
|
86
|
-
/** Build a cache key from docs array + locale + category meta.
|
|
87
|
-
* Includes nav-affecting frontmatter so HMR picks up changes. */
|
|
88
|
-
function navTreeCacheKey(
|
|
89
|
-
docs: DocsEntry[],
|
|
90
|
-
lang: Locale,
|
|
91
|
-
categoryMeta?: Map<string, CategoryMeta>,
|
|
92
|
-
): string {
|
|
93
|
-
const metaKey = categoryMeta
|
|
94
|
-
? JSON.stringify([...categoryMeta.entries()].sort(([a], [b]) => a.localeCompare(b)))
|
|
95
|
-
: "_";
|
|
96
|
-
return `${lang}:${metaKey}:${docs
|
|
97
|
-
.map((d) => {
|
|
98
|
-
const {
|
|
99
|
-
sidebar_position,
|
|
100
|
-
sidebar_label,
|
|
101
|
-
title,
|
|
102
|
-
description,
|
|
103
|
-
unlisted,
|
|
104
|
-
standalone,
|
|
105
|
-
slug,
|
|
106
|
-
category_no_page,
|
|
107
|
-
category_sort_order,
|
|
108
|
-
} = d.data;
|
|
109
|
-
return JSON.stringify([
|
|
110
|
-
d.id,
|
|
111
|
-
sidebar_position,
|
|
112
|
-
sidebar_label,
|
|
113
|
-
title,
|
|
114
|
-
description,
|
|
115
|
-
unlisted,
|
|
116
|
-
standalone,
|
|
117
|
-
slug,
|
|
118
|
-
category_no_page,
|
|
119
|
-
category_sort_order,
|
|
120
|
-
]);
|
|
121
|
-
})
|
|
122
|
-
.sort()
|
|
123
|
-
.join(",")}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** A docs-href builder: `(slug, locale) => url`. Lets a caller inject the URL
|
|
127
|
-
* space the tree's hrefs are minted in (e.g. a versioned route's own URLs)
|
|
128
|
-
* instead of the default latest-route `docsUrl`. */
|
|
129
|
-
export type BuildHref = (slug: string, locale: Locale) => string;
|
|
130
|
-
|
|
131
|
-
/** Optional injected dependencies for {@link buildNavTree}. */
|
|
132
|
-
export interface BuildNavTreeOptions {
|
|
133
|
-
/** Override the href builder used for every node (defaults to `docsUrl`). */
|
|
134
|
-
buildHref?: BuildHref;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Build a recursive navigation tree from a flat content collection.
|
|
139
|
-
* Mirrors the filesystem: directories become category nodes, files become leaves.
|
|
140
|
-
*
|
|
141
|
-
* Since #2030 the tree construction itself is delegated to the shared
|
|
142
|
-
* framework builder (`buildSidebarTree` in @takazudo/zudo-doc/sidebar-tree);
|
|
143
|
-
* this function keeps the host-side concerns: the NavNode shape consumed by
|
|
144
|
-
* every host nav surface, the content-key cache, and the identity fast-path.
|
|
145
|
-
*
|
|
146
|
-
* `options.buildHref` parameterizes the href space (#2344, S1a): callers pass it
|
|
147
|
-
* to mint hrefs in their own route's URL space. Omit it to keep the default
|
|
148
|
-
* latest-route `docsUrl` behavior — every existing 3-arg call site is unchanged.
|
|
149
|
-
*/
|
|
150
|
-
export function buildNavTree(
|
|
151
|
-
docs: DocsEntry[],
|
|
152
|
-
lang: Locale = defaultLocale,
|
|
153
|
-
categoryMeta?: Map<string, CategoryMeta>,
|
|
154
|
-
options?: BuildNavTreeOptions,
|
|
155
|
-
): NavNode[] {
|
|
156
|
-
const buildHref: BuildHref = options?.buildHref ?? ((slug, locale) => docsUrl(slug, locale));
|
|
157
|
-
// Both cache layers are keyed by (docs, lang, categoryMeta) and assume the
|
|
158
|
-
// default `docsUrl` href space. A custom `buildHref` mints a DIFFERENT href
|
|
159
|
-
// space for the same key, so it must skip both the read (would return a tree
|
|
160
|
-
// with default hrefs) and the write (would poison the default-href cache for
|
|
161
|
-
// later callers). Only the default-href path — every current call site — is
|
|
162
|
-
// cached; custom-href callers (versioned routes, S6/S7/S8) recompute. The
|
|
163
|
-
// cache exists for the hot default path, which this preserves exactly.
|
|
164
|
-
const useCache = options?.buildHref === undefined;
|
|
165
|
-
|
|
166
|
-
// Identity fast-path: stable array instance already seen for this
|
|
167
|
-
// (lang, categoryMeta)? Return its tree without recomputing the key.
|
|
168
|
-
if (useCache) {
|
|
169
|
-
const byIdentity = navTreeByIdentity.get(docs);
|
|
170
|
-
if (byIdentity) {
|
|
171
|
-
for (const slot of byIdentity) {
|
|
172
|
-
if (slot.lang === lang && slot.categoryMeta === categoryMeta) {
|
|
173
|
-
return slot.tree;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const cacheKey = navTreeCacheKey(docs, lang, categoryMeta);
|
|
179
|
-
const cached = navTreeCacheGet(cacheKey);
|
|
180
|
-
if (cached) {
|
|
181
|
-
rememberIdentity(docs, lang, categoryMeta, cached);
|
|
182
|
-
return cached;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const sidebarTree = buildSidebarTree(
|
|
187
|
-
// Pass `{ id, data }` only — NOT the whole entry. zfb entries carry the
|
|
188
|
-
// raw, un-index-stripped engine slug on the top-level `slug` field
|
|
189
|
-
// (e.g. "getting-started/index"), and the shared builder prefers
|
|
190
|
-
// `entry.slug` over the id-derived form; forwarding it would mint wrong
|
|
191
|
-
// node paths. Omitting it reproduces the legacy host derivation
|
|
192
|
-
// `data.slug ?? toRouteSlug(id)` (ids arrive pre-stripped via _data.ts).
|
|
193
|
-
docs.map((d) => ({ id: d.id, data: d.data })),
|
|
194
|
-
lang,
|
|
195
|
-
{
|
|
196
|
-
categoryMeta,
|
|
197
|
-
buildHref: (slug, locale) => buildHref(slug, locale as Locale),
|
|
198
|
-
// Host call sites own visibility: nav surfaces pre-filter via
|
|
199
|
-
// `stableNavDocs(docs.filter(isNavVisible))`, while the breadcrumb tree
|
|
200
|
-
// intentionally builds from the UNFILTERED list so unlisted pages still
|
|
201
|
-
// get breadcrumbs. Disable the builder's default unlisted/standalone
|
|
202
|
-
// filter so neither path changes behavior.
|
|
203
|
-
isNavVisible: () => true,
|
|
204
|
-
},
|
|
205
|
-
);
|
|
206
|
-
const result = sidebarTree.map(toNavNode);
|
|
207
|
-
|
|
208
|
-
// Root docs-index entry (derived slug "" — a root index.mdx arrives from
|
|
209
|
-
// _data.ts bridging as id ""). The shared builder drops empty slugs, but the
|
|
210
|
-
// legacy host builder minted a top-level node keyed "" (href /docs/) so the
|
|
211
|
-
// root page stayed present in sidebar/breadcrumb/prev-next data. Re-create
|
|
212
|
-
// that node here with the exact legacy field derivation, then re-sort with
|
|
213
|
-
// the same comparator the builder used (stable sort → idempotent for the
|
|
214
|
-
// already-sorted rest).
|
|
215
|
-
const rootDoc = findRootIndexDoc(docs);
|
|
216
|
-
if (rootDoc) {
|
|
217
|
-
result.push(toRootNavNode(rootDoc, lang, buildHref, categoryMeta));
|
|
218
|
-
result.sort((a, b) => {
|
|
219
|
-
const posCompare = a.position - b.position;
|
|
220
|
-
if (posCompare !== 0) return posCompare;
|
|
221
|
-
return a.slug.localeCompare(b.slug);
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (useCache) {
|
|
226
|
-
navTreeCacheSet(navTreeCacheKey(docs, lang, categoryMeta), result);
|
|
227
|
-
rememberIdentity(docs, lang, categoryMeta, result);
|
|
228
|
-
}
|
|
229
|
-
return result;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** Last entry whose package-derived slug is empty ("") — i.e. the entry the
|
|
233
|
-
* shared builder skips. Last one wins, mirroring the legacy builder's
|
|
234
|
-
* `node.doc = doc` overwrite. (A bare id "index" is NOT matched here: both
|
|
235
|
-
* the legacy and shared builders resolve it to a node keyed "index".) */
|
|
236
|
-
function findRootIndexDoc(docs: DocsEntry[]): DocsEntry | undefined {
|
|
237
|
-
let found: DocsEntry | undefined;
|
|
238
|
-
for (const d of docs) {
|
|
239
|
-
const slug = d.data.slug ?? d.id.replace(/\/index$/, "");
|
|
240
|
-
if (slug === "") found = d;
|
|
241
|
-
}
|
|
242
|
-
return found;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** Legacy-faithful node for the root docs index (slug ""): no children are
|
|
246
|
-
* possible (a multi-segment slug never has an empty first part), label falls
|
|
247
|
-
* back through the same chain (title is required, so it always resolves),
|
|
248
|
-
* and href is the locale docs root. */
|
|
249
|
-
function toRootNavNode(
|
|
250
|
-
doc: DocsEntry,
|
|
251
|
-
lang: Locale,
|
|
252
|
-
buildHref: BuildHref,
|
|
253
|
-
categoryMeta?: Map<string, CategoryMeta>,
|
|
254
|
-
): NavNode {
|
|
255
|
-
const meta = categoryMeta?.get("");
|
|
256
|
-
const noPage = doc.data.category_no_page ?? meta?.noPage;
|
|
257
|
-
const sortOrder = doc.data.category_sort_order ?? meta?.sortOrder ?? "asc";
|
|
258
|
-
return {
|
|
259
|
-
slug: "",
|
|
260
|
-
label: doc.data.sidebar_label ?? doc.data.title ?? meta?.label ?? "",
|
|
261
|
-
description: doc.data.description ?? meta?.description,
|
|
262
|
-
position: doc.data.sidebar_position ?? meta?.position ?? 999,
|
|
263
|
-
href: noPage ? undefined : buildHref("", lang),
|
|
264
|
-
hasPage: noPage !== true,
|
|
265
|
-
children: [],
|
|
266
|
-
sortOrder,
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** Map the shared builder's SidebarNode shape onto the host NavNode shape.
|
|
271
|
-
* `position` and `sortOrder` defaults mirror the legacy inline builder:
|
|
272
|
-
* position falls back to 999 (ties sort alphabetically) and sortOrder is
|
|
273
|
-
* always materialized ("asc" unless frontmatter/sidecar set it). */
|
|
274
|
-
function toNavNode(node: SidebarNode): NavNode {
|
|
275
|
-
return {
|
|
276
|
-
slug: node.id,
|
|
277
|
-
label: node.label,
|
|
278
|
-
description: node.description,
|
|
279
|
-
position: node.sidebar_position ?? 999,
|
|
280
|
-
href: node.href,
|
|
281
|
-
hasPage: node.hasPage,
|
|
282
|
-
children: node.children.map(toNavNode),
|
|
283
|
-
sortOrder: node.sortOrder ?? "asc",
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** Record a (docs-array identity, lang, categoryMeta) → tree mapping for the
|
|
288
|
-
* identity fast-path. No-op-safe to call multiple times for the same slot. */
|
|
289
|
-
function rememberIdentity(
|
|
290
|
-
docs: DocsEntry[],
|
|
291
|
-
lang: Locale,
|
|
292
|
-
categoryMeta: Map<string, CategoryMeta> | undefined,
|
|
293
|
-
tree: NavNode[],
|
|
294
|
-
): void {
|
|
295
|
-
let slots = navTreeByIdentity.get(docs);
|
|
296
|
-
if (!slots) {
|
|
297
|
-
slots = [];
|
|
298
|
-
navTreeByIdentity.set(docs, slots);
|
|
299
|
-
}
|
|
300
|
-
if (!slots.some((s) => s.lang === lang && s.categoryMeta === categoryMeta)) {
|
|
301
|
-
slots.push({ lang, categoryMeta, tree });
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Group "satellite" nodes under their primary node based on slug prefixes.
|
|
307
|
-
* E.g. with prefix "claude", nodes "claude-md", "claude-commands" get moved
|
|
308
|
-
* under the "claude" node as children.
|
|
309
|
-
*/
|
|
310
|
-
export function groupSatelliteNodes(tree: NavNode[], prefixes: string[]): NavNode[] {
|
|
311
|
-
const result = [...tree];
|
|
312
|
-
for (const prefix of prefixes) {
|
|
313
|
-
const primaryIdx = result.findIndex((n) => n.slug === prefix);
|
|
314
|
-
if (primaryIdx < 0) continue;
|
|
315
|
-
const primary = result[primaryIdx];
|
|
316
|
-
if (!primary) continue;
|
|
317
|
-
const satelliteIdxs: number[] = [];
|
|
318
|
-
for (let i = 0; i < result.length; i++) {
|
|
319
|
-
const node = result[i];
|
|
320
|
-
if (node && i !== primaryIdx && node.slug.startsWith(`${prefix}-`)) {
|
|
321
|
-
satelliteIdxs.push(i);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
if (satelliteIdxs.length === 0) continue;
|
|
325
|
-
const extraChildren: NavNode[] = [];
|
|
326
|
-
for (const idx of satelliteIdxs) {
|
|
327
|
-
const node = result[idx];
|
|
328
|
-
if (node) extraChildren.push(node);
|
|
329
|
-
}
|
|
330
|
-
result[primaryIdx] = {
|
|
331
|
-
...primary,
|
|
332
|
-
children: [...primary.children, ...extraChildren],
|
|
333
|
-
};
|
|
334
|
-
for (const idx of satelliteIdxs.reverse()) {
|
|
335
|
-
result.splice(idx, 1);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
return result;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** DFS flatten the tree for prev/next navigation. Only includes nodes with pages. */
|
|
342
|
-
export function flattenTree(nodes: NavNode[]): NavNode[] {
|
|
343
|
-
const result: NavNode[] = [];
|
|
344
|
-
flattenInto(nodes, result);
|
|
345
|
-
return result;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function flattenInto(nodes: NavNode[], acc: NavNode[]): void {
|
|
349
|
-
for (const node of nodes) {
|
|
350
|
-
if (node.hasPage) {
|
|
351
|
-
acc.push(node);
|
|
352
|
-
}
|
|
353
|
-
flattenInto(node.children, acc);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** Collect all category nodes that have children but no page (no index.mdx).
|
|
358
|
-
* Nodes without href (e.g. noPage categories) are skipped — they are toggle-only. */
|
|
359
|
-
export function collectAutoIndexNodes(nodes: NavNode[]): NavNode[] {
|
|
360
|
-
const result: NavNode[] = [];
|
|
361
|
-
for (const node of nodes) {
|
|
362
|
-
if (!node.hasPage && node.children.length > 0 && node.href) {
|
|
363
|
-
result.push(node);
|
|
364
|
-
}
|
|
365
|
-
result.push(...collectAutoIndexNodes(node.children));
|
|
366
|
-
}
|
|
367
|
-
return result;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Find a node by slug anywhere in the tree. */
|
|
371
|
-
export function findNode(nodes: NavNode[], slug: string): NavNode | undefined {
|
|
372
|
-
for (const node of nodes) {
|
|
373
|
-
if (node.slug === slug) return node;
|
|
374
|
-
const found = findNode(node.children, slug);
|
|
375
|
-
if (found) return found;
|
|
376
|
-
}
|
|
377
|
-
return undefined;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Return the href of the first routed descendant (a node with `hasPage` and an
|
|
382
|
-
* `href`), walking children depth-first in order. Returns undefined when the
|
|
383
|
-
* subtree has no routed page.
|
|
384
|
-
*
|
|
385
|
-
* Used by CategoryNav's `categories=` mode to give a `category_no_page` card a
|
|
386
|
-
* real destination: such a category has no route of its own (collectAutoIndexNodes
|
|
387
|
-
* skips noPage nodes), so a card linking to its own slug URL would be a dead
|
|
388
|
-
* link. Linking to the first child page keeps the route surface unchanged.
|
|
389
|
-
*/
|
|
390
|
-
export function firstRoutedHref(node: NavNode): string | undefined {
|
|
391
|
-
for (const child of node.children) {
|
|
392
|
-
if (child.hasPage && child.href) return child.href;
|
|
393
|
-
const nested = firstRoutedHref(child);
|
|
394
|
-
if (nested) return nested;
|
|
395
|
-
}
|
|
396
|
-
return undefined;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
export interface BreadcrumbItem {
|
|
400
|
-
label: string;
|
|
401
|
-
href?: string;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Build breadcrumb trail by walking the nav tree.
|
|
406
|
-
*
|
|
407
|
-
* Nav-node hrefs are always the LATEST `docsUrl(slug, lang)` values (see the
|
|
408
|
-
* `buildHref` wiring in `buildNavTree`). On versioned routes that would make
|
|
409
|
-
* breadcrumbs link back to
|
|
410
|
-
* latest content (#1916 #1). Pass an optional `hrefFor(slug)` to remap each
|
|
411
|
-
* intermediate crumb's href to the route's own URL space (e.g.
|
|
412
|
-
* `versionedDocsUrl`-bound). The home crumb and the current/last crumb carry no
|
|
413
|
-
* remappable href and are left untouched. Omit `hrefFor` (latest routes) to
|
|
414
|
-
* keep the unversioned hrefs.
|
|
415
|
-
*/
|
|
416
|
-
export function buildBreadcrumbs(
|
|
417
|
-
tree: NavNode[],
|
|
418
|
-
slug: string,
|
|
419
|
-
lang: Locale = defaultLocale,
|
|
420
|
-
hrefFor?: (slug: string) => string,
|
|
421
|
-
): BreadcrumbItem[] {
|
|
422
|
-
const parts = slug.split("/");
|
|
423
|
-
const homeHref = lang === defaultLocale ? withBase("/") : withBase(`/${lang}/`);
|
|
424
|
-
const crumbs: BreadcrumbItem[] = [{ label: "", href: homeHref }];
|
|
425
|
-
let nodes = tree;
|
|
426
|
-
|
|
427
|
-
for (let i = 0; i < parts.length; i++) {
|
|
428
|
-
const partialSlug = parts.slice(0, i + 1).join("/");
|
|
429
|
-
const node = nodes.find((n) => n.slug === partialSlug);
|
|
430
|
-
if (!node) break;
|
|
431
|
-
|
|
432
|
-
const isLast = i === parts.length - 1;
|
|
433
|
-
const href = isLast
|
|
434
|
-
? undefined
|
|
435
|
-
: hrefFor && node.href !== undefined
|
|
436
|
-
? hrefFor(node.slug)
|
|
437
|
-
: node.href;
|
|
438
|
-
crumbs.push({
|
|
439
|
-
label: node.label,
|
|
440
|
-
href,
|
|
441
|
-
});
|
|
442
|
-
nodes = node.children;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
return crumbs;
|
|
446
|
-
}
|