create-zudo-doc 0.2.0 → 0.2.1

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 (72) hide show
  1. package/dist/api.js +4 -1
  2. package/dist/cli.js +4 -6
  3. package/dist/preset.js +11 -0
  4. package/dist/prompts.js +2 -6
  5. package/dist/scaffold.js +15 -9
  6. package/dist/settings-gen.js +7 -7
  7. package/dist/utils.d.ts +8 -0
  8. package/dist/utils.js +25 -0
  9. package/dist/zfb-config-gen.js +11 -50
  10. package/package.json +1 -1
  11. package/templates/base/pages/_data.ts +10 -23
  12. package/templates/base/pages/docs/[[...slug]].tsx +27 -168
  13. package/templates/base/pages/lib/_doc-content-header.tsx +24 -4
  14. package/templates/base/pages/lib/_doc-history-area.tsx +21 -5
  15. package/templates/base/pages/lib/_doc-metainfo-area.tsx +22 -2
  16. package/templates/base/pages/lib/_doc-page-renderer.tsx +192 -0
  17. package/templates/base/pages/lib/_doc-page-shell.tsx +3 -2
  18. package/templates/base/pages/lib/_doc-route-entries.ts +188 -0
  19. package/templates/base/pages/lib/_doc-tags-area.tsx +7 -2
  20. package/templates/base/pages/lib/_footer-with-defaults.tsx +38 -27
  21. package/templates/base/pages/lib/_head-with-defaults.tsx +7 -10
  22. package/templates/base/pages/lib/_header-with-defaults.tsx +51 -89
  23. package/templates/base/pages/lib/_inline-version-switcher.tsx +5 -4
  24. package/templates/base/pages/lib/_nav-data-prep.ts +137 -0
  25. package/templates/base/pages/lib/_nav-source-docs.ts +10 -6
  26. package/templates/base/pages/lib/_search-widget-script.ts +32 -9
  27. package/templates/base/pages/lib/_sidebar-with-defaults.tsx +15 -60
  28. package/templates/base/pages/lib/locale-merge.ts +1 -1
  29. package/templates/base/pages/lib/route-enumerators.ts +11 -7
  30. package/templates/base/plugins/connect-adapter.mjs +30 -1
  31. package/templates/base/plugins/copy-public-plugin.mjs +10 -2
  32. package/templates/base/plugins/search-index-plugin.mjs +20 -8
  33. package/templates/base/src/components/sidebar-toggle.tsx +1 -1
  34. package/templates/base/src/components/sidebar-tree.tsx +10 -4
  35. package/templates/base/src/config/color-schemes.ts +4 -0
  36. package/templates/base/src/config/docs-schema.ts +94 -0
  37. package/templates/base/src/config/i18n.ts +10 -3
  38. package/templates/base/src/styles/global.css +14 -0
  39. package/templates/base/src/types/docs-entry.ts +8 -26
  40. package/templates/base/src/utils/base.ts +5 -3
  41. package/templates/base/src/utils/docs.ts +144 -169
  42. package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +20 -110
  43. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +62 -38
  44. package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +34 -8
  45. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +27 -45
  46. package/templates/features/docHistory/files/src/components/doc-history.tsx +28 -8
  47. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +6 -74
  48. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +6 -77
  49. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +7 -69
  50. package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -76
  51. package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +201 -0
  52. package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +41 -179
  53. package/templates/features/i18n/files/pages/[locale]/index.tsx +5 -5
  54. package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +33 -21
  55. package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +1 -1
  56. package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +5 -59
  57. package/templates/features/versioning/files/pages/docs/versions.tsx +8 -66
  58. package/templates/features/versioning/files/pages/lib/_versions-page.tsx +79 -0
  59. package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +46 -191
  60. package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +31 -173
  61. package/templates/base/src/components/content/heading-h3.tsx +0 -20
  62. package/templates/base/src/components/theme-toggle.tsx +0 -107
  63. package/templates/base/src/hooks/use-active-heading.ts +0 -133
  64. package/templates/base/src/plugins/docs-source-map.ts +0 -103
  65. package/templates/base/src/plugins/hast-utils.ts +0 -10
  66. package/templates/base/src/plugins/rehype-code-title.ts +0 -50
  67. package/templates/base/src/plugins/rehype-heading-links.ts +0 -53
  68. package/templates/base/src/plugins/rehype-mermaid.ts +0 -41
  69. package/templates/base/src/plugins/url-utils.ts +0 -4
  70. package/templates/base/src/utils/dedent.ts +0 -24
  71. package/templates/features/docHistory/files/src/utils/doc-history.ts +0 -180
  72. package/templates/features/sidebarResizer/files/src/scripts/sidebar-resizer.ts +0 -198
