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.
Files changed (127) hide show
  1. package/dist/claude-md-gen.d.ts +8 -0
  2. package/dist/claude-md-gen.js +43 -26
  3. package/dist/compose.d.ts +14 -20
  4. package/dist/compose.js +15 -25
  5. package/dist/features/body-foot-util.d.ts +8 -4
  6. package/dist/features/body-foot-util.js +8 -4
  7. package/dist/features/claude-resources.d.ts +12 -0
  8. package/dist/features/claude-resources.js +13 -6
  9. package/dist/features/design-token-panel.d.ts +0 -12
  10. package/dist/features/design-token-panel.js +32 -93
  11. package/dist/features/doc-history.d.ts +18 -3
  12. package/dist/features/doc-history.js +45 -60
  13. package/dist/features/doc-tags.d.ts +9 -13
  14. package/dist/features/doc-tags.js +10 -26
  15. package/dist/features/dynamic-page-transition.d.ts +19 -30
  16. package/dist/features/dynamic-page-transition.js +21 -209
  17. package/dist/features/footer-taglist.d.ts +1 -1
  18. package/dist/features/footer-taglist.js +1 -1
  19. package/dist/features/footer.d.ts +3 -2
  20. package/dist/features/footer.js +3 -2
  21. package/dist/features/i18n.d.ts +13 -8
  22. package/dist/features/i18n.js +14 -9
  23. package/dist/features/image-enlarge.d.ts +7 -26
  24. package/dist/features/image-enlarge.js +7 -26
  25. package/dist/features/llms-txt.d.ts +3 -5
  26. package/dist/features/llms-txt.js +3 -5
  27. package/dist/features/search.d.ts +7 -3
  28. package/dist/features/search.js +7 -3
  29. package/dist/features/sidebar-resizer.d.ts +4 -8
  30. package/dist/features/sidebar-resizer.js +4 -8
  31. package/dist/features/sidebar-toggle.d.ts +7 -7
  32. package/dist/features/sidebar-toggle.js +7 -7
  33. package/dist/features/tag-governance.d.ts +14 -7
  34. package/dist/features/tag-governance.js +52 -8
  35. package/dist/features/tauri.d.ts +13 -10
  36. package/dist/features/tauri.js +16 -52
  37. package/dist/features/versioning.d.ts +12 -24
  38. package/dist/features/versioning.js +13 -39
  39. package/dist/index.js +2 -3
  40. package/dist/preset.d.ts +1 -0
  41. package/dist/preset.js +5 -0
  42. package/dist/prompts.d.ts +2 -0
  43. package/dist/prompts.js +1 -0
  44. package/dist/scaffold.d.ts +13 -6
  45. package/dist/scaffold.js +78 -59
  46. package/dist/utils.d.ts +10 -0
  47. package/dist/utils.js +14 -0
  48. package/dist/zfb-config-gen.d.ts +32 -20
  49. package/dist/zfb-config-gen.js +400 -53
  50. package/package.json +2 -2
  51. package/templates/base/pages/docs/[[...slug]].tsx +59 -0
  52. package/templates/base/pages/index.tsx +6 -41
  53. package/templates/base/scripts/setup-doc-skill.sh +261 -0
  54. package/templates/base/src/styles/global.css +15 -340
  55. package/templates/base/tsconfig.json +3 -26
  56. package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +88 -0
  57. package/templates/features/tagGovernance/files/scripts/tags-suggest.ts +3 -1
  58. package/dist/settings-gen.d.ts +0 -2
  59. package/dist/settings-gen.js +0 -319
  60. package/templates/base/.htmlvalidate.json +0 -5
  61. package/templates/base/.zfb/doc-history-meta.json +0 -1
  62. package/templates/base/pages/_data.ts +0 -160
  63. package/templates/base/pages/lib/_body-end-islands.tsx +0 -165
  64. package/templates/base/pages/lib/_chrome.ts +0 -167
  65. package/templates/base/pages/lib/_details.tsx +0 -29
  66. package/templates/base/pages/lib/_doc-route-entries.ts +0 -10
  67. package/templates/base/pages/lib/_extract-headings.ts +0 -37
  68. package/templates/base/pages/lib/_frontmatter-preview-data.ts +0 -27
  69. package/templates/base/pages/lib/_nav-source-cache.ts +0 -100
  70. package/templates/base/pages/lib/_nav-source-docs.ts +0 -17
  71. package/templates/base/pages/lib/_preset-generator.tsx +0 -81
  72. package/templates/base/pages/lib/_route-context.ts +0 -32
  73. package/templates/base/pages/lib/_search-widget.tsx +0 -17
  74. package/templates/base/pages/lib/doc-page-props.ts +0 -30
  75. package/templates/base/pages/lib/locale-merge.ts +0 -59
  76. package/templates/base/scripts/run-b4push.sh +0 -102
  77. package/templates/base/src/components/ai-chat-modal.tsx +0 -18
  78. package/templates/base/src/components/content/code-group.tsx +0 -3
  79. package/templates/base/src/components/content/content-admonition.tsx +0 -4
  80. package/templates/base/src/components/desktop-sidebar-toggle.tsx +0 -15
  81. package/templates/base/src/components/doc-history.tsx +0 -21
  82. package/templates/base/src/components/image-enlarge.tsx +0 -24
  83. package/templates/base/src/components/preset-generator.tsx +0 -14
  84. package/templates/base/src/components/sidebar-toggle.tsx +0 -6
  85. package/templates/base/src/components/sidebar-tree.tsx +0 -6
  86. package/templates/base/src/config/color-scheme-utils.ts +0 -69
  87. package/templates/base/src/config/color-schemes.ts +0 -165
  88. package/templates/base/src/config/docs-schema.ts +0 -95
  89. package/templates/base/src/config/frontmatter-preview-defaults.ts +0 -27
  90. package/templates/base/src/config/frontmatter-preview-renderers.tsx +0 -46
  91. package/templates/base/src/config/i18n.ts +0 -239
  92. package/templates/base/src/config/settings-types.ts +0 -45
  93. package/templates/base/src/config/sidebars.ts +0 -66
  94. package/templates/base/src/config/tag-vocabulary-types.ts +0 -4
  95. package/templates/base/src/config/tag-vocabulary.ts +0 -20
  96. package/templates/base/src/config/z-index-tokens.ts +0 -128
  97. package/templates/base/src/types/docs-entry.ts +0 -28
  98. package/templates/base/src/types/heading.ts +0 -5
  99. package/templates/base/src/types/locale.ts +0 -10
  100. package/templates/base/src/utils/base.ts +0 -164
  101. package/templates/base/src/utils/docs.ts +0 -446
  102. package/templates/base/src/utils/git-info.ts +0 -70
  103. package/templates/base/src/utils/github.ts +0 -22
  104. package/templates/base/src/utils/nav-scope.ts +0 -34
  105. package/templates/base/src/utils/sidebar.ts +0 -36
  106. package/templates/base/src/utils/slug.ts +0 -10
  107. package/templates/base/src/utils/smart-break.tsx +0 -12
  108. package/templates/base/src/utils/tags.ts +0 -83
  109. package/templates/base/zfb-shim.d.ts +0 -178
  110. package/templates/features/bodyFootUtil/files/src/utils/github.ts +0 -22
  111. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/escape-for-mdx.test.ts +0 -42
  112. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +0 -752
  113. package/templates/features/claudeResources/files/src/integrations/claude-resources/escape-for-mdx.ts +0 -97
  114. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +0 -735
  115. package/templates/features/designTokenPanel/files/src/components/design-token-panel-bootstrap.tsx +0 -15
  116. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +0 -435
  117. package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +0 -174
  118. package/templates/features/designTokenPanel/files/src/lib/design-token-panel-bootstrap.ts +0 -30
  119. package/templates/features/docHistory/files/src/components/doc-history.tsx +0 -10
  120. package/templates/features/docHistory/files/src/types/doc-history.ts +0 -7
  121. package/templates/features/dynamicPageTransition/files/src/components/client-router-bootstrap.tsx +0 -72
  122. package/templates/features/i18n/files/pages/[locale]/index.tsx +0 -72
  123. package/templates/features/imageEnlarge/files/src/components/image-enlarge.tsx +0 -11
  124. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +0 -6
  125. package/templates/features/tauri/files/src/components/find-bar.tsx +0 -122
  126. package/templates/features/tauri/files/src/components/find-in-page-init.tsx +0 -59
  127. 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,5 +0,0 @@
1
- export interface Heading {
2
- depth: number;
3
- slug: string;
4
- text: string;
5
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * Represents a locale switcher link.
3
- * Used by both SidebarFooter (Preact) and language-switcher (Preact).
4
- */
5
- export interface LocaleLink {
6
- code: string;
7
- label: string;
8
- href: string;
9
- active: boolean;
10
- }
@@ -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
- }