@takazudo/zudo-doc 0.2.22 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/bin/zudo-doc.mjs +83 -0
  2. package/dist/eject/index.d.ts +33 -0
  3. package/dist/eject/index.js +229 -0
  4. package/dist/plugins/routes.d.ts +2 -0
  5. package/dist/plugins/routes.js +100 -0
  6. package/dist/preset.d.ts +47 -1
  7. package/dist/preset.js +29 -13
  8. package/dist/routes/404.d.ts +7 -0
  9. package/dist/routes/404.js +47 -0
  10. package/dist/routes/_chrome.d.ts +32 -0
  11. package/dist/routes/_chrome.js +365 -0
  12. package/dist/routes/_context.d.ts +56 -0
  13. package/dist/routes/_context.js +222 -0
  14. package/dist/routes/_docs-helpers.d.ts +43 -0
  15. package/dist/routes/_docs-helpers.js +173 -0
  16. package/dist/routes/_virtual.d.js +0 -0
  17. package/dist/routes/api-ai-chat.d.ts +5 -0
  18. package/dist/routes/api-ai-chat.js +24 -0
  19. package/dist/routes/docs-slug.d.ts +21 -0
  20. package/dist/routes/docs-slug.js +26 -0
  21. package/dist/routes/docs-tags-index.d.ts +7 -0
  22. package/dist/routes/docs-tags-index.js +11 -0
  23. package/dist/routes/docs-tags-tag.d.ts +23 -0
  24. package/dist/routes/docs-tags-tag.js +19 -0
  25. package/dist/routes/docs-versions.d.ts +7 -0
  26. package/dist/routes/docs-versions.js +11 -0
  27. package/dist/routes/index.d.ts +7 -0
  28. package/dist/routes/index.js +117 -0
  29. package/dist/routes/locale-docs-slug.d.ts +27 -0
  30. package/dist/routes/locale-docs-slug.js +40 -0
  31. package/dist/routes/locale-docs-tags-index.d.ts +18 -0
  32. package/dist/routes/locale-docs-tags-index.js +15 -0
  33. package/dist/routes/locale-docs-tags-tag.d.ts +25 -0
  34. package/dist/routes/locale-docs-tags-tag.js +22 -0
  35. package/dist/routes/locale-docs-versions.d.ts +18 -0
  36. package/dist/routes/locale-docs-versions.js +15 -0
  37. package/dist/routes/locale-index.d.ts +24 -0
  38. package/dist/routes/locale-index.js +132 -0
  39. package/dist/routes/robots.txt.d.ts +3 -0
  40. package/dist/routes/robots.txt.js +8 -0
  41. package/dist/routes/sitemap.xml.d.ts +5 -0
  42. package/dist/routes/sitemap.xml.js +31 -0
  43. package/dist/routes/v-docs-slug.d.ts +27 -0
  44. package/dist/routes/v-docs-slug.js +34 -0
  45. package/dist/routes/v-locale-docs-slug.d.ts +31 -0
  46. package/dist/routes/v-locale-docs-slug.js +50 -0
  47. package/dist/safelist.css +1 -1
  48. package/dist/settings.d.ts +15 -0
  49. package/eject/breadcrumb/breadcrumb.tsx +194 -0
  50. package/eject/breadcrumb/find-path.ts +26 -0
  51. package/eject/breadcrumb/index.ts +4 -0
  52. package/eject/breadcrumb/types.ts +32 -0
  53. package/eject/code-group/index.tsx +78 -0
  54. package/eject/content-admonition/index.tsx +50 -0
  55. package/eject/details/details.tsx +34 -0
  56. package/eject/details/index.ts +4 -0
  57. package/eject/doc-pager/index.tsx +91 -0
  58. package/eject/footer/footer.tsx +167 -0
  59. package/eject/footer/index.ts +19 -0
  60. package/eject/footer/types.ts +60 -0
  61. package/eject/header/header.tsx +725 -0
  62. package/eject/header/index.ts +36 -0
  63. package/eject/header/nav-active.ts +122 -0
  64. package/eject/header/nav-overflow-script.ts +302 -0
  65. package/eject/header/right-items.ts +78 -0
  66. package/eject/header/types.ts +69 -0
  67. package/eject/page-loading/index.ts +10 -0
  68. package/eject/page-loading/page-loading-overlay.tsx +103 -0
  69. package/eject/sidebar/index.ts +27 -0
  70. package/eject/sidebar/sidebar.tsx +137 -0
  71. package/eject/sidebar/types.ts +73 -0
  72. package/eject/tab-item/index.ts +7 -0
  73. package/eject/tab-item/tab-item.tsx +70 -0
  74. package/eject/theme-toggle/color-scheme-sync.ts +73 -0
  75. package/eject/theme-toggle/index.tsx +120 -0
  76. package/eject/toc/cx.ts +38 -0
  77. package/eject/toc/index.ts +11 -0
  78. package/eject/toc/mobile-toc.tsx +133 -0
  79. package/eject/toc/toc-title.ts +33 -0
  80. package/eject/toc/toc.tsx +119 -0
  81. package/eject/toc/types.ts +14 -0
  82. package/eject/toc/use-active-heading.ts +162 -0
  83. package/package.json +87 -7
  84. package/routes-src/404.tsx +49 -0
  85. package/routes-src/_chrome.tsx +470 -0
  86. package/routes-src/_context.ts +314 -0
  87. package/routes-src/_docs-helpers.ts +284 -0
  88. package/routes-src/_virtual.d.ts +15 -0
  89. package/routes-src/api-ai-chat.tsx +46 -0
  90. package/routes-src/docs-slug.tsx +43 -0
  91. package/routes-src/docs-tags-index.tsx +14 -0
  92. package/routes-src/docs-tags-tag.tsx +29 -0
  93. package/routes-src/docs-versions.tsx +15 -0
  94. package/routes-src/index.tsx +134 -0
  95. package/routes-src/locale-docs-slug.tsx +69 -0
  96. package/routes-src/locale-docs-tags-index.tsx +23 -0
  97. package/routes-src/locale-docs-tags-tag.tsx +39 -0
  98. package/routes-src/locale-docs-versions.tsx +23 -0
  99. package/routes-src/locale-index.tsx +150 -0
  100. package/routes-src/robots.txt.tsx +13 -0
  101. package/routes-src/sitemap.xml.tsx +48 -0
  102. package/routes-src/v-docs-slug.tsx +64 -0
  103. package/routes-src/v-locale-docs-slug.tsx +85 -0