@@ -35,14 +35,13 @@ import type { JSX } from "preact";
35
35
  // reaches the rendered tree).
36
36
  import { Island } from "@takazudo/zfb";
37
37
  import SidebarTree from "@/components/sidebar-tree";
38
- import { settings } from "@/config/settings";
39
38
  import { defaultLocale, locales, t, type Locale } from "@/config/i18n";
40
- import { buildLocaleLinks, navHref, versionedDocsUrl } from "@/utils/base";
41
39
  import {
42
- type NavNode,
43
- } from "@/utils/docs";
44
- import { buildSidebarForSection } from "@/utils/sidebar";
45
- import { loadNavSourceDocs } from "./_nav-source-docs";
40
+ buildRootMenuItems,
41
+ buildLocaleLinksForNav,
42
+ buildSidebarNodes,
43
+ getThemeDefaultMode,
44
+ } from "./_nav-data-prep";
46
45
 
47
46
  export interface SidebarWithDefaultsProps {
48
47
  /** Slug of the active doc page, used to highlight the current entry. */
@@ -61,34 +60,6 @@ export interface SidebarWithDefaultsProps {
61
60
  currentPath?: string;
62
61
  }
63
62
 
64
- /**
65
- * Walk the nav tree and rewrite each node's `href` to its versioned form.
66
- *
67
- * `buildNavTree` always emits hrefs via `docsUrl()`; when the active route
68
- * lives under `/v/{version}/...` we need the same nodes pointing at the
69
- * versioned URL so internal nav clicks stay inside the version. Skips
70
- * nodes without an href (link-only or category placeholders).
71
- */
72
- function remapVersionedHrefs(
73
- nodes: NavNode[],
74
- version: string,
75
- nodeLang: Locale,
76
- ): NavNode[] {
77
- return nodes.map((node) => {
78
- const children =
79
- node.children.length > 0
80
- ? remapVersionedHrefs(node.children, version, nodeLang)
81
- : node.children;
82
-
83
- if (!node.href || node.slug.startsWith("__link__")) {
84
- return children !== node.children ? { ...node, children } : node;
85
- }
86
-
87
- const newHref = versionedDocsUrl(node.slug, version, nodeLang);
88
- return { ...node, href: newHref, children };
89
- });
90
- }
91
-
92
63
  /**
93
64
  * Default-bearing host wrapper that performs sidebar data prep, then wraps
94
65
  * the project's `<SidebarTree>`
@@ -117,35 +88,21 @@ export function SidebarWithDefaults(
117
88
  currentPath = "",
118
89
  } = props;
119
90
 
120
- // Root-menu items derived from headerNav (mobile back-to-menu list).
121
- // The Astro template fed labelKey through `t(...)` and computed hrefs
122
- // with `navHref()`; mirror that exactly so the rendered list stays
123
- // identical between the A and B sites.
124
- const rootMenuItems = settings.headerNav.map((item) => ({
125
- label: item.labelKey
126
- ? t(item.labelKey as Parameters<typeof t>[0], lang)
127
- : item.label,
128
- href: navHref(item.path, lang, currentVersion),
129
- children: item.children?.map((child) => ({
130
- label: child.labelKey
131
- ? t(child.labelKey as Parameters<typeof t>[0], lang)
132
- : child.label,
133
- href: navHref(child.path, lang, currentVersion),
134
- })),
135
- }));
91
+ // Root-menu items, sidebar nodes, locale links, and theme mode — all
92
+ // delegated to the shared _nav-data-prep helpers so header and sidebar
93
+ // wrappers stay in sync without duplicating the logic.
94
+ const rootMenuItems = buildRootMenuItems(lang, currentVersion);
136
95
 
137
96
  const backToMenuLabel = navSection ? t("nav.backToMenu", lang) : undefined;
138
97
 
139
- const { navDocs, categoryMeta } = loadNavSourceDocs(lang, currentVersion);
140
- const rawNodes = buildSidebarForSection(navDocs, lang, navSection, categoryMeta);
141
- const nodes = currentVersion
142
- ? remapVersionedHrefs(rawNodes, currentVersion, lang)
143
- : rawNodes;
98
+ // emptyWhenUnsectioned=false: the desktop sidebar falls back to the FULL
99
+ // tree for pages whose slug matches no headerNav categoryMatch (legacy
100
+ // behavior) only the header's mobile drawer collapses to root menu.
101
+ const nodes = buildSidebarNodes(lang, navSection, currentVersion, false);
144
102
 
145
103
  // Locale-switcher links are only meaningful when more than one locale is
146
104
  // configured — matches the Astro template's guard.
147
- const localeLinks =
148
- locales.length > 1 ? buildLocaleLinks(currentPath, lang) : undefined;
105
+ const localeLinks = buildLocaleLinksForNav(currentPath, lang, locales.length);
149
106
 
150
107
  // Wrap <SidebarTree> directly in <Island when="load">. SSR emits the
151
108
  // `data-zfb-island="SidebarTree"` marker around the rendered tree, with
@@ -164,9 +121,7 @@ export function SidebarWithDefaults(
164
121
  rootMenuItems={rootMenuItems}
165
122
  backToMenuLabel={backToMenuLabel}
166
123
  localeLinks={localeLinks}
167
- themeDefaultMode={
168
- settings.colorMode ? settings.colorMode.defaultMode : undefined
169
- }
124
+ themeDefaultMode={getThemeDefaultMode()}
170
125
  />
171
126
  ),
172
127
  }) as unknown as JSX.Element;
@@ -94,7 +94,7 @@ export interface MergeLocaleDocsResult<T extends DocsEntry = DocsEntry> {
94
94
  * Useful for callers that need to determine whether a page is a fallback
95
95
  * (i.e. `isFallback = !localeSlugSet.has(slug)`).
96
96
  */
97
- localeSlugSet: Set<string>;
97
+ localeSlugSet: ReadonlySet<string>;
98
98
  }
99
99
 
100
100
  /**
@@ -71,10 +71,10 @@ export function enumerateDocsRoutes(locale: string): string[] {
71
71
  // safety re-application of the same rule (idempotent on the already-
72
72
  // stripped id); kept so this enumerator's slug derivation reads as a
73
73
  // single explicit call to the canonical helper.
74
- urls.push(docsUrl(doc.data.slug ?? toRouteSlug(doc.id), locale as string));
74
+ urls.push(docsUrl(doc.data.slug ?? toRouteSlug(doc.id), locale as Locale));
75
75
  }
76
76
  for (const node of collectAutoIndexNodes(tree)) {
77
- urls.push(docsUrl(node.slug, locale as string));
77
+ urls.push(docsUrl(node.slug, locale as Locale));
78
78
  }
79
79
 
80
80
  return [...new Set(urls)];
@@ -127,10 +127,14 @@ export function enumerateTagsRoutes(locale: string): string[] {
127
127
  const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
128
128
 
129
129
  for (const tag of tagMap.keys()) {
130
+ // Tag segment URL-encoded — these URLs feed the sitemap, which must
131
+ // carry well-formed encoded URLs (e.g. "type:guide" → "type%3Aguide").
132
+ // Route params (the page paths() functions) stay raw.
133
+ const encoded = encodeURIComponent(tag);
130
134
  const tagPath =
131
135
  locale === defaultLocale
132
- ? `/docs/tags/${tag}`
133
- : `/${locale}/docs/tags/${tag}`;
136
+ ? `/docs/tags/${encoded}`
137
+ : `/${locale}/docs/tags/${encoded}`;
134
138
  urls.push(withBase(tagPath));
135
139
  }
136
140
 
@@ -194,10 +198,10 @@ export function enumerateVersionedRoutes(
194
198
  // category_no_page index.mdx → no route, no sitemap URL (see paths()).
195
199
  if (doc.data.category_no_page === true) continue;
196
200
  const slug = doc.data.slug ?? toRouteSlug(doc.id);
197
- urls.push(versionedDocsUrl(slug, version.slug, locale as string));
201
+ urls.push(versionedDocsUrl(slug, version.slug, locale as Locale));
198
202
  }
199
203
  for (const node of collectAutoIndexNodes(tree)) {
200
- urls.push(versionedDocsUrl(node.slug, version.slug, locale as string));
204
+ urls.push(versionedDocsUrl(node.slug, version.slug, locale as Locale));
201
205
  }
202
206
  }
203
207
 
@@ -222,7 +226,7 @@ export function enumerateVersionedRoutes(
222
226
  * slash). The sitemap renderer prefixes each with settings.siteUrl.
223
227
  */