@@ -0,0 +1,314 @@
1
+ // routes/_context — the route-context reconstruction seam (epic Package-First
2
+ // Finale #2356, A1 #2361; ADR docs/adr/route-injection-seam.md, Decision 1).
3
+ //
4
+ // Every package route entrypoint imports `{ routeContext }` from the virtual
5
+ // module `virtual:zudo-doc-route-context` (SERIALIZABLE DATA ONLY: `settings`,
6
+ // `translations`, `tagVocabulary`) and rebuilds the full injected context by
7
+ // calling the ALREADY-EXISTING package factories with importable package
8
+ // subpaths — never raw host functions, never `@/` aliases. This module does
9
+ // that reconstruction ONCE and exposes the wired runtime context so each
10
+ // entrypoint stays a thin `paths()` + default-export shell (mirroring the kept
11
+ // `pages/*.tsx` stubs).
12
+ //
13
+ // The host-dep → package-subpath map this wires (ADR table):
14
+ // buildNavTree/collectAutoIndexNodes/groupSatelliteNodes/isNavVisible/
15
+ // findNode/firstRoutedHref/buildBreadcrumbs ... ./routes/_docs-helpers
16
+ // (the package `docs` helpers, on `@takazudo/zudo-doc/sidebar-tree`)
17
+ // loadCategoryMeta ........................... @takazudo/zudo-doc/sidebar-tree
18
+ // extractHeadings ............................ @takazudo/zudo-doc/extract-headings
19
+ // toRouteSlug/toSlugParams/toTitleCase/toHistorySlug ... @takazudo/zudo-doc/slug
20
+ // resolveTag / collectTags ................... @takazudo/zudo-doc/tag-helpers
21
+ // getNavSectionForSlug/getNavSubtree/getCategoryOrder .. @takazudo/zudo-doc/nav-scope
22
+ // url helpers ................................ @takazudo/zudo-doc/url-helpers
23
+ // mergeLocaleDocs ............................ @takazudo/zudo-doc/locale-merge
24
+ // stableDocs (content bridge) ................ ./routes/_docs-helpers (zfb/content)
25
+ // resolveNavSource/… ......................... @takazudo/zudo-doc/nav-source-docs
26
+ // buildDocRouteEntries ....................... @takazudo/zudo-doc/doc-route-entries
27
+ // route enumeration .......................... @takazudo/zudo-doc/route-enumerators
28
+ // i18n (t/getLocaleLabel/defaultLocale/locales) reconstructed from settings +
29
+ // translations as a FactoryI18n.
30
+
31
+ // Virtual module, resolved by the routes plugin at build via
32
+ // addVirtualModule("virtual:zudo-doc-route-context", …). Not present on disk;
33
+ // the package ships ambient typings for it (routes/_virtual.d.ts).
34
+ import { routeContext } from "virtual:zudo-doc-route-context";
35
+
36
+ import type { Settings } from "@takazudo/zudo-doc/settings";
37
+ import type { FactoryI18n } from "@takazudo/zudo-doc/factory-context";
38
+ import { makeUrlHelpers, type UrlHelpers } from "@takazudo/zudo-doc/url-helpers";
39
+ import {
40
+ loadCategoryMeta,
41
+ type CategoryMeta,
42
+ } from "@takazudo/zudo-doc/sidebar-tree";
43
+ import { extractHeadings as extractHeadingsBase } from "@takazudo/zudo-doc/extract-headings";
44
+ import { toRouteSlug, toSlugParams } from "@takazudo/zudo-doc/slug";
45
+ import { resolveTag, type TagVocabularyEntry } from "@takazudo/zudo-doc/tag-helpers";
46
+ import {
47
+ getCategoryOrder as getCategoryOrderBase,
48
+ getNavSectionForSlug as getNavSectionForSlugBase,
49
+ getNavSubtree as getNavSubtreeBase,
50
+ } from "@takazudo/zudo-doc/nav-scope";
51
+ import { mergeLocaleDocs } from "@takazudo/zudo-doc/locale-merge";
52
+ import {
53
+ createNavSourceDocs,
54
+ type NavSourceDocsAPI,
55
+ } from "@takazudo/zudo-doc/nav-source-docs";
56
+ import {
57
+ createDocRouteEntries,
58
+ type DocRouteEntriesAPI,
59
+ } from "@takazudo/zudo-doc/doc-route-entries";
60
+ import {
61
+ createRouteEnumerators,
62
+ type RouteEnumeratorsAPI,
63
+ type DocsEntryForTags,
64
+ type TagInfoForEnum,
65
+ } from "@takazudo/zudo-doc/route-enumerators";
66
+ import type { DocNavNode, DocPageEntry } from "@takazudo/zudo-doc/doc-page-props";
67
+ import type { HeadingItem } from "@takazudo/zudo-doc/extract-headings";
68
+ import {
69
+ stableDocs,
70
+ isNavVisible,
71
+ buildNavTree,
72
+ buildBreadcrumbs,
73
+ collectAutoIndexNodes,
74
+ groupSatelliteNodes,
75
+ findNode,
76
+ firstRoutedHref,
77
+ } from "./_docs-helpers.js";
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // The serializable route-context payload (re-exported for entrypoints).
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export interface RouteContextPayload {
84
+ settings: Settings;
85
+ translations: Record<string, Record<string, string>>;
86
+ tagVocabulary: readonly TagVocabularyEntry[];
87
+ }
88
+
89
+ /** The serializable route-context (from the virtual module). */
90
+ export const ctx = routeContext as unknown as RouteContextPayload;
91
+ export const settings: Settings = ctx.settings;
92
+ const translations = ctx.translations;
93
+ const tagVocabulary = ctx.tagVocabulary;
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // i18n — reconstruct a FactoryI18n from settings + translations (Decision 1).
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export const defaultLocale: string = settings.defaultLocale;
100
+ export const locales: readonly string[] = [
101
+ defaultLocale,
102
+ ...Object.keys(settings.locales),
103
+ ];
104
+
105
+ export function getLocaleConfig(locale: string): { label: string; dir: string } | undefined {
106
+ return (settings.locales as Record<string, { label: string; dir: string } | undefined>)[locale];
107
+ }
108
+
109
+ export function getLocaleLabel(locale: string): string {
110
+ if (locale === defaultLocale) return defaultLocale.toUpperCase();
111
+ return getLocaleConfig(locale)?.label ?? locale.toUpperCase();
112
+ }
113
+
114
+ export function t(key: string, locale: string = defaultLocale): string {
115
+ return translations[locale]?.[key] ?? translations[defaultLocale]?.[key] ?? key;
116
+ }
117
+
118
+ export const i18n: FactoryI18n = {
119
+ defaultLocale,
120
+ locales,
121
+ getLocaleLabel,
122
+ t,
123
+ };
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // URL helpers (@takazudo/zudo-doc/url-helpers).
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export const urlHelpers: UrlHelpers = makeUrlHelpers(settings, i18n);
130
+ export const {
131
+ withBase,
132
+ stripBase,
133
+ docsUrl,
134
+ versionedDocsUrl,
135
+ navHref,
136
+ isDefaultLocaleOnlyPath,
137
+ absoluteUrl,
138
+ isExternal,
139
+ resolveHref,
140
+ buildLocaleLinks,
141
+ } = urlHelpers;
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // nav-scope (@takazudo/zudo-doc/nav-scope) — parameterized by settings.headerNav.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export function getCategoryOrder(): string[] {
148
+ return getCategoryOrderBase(settings.headerNav);
149
+ }
150
+ export function getNavSectionForSlug(slug: string): string | undefined {
151
+ return getNavSectionForSlugBase(slug, settings.headerNav);
152
+ }
153
+ export function getNavSubtree(tree: DocNavNode[], categoryMatch?: string): DocNavNode[] {
154
+ return getNavSubtreeBase(tree, categoryMatch, settings.headerNav);
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // extract-headings — bound to settings depth window + id strategy.
159
+ // ---------------------------------------------------------------------------
160
+
161
+ export function extractHeadings(body: string): HeadingItem[] {
162
+ return extractHeadingsBase(body, {
163
+ tocMinDepth: settings.tocMinDepth,
164
+ tocMaxDepth: settings.tocMaxDepth,
165
+ strategy: settings.headingIdStrategy,
166
+ });
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // tag-helpers — collectTags reconstructed package-side (the host version lives
171
+ // in src/utils/tags.ts because it depends on the host DocsEntry; here it is
172
+ // parameterized by the virtual-module tagVocabulary + settings.tagGovernance).
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export interface TagInfo {
176
+ tag: string;
177
+ count: number;
178
+ docs: { slug: string; title: string; description?: string }[];
179
+ }
180
+
181
+ function getVocab(): readonly TagVocabularyEntry[] | false {
182
+ return settings.tagVocabulary ? tagVocabulary : false;
183
+ }
184
+
185
+ export function resolveTagBound(raw: string) {
186
+ return resolveTag(raw, getVocab(), settings.tagGovernance);
187
+ }
188
+
189
+ export function collectTags(
190
+ entries: ReadonlyArray<{ id: string; data: { slug?: string; title?: string; description?: string; tags?: string[] } }>,
191
+ slugFn: (id: string, data: { slug?: string }) => string,
192
+ ): Map<string, TagInfo> {
193
+ const tagMap = new Map<string, TagInfo>();
194
+ for (const entry of entries) {
195
+ const rawTags = entry.data.tags ?? [];
196
+ const slug = slugFn(entry.id, entry.data);
197
+ const seen = new Set<string>();
198
+ for (const raw of rawTags) {
199
+ const resolved = resolveTagBound(raw);
200
+ if (resolved.deprecated) continue;
201
+ if (seen.has(resolved.canonical)) continue;
202
+ seen.add(resolved.canonical);
203
+ if (!tagMap.has(resolved.canonical)) {
204
+ tagMap.set(resolved.canonical, { tag: resolved.canonical, count: 0, docs: [] });
205
+ }
206
+ const info = tagMap.get(resolved.canonical)!;
207
+ info.count++;
208
+ info.docs.push({
209
+ slug,
210
+ title: entry.data.title ?? "",
211
+ description: entry.data.description,
212
+ });
213
+ }
214
+ }
215
+ return tagMap;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Nav-source resolver (@takazudo/zudo-doc/nav-source-docs).
220
+ // ---------------------------------------------------------------------------
221
+
222
+ const navSourceDocs: NavSourceDocsAPI = createNavSourceDocs({
223
+ defaultLocale,
224
+ docsDir: settings.docsDir,
225
+ getVersions: () => settings.versions,
226
+ getLocaleConfig,
227
+ loadCategoryMeta: loadCategoryMeta as (dir: string) => Map<string, CategoryMeta>,
228
+ isNavVisible,
229
+ isDefaultLocaleOnlyPath,
230
+ stableDocs,
231
+ });
232
+
233
+ export const {
234
+ resolveNavSource,
235
+ resolveVersionedLocaleSource,
236
+ loadNavSourceDocs,
237
+ } = navSourceDocs;
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // doc-route-entries (@takazudo/zudo-doc/doc-route-entries).
241
+ // ---------------------------------------------------------------------------
242
+
243
+ const docRouteEntries: DocRouteEntriesAPI = createDocRouteEntries({
244
+ buildNavTree: (docs: DocPageEntry[], locale: string, categoryMeta: Map<string, unknown>) =>
245
+ buildNavTree(
246
+ docs,
247
+ locale,
248
+ categoryMeta as Map<string, CategoryMeta>,
249
+ (slug, loc) => docsUrl(slug, loc),
250
+ ),
251
+ buildBreadcrumbs: (tree: DocNavNode[], slug: string, locale: string, urlFor?: (slug: string) => string) =>
252
+ buildBreadcrumbs(
253
+ tree,
254
+ slug,
255
+ locale === defaultLocale ? withBase("/") : withBase(`/${locale}/`),
256
+ urlFor,
257
+ ),
258
+ collectAutoIndexNodes,
259
+ getNavSectionForSlug,
260
+ getNavSubtree,
261
+ toRouteSlug,
262
+ toSlugParams,
263
+ extractHeadings,
264
+ });
265
+
266
+ export const { buildDocRouteEntries } = docRouteEntries;
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // route-enumerators (@takazudo/zudo-doc/route-enumerators).
270
+ // ---------------------------------------------------------------------------
271
+
272
+ const routeEnumerators: RouteEnumeratorsAPI = createRouteEnumerators({
273
+ defaultLocale,
274
+ getLocaleKeys: () => Object.keys(settings.locales),
275
+ getVersions: () => settings.versions,
276
+ getDocTags: () => settings.docTags,
277
+ docsUrl,
278
+ versionedDocsUrl,
279
+ withBase,
280
+ loadDocs: (collectionName: string) => stableDocs(collectionName) as unknown as DocsEntryForTags[],
281
+ isDefaultLocaleOnlyPath,
282
+ collectTags: (docs: DocsEntryForTags[], slugFn): Map<string, TagInfoForEnum> =>
283
+ collectTags(docs as unknown as Array<{ id: string; data: { slug?: string; title?: string; description?: string; tags?: string[] } }>, slugFn) as unknown as Map<string, TagInfoForEnum>,
284
+ toRouteSlug,
285
+ buildNavTree: (docs: DocPageEntry[], locale: string, categoryMeta: Map<string, unknown>) =>
286
+ buildNavTree(docs, locale, categoryMeta as Map<string, CategoryMeta>, (slug, loc) => docsUrl(slug, loc)),
287
+ collectAutoIndexNodes,
288
+ resolveNavSource,
289
+ resolveVersionedLocaleSource,
290
+ mergeLocaleDocs: mergeLocaleDocs as RouteEnumeratorsAPIDeps["mergeLocaleDocs"],
291
+ });
292
+
293
+ // Local alias so the `mergeLocaleDocs` cast above reads clearly.
294
+ type RouteEnumeratorsAPIDeps = Parameters<typeof createRouteEnumerators>[0];
295
+
296
+ export const {
297
+ enumerateDocsRoutes,
298
+ enumerateTagsRoutes,
299
+ enumerateVersionedRoutes,
300
+ enumerateAllRoutes,
301
+ } = routeEnumerators;
302
+
303
+ // Re-export the pure nav-tree helpers entrypoints reach for directly.
304
+ export {
305
+ buildNavTree,
306
+ groupSatelliteNodes,
307
+ findNode,
308
+ firstRoutedHref,
309
+ collectAutoIndexNodes,
310
+ isNavVisible,
311
+ stableDocs,
312
+ toRouteSlug,
313
+ toSlugParams,
314
+ };
@@ -0,0 +1,284 @@
1
+ // routes/_docs-helpers — package-side nav-tree helpers for the package-owned
2
+ // route entrypoints (epic Package-First Finale #2356, A1 #2361).
3
+ //
4
+ // The host's `src/utils/docs.ts` `buildNavTree` / `buildBreadcrumbs` /
5
+ // `collectAutoIndexNodes` / `groupSatelliteNodes` / `findNode` /
6
+ // `firstRoutedHref` are host-only wrappers around the package primitive
7
+ // `buildSidebarTree` (`@takazudo/zudo-doc/sidebar-tree`). The package route
8
+ // entrypoints inject these same helpers into the doc-route / nav-source /
9
+ // nav-wrapper factories, so this module reproduces them WITHOUT the host `@/`
10
+ // alias — the "+ package `docs` helpers" note in the ADR's host-dep table.
11
+ //
12
+ // Faithful to the host derivation (root-index node synthesis, NavNode shape,
13
+ // satellite grouping, breadcrumb walk) but without the host-only LRU /
14
+ // identity caches: build-time array-identity stability already comes from the
15
+ // snapshot-anchored `stableDocs` (below) + `memoizeDerived` in the factories.
16
+ //
17
+ // `stableDocs` (the content bridge — Decision 5) imports `@takazudo/zfb/content`
18
+ // directly (NOT the host `zfb/content` tsconfig alias — Decision 2), anchoring
19
+ // the bridged + draft-filtered array on the snapshot so repeat callers within a
20
+ // build get the SAME instance and the nav-tree / doc-route memos short-circuit.
21
+
22
+ import {
23
+ getCollection,
24
+ getContentSnapshot,
25
+ type CollectionEntry,
26
+ } from "@takazudo/zfb/content";
27
+ import {
28
+ buildSidebarTree,
29
+ type CategoryMeta,
30
+ type SidebarNode,
31
+ } from "@takazudo/zudo-doc/sidebar-tree";
32
+ import { toRouteSlug } from "@takazudo/zudo-doc/slug";
33
+ import type { DocNavNode, DocPageEntry, DocPageFrontmatter } from "@takazudo/zudo-doc/doc-page-props";
34
+
35
+ export type { CategoryMeta, DocNavNode, DocPageEntry };
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Content bridge — snapshot-anchored stable docs (Decision 5)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** The stable per-build anchor array for a collection — the raw readonly
42
+ * snapshot array zfb installs once. `undefined` on the fs-fallback path. */
43
+ function snapshotAnchor(name: string): readonly unknown[] | undefined {
44
+ return getContentSnapshot()?.collections[name];
45
+ }
46
+
47
+ const bridgedByAnchor = new WeakMap<object, DocPageEntry[]>();
48
+
49
+ /** Bridge a raw zfb collection entry onto the `DocPageEntry` shape the factories
50
+ * expect (Astro-compat `id`/`collection`, with `index` suffix stripped via the
51
+ * canonical root-slug rule). */
52
+ function bridgeEntry(
53
+ e: CollectionEntry<DocPageFrontmatter>,
54
+ collectionName: string,
55
+ ): DocPageEntry {
56
+ return {
57
+ slug: e.slug,
58
+ id: toRouteSlug(e.slug),
59
+ collection: collectionName,
60
+ data: e.data,
61
+ body: e.body,
62
+ module_specifier: e.module_specifier,
63
+ Content: e.Content,
64
+ };
65
+ }
66
+
67
+ function buildBridged(collectionName: string): DocPageEntry[] {
68
+ const raw = getCollection<DocPageFrontmatter>(collectionName);
69
+ return raw.map((e) => bridgeEntry(e, collectionName)).filter((d) => !d.data.draft);
70
+ }
71
+
72
+ /**
73
+ * Identity-stable, draft-filtered `DocPageEntry[]` for a collection. Returns the
74
+ * SAME array instance on every call within one build (anchored on the snapshot
75
+ * array); the no-snapshot (fs-fallback / unit-test) path computes fresh and is
76
+ * deliberately unmemoized. Passed as `stableDocs` to `createNavSourceDocs`.
77
+ */
78
+ export function stableDocs(collectionName: string): DocPageEntry[] {
79
+ const anchor = snapshotAnchor(collectionName);
80
+ if (anchor === undefined) return buildBridged(collectionName);
81
+ const cached = bridgedByAnchor.get(anchor);
82
+ if (cached) return cached;
83
+ const built = buildBridged(collectionName);
84
+ bridgedByAnchor.set(anchor, built);
85
+ return built;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Nav-tree helpers — ported from src/utils/docs.ts onto buildSidebarTree
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /** Filter predicate: true when a doc should appear in navigation. */
93
+ export function isNavVisible(doc: DocPageEntry): boolean {
94
+ return !doc.data.unlisted && !doc.data.standalone;
95
+ }
96
+
97
+ /** A docs-href builder: `(slug, locale) => url`. */
98
+ export type BuildHref = (slug: string, locale: string) => string;
99
+
100
+ export interface BuildNavTreeOptions {
101
+ buildHref?: BuildHref;
102
+ }
103
+
104
+ function toNavNode(node: SidebarNode): DocNavNode {
105
+ return {
106
+ slug: node.id,
107
+ label: node.label,
108
+ description: node.description,
109
+ position: node.sidebar_position ?? 999,
110
+ href: node.href,
111
+ hasPage: node.hasPage,
112
+ children: node.children.map(toNavNode),
113
+ sortOrder: node.sortOrder ?? "asc",
114
+ };
115
+ }
116
+
117
+ /** Last entry whose package-derived slug is empty ("") — the entry the shared
118
+ * builder skips. Last one wins (mirrors the host builder's overwrite). */
119
+ function findRootIndexDoc(docs: DocPageEntry[]): DocPageEntry | undefined {
120
+ let found: DocPageEntry | undefined;
121
+ for (const d of docs) {
122
+ const slug = d.data.slug ?? d.id.replace(/\/index$/, "");
123
+ if (slug === "") found = d;
124
+ }
125
+ return found;
126
+ }
127
+
128
+ function toRootNavNode(
129
+ doc: DocPageEntry,
130
+ locale: string,
131
+ buildHref: BuildHref,
132
+ categoryMeta?: Map<string, CategoryMeta>,
133
+ ): DocNavNode {
134
+ const meta = categoryMeta?.get("");
135
+ const noPage = doc.data.category_no_page ?? meta?.noPage;
136
+ const sortOrder =
137
+ (doc.data.category_sort_order as "asc" | "desc" | undefined) ?? meta?.sortOrder ?? "asc";
138
+ return {
139
+ slug: "",
140
+ label:
141
+ (doc.data.sidebar_label as string | undefined) ??
142
+ doc.data.title ??
143
+ meta?.label ??
144
+ "",
145
+ description: doc.data.description ?? meta?.description,
146
+ position: (doc.data.sidebar_position as number | undefined) ?? meta?.position ?? 999,
147
+ href: noPage ? undefined : buildHref("", locale),
148
+ hasPage: noPage !== true,
149
+ children: [],
150
+ sortOrder,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Build a recursive navigation tree from a flat doc collection. Delegates tree
156
+ * construction to the shared `buildSidebarTree`; keeps the host-side concerns
157
+ * (DocNavNode shape, root-index node synthesis). `buildHref` parameterizes the
158
+ * href space (default: the injected `docsUrl`). Unlike the host copy this drops
159
+ * the LRU / identity caches — the snapshot-anchored `stableDocs` already makes
160
+ * the input arrays identity-stable so the factory-level `memoizeDerived` short-
161
+ * circuits per build.
162
+ */
163
+ export function buildNavTree(
164
+ docs: DocPageEntry[],
165
+ locale: string,
166
+ categoryMeta: Map<string, CategoryMeta> | undefined,
167
+ buildHref: BuildHref,
168
+ options?: BuildNavTreeOptions,
169
+ ): DocNavNode[] {
170
+ const href: BuildHref = options?.buildHref ?? buildHref;
171
+ const sidebarTree = buildSidebarTree(
172
+ docs.map((d) => ({ id: d.id, data: d.data })),
173
+ locale,
174
+ {
175
+ categoryMeta,
176
+ buildHref: (slug, loc) => href(slug, loc),
177
+ // Host call sites own visibility; disable the builder's default filter so
178
+ // breadcrumb (unfiltered) and nav (pre-filtered) paths are unchanged.
179
+ isNavVisible: () => true,
180
+ },
181
+ );
182
+ const result = sidebarTree.map(toNavNode);
183
+
184
+ const rootDoc = findRootIndexDoc(docs);
185
+ if (rootDoc) {
186
+ result.push(toRootNavNode(rootDoc, locale, href, categoryMeta));
187
+ result.sort((a, b) => {
188
+ const posCompare = a.position - b.position;
189
+ if (posCompare !== 0) return posCompare;
190
+ return a.slug.localeCompare(b.slug);
191
+ });
192
+ }
193
+ return result;
194
+ }
195
+
196
+ /** Group "satellite" nodes (slug-prefix siblings) under their primary node. */
197
+ export function groupSatelliteNodes(tree: DocNavNode[], prefixes: string[]): DocNavNode[] {
198
+ const result = [...tree];
199
+ for (const prefix of prefixes) {
200
+ const primaryIdx = result.findIndex((n) => n.slug === prefix);
201
+ if (primaryIdx < 0) continue;
202
+ const primary = result[primaryIdx];
203
+ if (!primary) continue;
204
+ const satelliteIdxs: number[] = [];
205
+ for (let i = 0; i < result.length; i++) {
206
+ const node = result[i];
207
+ if (node && i !== primaryIdx && node.slug.startsWith(`${prefix}-`)) {
208
+ satelliteIdxs.push(i);
209
+ }
210
+ }
211
+ if (satelliteIdxs.length === 0) continue;
212
+ const extraChildren: DocNavNode[] = [];
213
+ for (const idx of satelliteIdxs) {
214
+ const node = result[idx];
215
+ if (node) extraChildren.push(node);
216
+ }
217
+ result[primaryIdx] = { ...primary, children: [...primary.children, ...extraChildren] };
218
+ for (const idx of satelliteIdxs.reverse()) result.splice(idx, 1);
219
+ }
220
+ return result;
221
+ }
222
+
223
+ /** Find a node by slug anywhere in the tree. */
224
+ export function findNode(nodes: DocNavNode[], slug: string): DocNavNode | undefined {
225
+ for (const node of nodes) {
226
+ if (node.slug === slug) return node;
227
+ const found = findNode(node.children, slug);
228
+ if (found) return found;
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ /** Href of the first routed descendant, walking children depth-first. */
234
+ export function firstRoutedHref(node: DocNavNode): string | undefined {
235
+ for (const child of node.children) {
236
+ if (child.hasPage && child.href) return child.href;
237
+ const nested = firstRoutedHref(child);
238
+ if (nested) return nested;
239
+ }
240
+ return undefined;
241
+ }
242
+
243
+ /** Collect all category nodes that have children but no page (no index.mdx). */
244
+ export function collectAutoIndexNodes(nodes: DocNavNode[]): DocNavNode[] {
245
+ const result: DocNavNode[] = [];
246
+ for (const node of nodes) {
247
+ if (!node.hasPage && node.children.length > 0 && node.href) result.push(node);
248
+ result.push(...collectAutoIndexNodes(node.children));
249
+ }
250
+ return result;
251
+ }
252
+
253
+ export interface BreadcrumbItem {
254
+ label: string;
255
+ href?: string;
256
+ }
257
+
258
+ /** Build breadcrumb trail by walking the nav tree. `homeHref` is the locale
259
+ * docs root (injected). `hrefFor` optionally remaps intermediate crumbs into
260
+ * a versioned URL space. */
261
+ export function buildBreadcrumbs(
262
+ tree: DocNavNode[],
263
+ slug: string,
264
+ homeHref: string,
265
+ hrefFor?: (slug: string) => string,
266
+ ): BreadcrumbItem[] {
267
+ const parts = slug.split("/");
268
+ const crumbs: BreadcrumbItem[] = [{ label: "", href: homeHref }];
269
+ let nodes = tree;
270
+ for (let i = 0; i < parts.length; i++) {
271
+ const partialSlug = parts.slice(0, i + 1).join("/");
272
+ const node = nodes.find((n) => n.slug === partialSlug);
273
+ if (!node) break;
274
+ const isLast = i === parts.length - 1;
275
+ const href = isLast
276
+ ? undefined
277
+ : hrefFor && node.href !== undefined
278
+ ? hrefFor(node.slug)
279
+ : node.href;
280
+ crumbs.push({ label: node.label, href });
281
+ nodes = node.children;
282
+ }
283
+ return crumbs;
284
+ }
@@ -0,0 +1,15 @@
1
+ // Ambient typing for the route-context virtual module emitted by the routes
2
+ // plugin (`addVirtualModule("virtual:zudo-doc-route-context", …)`). The module
3
+ // has no on-disk source — it is materialised at build by the zfb plugin runtime
4
+ // — so this declaration gives the package route entrypoints a typed import.
5
+ //
6
+ // The payload is SERIALIZABLE DATA ONLY (ADR Decision 1): `settings`,
7
+ // `translations`, `tagVocabulary`. `_context.ts` narrows it to the concrete
8
+ // `RouteContextPayload` at the seam.
9
+ declare module "virtual:zudo-doc-route-context" {
10
+ export const routeContext: {
11
+ settings: unknown;
12
+ translations: Record<string, Record<string, string>>;
13
+ tagVocabulary: ReadonlyArray<Record<string, unknown>>;
14
+ };
15
+ }
@@ -0,0 +1,46 @@
1
+ // Package route entrypoint: /api/ai-chat — package-owned equivalent of
2
+ // pages/api/ai-chat.tsx (A1 #2361). SSR route (prerender = false), injected
3
+ // with `opts: { prerender: false }` by the routes plugin (Decision 3).
4
+ //
5
+ // SCOPE NOTE (A1): the FULL ai-chat server handler (CORS allowlist, prompt-
6
+ // injection screening, KV rate limiting, audit logging, the Anthropic client)
7
+ // lives host-side in `pages/api/_ai-chat-*` and is NOT yet package-shipped — it
8
+ // depends on host-only modules and CF env bindings that the serializable
9
+ // route-context virtual module does not carry. This package entrypoint proves
10
+ // the SSR-route seam (prerender shape + demo-mode short-circuit driven from the
11
+ // virtual-module `settings`) and returns a safe disabled-by-default response;
12
+ // re-homing the full handler into the package is deferred to the render-proof
13
+ // follow-up (A2 #2363 / a later sub-issue). With `packageOwnedRoutes` off this
14
+ // is never evaluated, and even on it is dropped if the user keeps their
15
+ // `pages/api/ai-chat.tsx` stub (Decision 6).
16
+
17
+ import { settings } from "./_context.js";
18
+
19
+ // `frontmatter` is required by zfb's TSX page contract; without it zfb defaults
20
+ // the route to SSG and the `prerender = false` below is ignored.
21
+ export const frontmatter = { title: "AI Chat API" };
22
+
23
+ export const prerender = false;
24
+
25
+ const DISABLED_MESSAGE =
26
+ "This feature is disabled on this demo. Need per project setup to enable this.";
27
+
28
+ function jsonResponse(body: Record<string, unknown>, status: number): Response {
29
+ return new Response(JSON.stringify(body), {
30
+ status,
31
+ headers: { "Content-Type": "application/json" },
32
+ });
33
+ }
34
+
35
+ export default function AiChatHandler(): Response {
36
+ // Package default: no real Anthropic-backed handler is wired here (see the
37
+ // scope note). Reply with the stable disabled message regardless of method —
38
+ // the host route (kept as a `pages/` stub) owns the full implementation.
39
+ if (settings.aiChatDemoMode === false && settings.aiAssistant) {
40
+ return jsonResponse(
41
+ { error: "ai-chat handler not provided by the package route (see A1 #2361)" },
42
+ 501,
43
+ );
44
+ }
45
+ return jsonResponse({ response: DISABLED_MESSAGE }, 200);
46
+ }