224
228
  export function enumerateAllRoutes(): Map<string, string> {
225
- const today = new Date().toISOString().split("T")[0];
229
+ const today = new Date().toISOString().split("T")[0] ?? "";
226
230
  const routes = new Map<string, string>();
227
231
 
228
232
  function add(url: string): void {
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // Adapter from Connect-style middleware (`(req, res, next) => void`) to
2
3
  // the request-response shape zfb's `devMiddleware` lifecycle hook
3
4
  // expects (`(req: ZfbDevMiddlewareRequest) => Promise<ZfbDevMiddlewareResponse | undefined>`).
@@ -16,8 +17,19 @@
16
17
  // the v2 integration package keeps its Connect-style API surface so
17
18
  // non-zfb embedders (Astro, plain Vite, a unit test) continue to work.
18
19
 
20
+ /** @import { ZfbDevMiddlewareRequest, ZfbDevMiddlewareResponse } from "@takazudo/zfb/plugins" */
21
+
19
22
  import { Buffer } from "node:buffer";
20
23
 
24
+ /**
25
+ * Connect-style middleware: `(req, res, next) => void`.
26
+ * The req/res parameters are typed as `any` here because this adapter
27
+ * intentionally passes a plain-object shim that structurally satisfies the
28
+ * subset of `IncomingMessage` / `ServerResponse` that the v2 integration
29
+ * middlewares actually access — not the full Node.js types.
30
+ * @typedef {(req: any, res: any, next: (err?: unknown) => void) => void} ConnectMiddleware
31
+ */
32
+
21
33
  /**
22
34
  * Convert a Connect-style middleware to a zfb devMiddleware handler.
23
35
  * The returned async function takes a `ZfbDevMiddlewareRequest` and
@@ -37,6 +49,9 @@ import { Buffer } from "node:buffer";
37
49
  * - Binary bodies (Buffer / Uint8Array) → encoded as base64 and
38
50
  * flagged `bodyEncoding: "base64"` so the JSON envelope round-trip
39
51
  * stays loss-less.
52
+ *
53
+ * @param {ConnectMiddleware} middleware
54
+ * @returns {(zfbReq: ZfbDevMiddlewareRequest) => Promise<ZfbDevMiddlewareResponse | undefined>}
40
55
  */
41
56
  export function connectToZfbHandler(middleware) {
42
57
  return (zfbReq) => {
@@ -57,9 +72,14 @@ export function connectToZfbHandler(middleware) {
57
72
  // today (`statusCode`, `setHeader`, `getHeader`, `end`) — extend
58
73
  // here if a future middleware needs more.
59
74
  let statusCode = 200;
75
+ /** @type {Record<string, string>} */
60
76
  const headers = {};
61
77
  let settled = false;
62
78
 
79
+ /**
80
+ * @param {string | Buffer | Uint8Array | null | undefined} body
81
+ * @returns {void}
82
+ */
63
83
  const finish = (body) => {
64
84
  if (settled) return;
65
85
  settled = true;
@@ -67,6 +87,7 @@ export function connectToZfbHandler(middleware) {
67
87
  // axum's expectation (`Record<string, string>` of arbitrary
68
88
  // case). Last-wins on collision; with `setHeader` callers this
69
89
  // shouldn't happen.
90
+ /** @type {Record<string, string>} */
70
91
  const normalisedHeaders = {};
71
92
  for (const [k, v] of Object.entries(headers)) {
72
93
  normalisedHeaders[k.toLowerCase()] = String(v);
@@ -93,12 +114,18 @@ export function connectToZfbHandler(middleware) {
93
114
  get statusCode() {
94
115
  return statusCode;
95
116
  },
117
+ /** @param {number} v */
96
118
  set statusCode(v) {
97
119
  statusCode = v;
98
120
  },
121
+ /**
122
+ * @param {string} name
123
+ * @param {string | number | readonly string[]} value
124
+ */
99
125
  setHeader(name, value) {
100
- headers[name] = value;
126
+ headers[name] = String(value);
101
127
  },
128
+ /** @param {string} name */
102
129
  getHeader(name) {
103
130
  // Header lookup is case-insensitive in Node's real
104
131
  // ServerResponse — mirror that so middlewares that probe an
@@ -113,11 +140,13 @@ export function connectToZfbHandler(middleware) {
113
140
  get headersSent() {
114
141
  return settled;
115
142
  },
143
+ /** @param {string | Buffer | Uint8Array | null | undefined} [body] */
116
144
  end(body) {
117
145
  finish(body);
118
146
  },
119
147
  };
120
148
 
149
+ /** @param {unknown} [err] */
121
150
  const next = (err) => {
122
151
  if (settled) return;
123
152
  if (err) {
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // zfb plugin module: copy-public.
2
3
  //
3
4
  // Workaround for upstream zfb gap — `zfb build` does not copy `public/`
@@ -21,15 +22,22 @@
21
22
  // `zfb.config.ts`. The `base` option is intentionally unused — see
22
23
  // rationale above.
23
24
 
25
+ /** @import { ZfbBuildHookContext, ZfbPlugin } from "@takazudo/zfb/plugins" */
26
+
24
27
  import { cp } from "node:fs/promises";
25
28
  import { resolve } from "node:path";
26
29
 
30
+ /** @type {ZfbPlugin} */
27
31
  export default {
28
32
  name: "copy-public",
29
33
 
34
+ /** @param {ZfbBuildHookContext} ctx */
30
35
  async postBuild(ctx) {
31
36
  const { publicDir: publicDirOption } = ctx.options;
32
- const publicDir = resolve(ctx.projectRoot, publicDirOption ?? "public");
37
+ const publicDir = resolve(
38
+ ctx.projectRoot,
39
+ typeof publicDirOption === "string" ? publicDirOption : "public",
40
+ );
33
41
  const dest = ctx.outDir;
34
42
 
35
43
  ctx.logger.info(`copying ${publicDir} → ${dest}`);
@@ -38,7 +46,7 @@ export default {
38
46
  recursive: true,
39
47
  force: true,
40
48
  errorOnExist: false,
41
- }).catch((err) => {
49
+ }).catch((/** @type {NodeJS.ErrnoException} */ err) => {
42
50
  if (err.code === "ENOENT") {
43
51
  // publicDir does not exist or is empty — treat as no-op.
44
52
  ctx.logger.info("public/ not found — skipping copy");
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  // zfb plugin module: search-index.
2
3
  //
3
4
  // Wires two lifecycle hooks for the search-index integration:
@@ -16,25 +17,30 @@
16
17
  // Inline functions are not supported by zfb's plugin runtime; see the
17
18
  // sibling `doc-history-plugin.mjs` for the rationale.
18
19
 
20
+ /** @import { ZfbBuildHookContext, ZfbDevMiddlewareContext, ZfbPlugin } from "@takazudo/zfb/plugins" */
21
+ /** @import { SearchIndexBuildOptions, SearchIndexConfig } from "@takazudo/zudo-doc/integrations/search-index" */
22
+
19
23
  import { emitSearchIndex, createSearchIndexDevMiddleware } from "@takazudo/zudo-doc/integrations/search-index";
20
24
  import { connectToZfbHandler } from "./connect-adapter.mjs";
21
25
 
26
+ /** @type {ZfbPlugin} */
22
27
  export default {
23
28
  name: "search-index",
24
29
 
30
+ /** @param {ZfbBuildHookContext} ctx */
25
31
  postBuild(ctx) {
26
- const { docsDir, locales, base } = ctx.options;
27
- emitSearchIndex({
32
+ emitSearchIndex(/** @type {SearchIndexBuildOptions} */ (/** @type {unknown} */ ({
33
+ ...ctx.options,
28
34
  outDir: ctx.outDir,
29
- docsDir,
30
- locales,
31
- base,
32
35
  logger: ctx.logger,
33
- });
36
+ })));
34
37
  },
35
38
 
39
+ /** @param {ZfbDevMiddlewareContext} ctx */
36
40
  devMiddleware(ctx) {
37
- const middleware = createSearchIndexDevMiddleware(ctx.options);
41
+ const middleware = createSearchIndexDevMiddleware(
42
+ /** @type {SearchIndexConfig} */ (/** @type {unknown} */ (ctx.options)),
43
+ );
38
44
  // zfb's `register(path, handler)` matches against the FULL request
39
45
  // URL (no base-stripping). For a non-root base (e.g. "/my-docs/"),
40
46
  // requests arrive as `/my-docs/search-index.json`, so we register
@@ -43,11 +49,17 @@ export default {
43
49
  // middleware itself is base-tolerant (matches via
44
50
  // `endsWith("/search-index.json")`), so it does not need a
45
51
  // separate base-stripping pass.
46
- const basePrefix = stripTrailingSlash(ctx.options.base ?? "");
52
+ const basePrefix = stripTrailingSlash(
53
+ typeof ctx.options["base"] === "string" ? ctx.options["base"] : "",
54
+ );
47
55
  ctx.register(`${basePrefix}/search-index.json`, connectToZfbHandler(middleware));
48
56
  },
49
57
  };
50
58
 
59
+ /**
60
+ * @param {string} s
61
+ * @returns {string}
62
+ */
51
63
  function stripTrailingSlash(s) {
52
64
  if (typeof s !== "string" || s.length === 0) return "";
53
65
  return s.endsWith("/") ? s.slice(0, -1) : s;
@@ -3,7 +3,7 @@
3
3
  // Use preact hook entrypoints directly — the "react" → "preact/compat" alias
4
4
  // lets us consume React-typed components in this Preact app (configured
5
5
  // project-wide). Same pattern as src/components/sidebar-tree.tsx and
6
- // packages/zudo-doc/src/theme/theme-toggle.tsx. Type references via
6
+ // packages/zudo-doc/src/theme-toggle/index.tsx. Type references via
7
7
  // the global React namespace still resolve via @types/react.
8
8
  import { useState, useEffect } from "preact/hooks";
9
9
  import clsx from "clsx";
@@ -7,7 +7,9 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "preact/hooks"
7
7
  import type { NavNode } from "@/utils/docs";
8
8
  import type { LocaleLink } from "@/types/locale";
9
9
  import { INDENT, BASE_PAD, connectorLeft, ConnectorLines, CategoryLinkIcon } from "./tree-nav-shared";
10
- import ThemeToggle from "@/components/theme-toggle";
10
+ // BARE ThemeToggle (#2012 E2) — this footer toggle renders inside the
11
+ // SidebarToggle island, so it must NOT bring its own island wrapper.
12
+ import { ThemeToggle } from "@takazudo/zudo-doc/theme-toggle";
11
13
  import { smartBreakToHtml } from "@/utils/smart-break";
12
14
 
13
15
  function ToggleChevron({ isExpanded, className }: { isExpanded: boolean; className?: string }) {
@@ -61,7 +63,9 @@ function findActiveSlug(nodes: NavNode[], pathname: string): string | undefined
61
63
  for (const node of nodes) {
62
64
  if (node.href && normalizePath(node.href) === pathname) return node.slug;
63
65
  const found = findActiveSlug(node.children, pathname);
64
- if (found) return found;
66
+ // "" is the canonical root-index slug (#1891) a truthiness check
67
+ // would discard a legitimate root match.
68
+ if (found !== undefined) return found;
65
69
  }
66
70
  return undefined;
67
71
  }
@@ -246,8 +250,10 @@ export default function SidebarTree({ nodes, currentSlug, rootMenuItems, backToM
246
250
  }
247
251
 
248
252
  // Top page: show only header nav links, no doc tree or filter.
249
- // Derived from activeSlug (runtime-synced) so it stays correct across View Transitions.
250
- if (!activeSlug && rootMenuItems) {
253
+ // Derived from activeSlug (runtime-synced) so it stays correct across View
254
+ // Transitions. Must be an undefined check, not truthiness: "" is the
255
+ // canonical root-index doc slug (#1891) and gets the full tree.
256
+ if (activeSlug === undefined && rootMenuItems) {
251
257
  return (
252
258
  <nav>
253
259
  {rootMenuItems.map((item) => (
@@ -11,6 +11,10 @@ export interface ColorScheme {
11
11
  string, string, string, string, string, string, string, string,
12
12
  string, string, string, string, string, string, string, string,
13
13
  ];
14
+ /** Optional Shiki theme for the zdtp panel's client-side code-block preview.
15
+ * Falls back to the panel config's DEFAULT_SHIKI_THEME when omitted.
16
+ * Static highlighting (syntect via zfb's Rust pipeline) is unaffected. */
17
+ shikiTheme?: string;
14
18
  /** Optional semantic overrides — when omitted, defaults are used:
15
19
  * surface=p0, muted=p8, accent=p5, accentHover=p14
16
20
  * codeBg=p10, codeFg=p11, success=p2, danger=p1, warning=p3, info=p4
@@ -0,0 +1,94 @@
1
+ import { z } from "zod";
2
+ import { settings } from "./settings";
3
+ import { tagVocabulary } from "./tag-vocabulary";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Tags schema builder — governance-aware.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Build the `tags` schema based on governance mode. `"strict"` tightens to a
11
+ * `z.enum` of every canonical id plus every alias (content still uses
12
+ * aliases verbatim — resolution happens at the aggregation layer, after
13
+ * parsing).
14
+ */
15
+ function buildTagsSchema() {
16
+ const vocabularyActive =
17
+ settings.tagVocabulary && settings.tagGovernance === "strict";
18
+ if (!vocabularyActive) return z.array(z.string()).optional();
19
+ const allowed = new Set<string>();
20
+ for (const entry of tagVocabulary) {
21
+ allowed.add(entry.id);
22
+ for (const alias of entry.aliases ?? []) allowed.add(alias);
23
+ }
24
+ const allowedList = [...allowed];
25
+ if (allowedList.length === 0) return z.array(z.string()).optional();
26
+ const [first, ...rest] = allowedList;
27
+ return z
28
+ .array(z.enum([first, ...rest] as [string, ...string[]]))
29
+ .optional();
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Schema builder — single source of truth for the docs frontmatter shape.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Build the docs frontmatter zod schema.
38
+ *
39
+ * Returns a single `z.object(...).passthrough()` that is reused for every
40
+ * docs collection (default + per-locale + per-version + per-version-per-locale).
41
+ * The `tags` field is governance-aware: `buildTagsSchema()` returns a plain
42
+ * `z.array(z.string())` when governance is off, or a restricted `z.enum`
43
+ * when `tagGovernance: "strict"` + `tagVocabulary` is configured.
44
+ *
45
+ * `.passthrough()` keeps custom frontmatter keys (e.g. `author`, `status`)
46
+ * available downstream — the frontmatter-preview UI relies on this to
47
+ * surface arbitrary keys without declaring each one here.
48
+ */
49
+ export function buildDocsSchema() {
50
+ return z
51
+ .object({
52
+ title: z.string(),
53
+ description: z.string().optional(),
54
+ category: z.string().optional(),
55
+ sidebar_position: z.number().optional(),
56
+ sidebar_label: z.string().optional(),
57
+ tags: buildTagsSchema(),
58
+ search_exclude: z.boolean().optional(),
59
+ pagination_next: z.string().nullable().optional(),
60
+ pagination_prev: z.string().nullable().optional(),
61
+ draft: z.boolean().optional(),
62
+ unlisted: z.boolean().optional(),
63
+ hide_sidebar: z.boolean().optional(),
64
+ hide_toc: z.boolean().optional(),
65
+ doc_history: z.boolean().optional(),
66
+ standalone: z.boolean().optional(),
67
+ slug: z.string().optional(),
68
+ generated: z.boolean().optional(),
69
+ // Category metadata expressed as a directory index.mdx's frontmatter — the
70
+ // frontmatter form of `_category_.json`. `category_no_page` makes the index
71
+ // a non-linked sidebar header excluded from routes/sitemap/search;
72
+ // `category_sort_order` sets the child sort direction. Frontmatter wins
73
+ // over the sidecar.
74
+ category_no_page: z.boolean().optional(),
75
+ category_sort_order: z.enum(["asc", "desc"]).optional(),
76
+ })
77
+ .passthrough();
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Inferred type — single source of truth for the docs data shape.
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * TypeScript type inferred from the docs frontmatter zod schema.
86
+ *
87
+ * Import this type instead of hand-writing the field list in `pages/_data.ts`
88
+ * (`ZfbDocsData`) or `src/types/docs-entry.ts` (`DocsEntry.data`).
89
+ *
90
+ * The `[key: string]: unknown` index signature from `.passthrough()` is
91
+ * naturally present via `z.infer` — custom frontmatter keys remain accessible
92
+ * downstream (e.g. frontmatter-preview) without extra casting.
93
+ */
94
+ export type DocsData = z.infer<ReturnType<typeof buildDocsSchema>>;
@@ -1,4 +1,5 @@
1
1
  import { settings } from "./settings";
2
+ import type { LocaleConfig } from "./settings-types";
2
3
 
3
4
  // Collection name string used by zfb's content engine (`getCollection(...)`).
4
5
  // Kept as a structural string-literal alias so callers don't have to redeclare
@@ -15,9 +16,9 @@ export const locales = [
15
16
  ] as const;
16
17
  export type Locale = (typeof locales)[number];
17
18
 
18
- /** Safely look up a locale in settings.locales. */
19
- function getLocaleConfig(locale: string) {
20
- return settings.locales[locale];
19
+ /** Safely look up a locale in settings.locales by string key. */
20
+ export function getLocaleConfig(locale: string): LocaleConfig | undefined {
21
+ return (settings.locales as Record<string, LocaleConfig | undefined>)[locale];
21
22
  }
22
23
 
23
24
  /** Get the content directory for a locale. */
@@ -70,6 +71,8 @@ const translations: Record<string, Record<string, string>> = {
70
71
  "toc.title": "On this page",
71
72
  "docs.browseAll": "Browse all documentation sections.",
72
73
  "search.label": "Search",
74
+ "search.placeholder": "Type to search...",
75
+ "search.shortcutHint": "to open search from anywhere",
73
76
  "search.resultCount": "{count} results",
74
77
  "code.copy": "Copy code",
75
78
  "code.copied": "Copied!",
@@ -128,6 +131,8 @@ const translations: Record<string, Record<string, string>> = {
128
131
  "toc.title": "目次",
129
132
  "docs.browseAll": "すべてのドキュメントセクションを閲覧",
130
133
  "search.label": "検索",
134
+ "search.placeholder": "検索したい単語を入力",
135
+ "search.shortcutHint": "いつでも検索バーを開ける",
131
136
  "search.resultCount": "{count} 件",
132
137
  "code.copy": "コードをコピー",
133
138
  "code.copied": "コピーしました!",
@@ -186,6 +191,8 @@ const translations: Record<string, Record<string, string>> = {
186
191
  "toc.title": "Auf dieser Seite",
187
192
  "docs.browseAll": "Alle Dokumentationsabschnitte durchsuchen.",
188
193
  "search.label": "Suche",
194
+ "search.placeholder": "Suchbegriff eingeben...",
195
+ "search.shortcutHint": "Suche von überall öffnen",
189
196
  "search.resultCount": "{count} Ergebnisse",
190
197
  "code.copy": "Code kopieren",
191
198
  "code.copied": "Kopiert!",
@@ -1002,6 +1002,20 @@ pre[class^="syntect-"] .line .highlighted-word {
1002
1002
  html[data-sidebar-hidden] .zd-sidebar-content-wrapper {
1003
1003
  margin-left: 0;
1004
1004
  }
1005
+
1006
+ /* When hidden via the toggle, narrow the content band to the
1007
+ * hide_sidebar frontmatter width so it centers (the flex parent already
1008
+ * applies justify-content: center) instead of leaving a dead gap where
1009
+ * the sidebar was. 80rem must match the `max-w-[80rem]` hide_sidebar
1010
+ * branch in doc-layout.tsx. These rules are unlayered, so they win over
1011
+ * the Tailwind `max-w-[clamp(...)]` utility (utilities layer). (#2002) */
1012
+ .zd-doc-content-band {
1013
+ transition: max-width 200ms ease-in-out;
1014
+ }
1015
+
1016
+ html[data-sidebar-hidden] .zd-doc-content-band {
1017
+ max-width: 80rem;
1018
+ }
1005
1019
  }
1006
1020
 
1007
1021
  /* Sidebar toggle button — left position uses CSS variable, needs global rule */
@@ -1,3 +1,5 @@
1
+ import type { DocsData } from "@/config/docs-schema";
2
+
1
3
  /**
2
4
  * Concrete entry type for docs collections.
3
5
  *
@@ -6,6 +8,9 @@
6
8
  * but is defined locally now that the project runs on the zfb content
7
9
  * engine — collection-name-specific generics are not exposed by zfb, so
8
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.
9
14
  */
10
15
  // Structural shape of zfb's optional rendered-content payload for a doc
11
16
  // entry (kept loose to stay engine-agnostic — pages do not rely on the
@@ -13,34 +18,11 @@
13
18
  type RenderedContent = unknown;
14
19
  export interface DocsEntry {
15
20
  id: string;
21
+ /** zfb content engine slug (filename without `.md`/`.mdx`; used by toRouteSlug). */
22
+ slug: string;
16
23
  body?: string;
17
24
  collection: string;
18
- data: {
19
- title: string;
20
- description?: string;
21
- category?: string;
22
- sidebar_position?: number;
23
- sidebar_label?: string;
24
- tags?: string[];
25
- search_exclude?: boolean;
26
- pagination_next?: string | null;
27
- pagination_prev?: string | null;
28
- draft?: boolean;
29
- unlisted?: boolean;
30
- hide_sidebar?: boolean;
31
- hide_toc?: boolean;
32
- doc_history?: boolean;
33
- standalone?: boolean;
34
- slug?: string;
35
- generated?: boolean;
36
- /** Category metadata on a directory's index.mdx (frontmatter form of
37
- * `_category_.json` `noPage`): non-linked sidebar header + excluded from
38
- * routes/sitemap/search. Frontmatter wins over the sidecar. */
39
- category_no_page?: boolean;
40
- /** Frontmatter form of `_category_.json` `sortOrder` — child sort
41
- * direction. Frontmatter wins over the sidecar. */
42
- category_sort_order?: "asc" | "desc";
43
- };
25
+ data: DocsData;
44
26
  rendered?: RenderedContent;
45
27
  filePath?: string;
46
28
  }
@@ -37,8 +37,10 @@ export function withBase(path: string): string {
37
37
  /** Strip the base prefix from a URL pathname. */
38
38
  export function stripBase(path: string): string {
39
39
  if (normalizedBase === "") return path;
40
- return path.startsWith(normalizedBase)
41
- ? path.slice(normalizedBase.length) || "/"
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)
42
44
  : path;
43
45
  }
44
46
 
@@ -55,7 +57,7 @@ export function absoluteUrl(pageUrl: string): string | undefined {
55
57
  }
56
58
 
57
59
  /** Build a docs URL for the given slug and lang. */
58
- export function docsUrl(slug: string, lang: Locale = defaultLocale): string {
60
+ export function docsUrl(slug: string, lang: Locale | string = defaultLocale): string {
59
61
  const path = lang === defaultLocale ? `/docs/${slug}` : `/${lang}/docs/${slug}`;
60
62
  return withBase(path);
61
63
  }