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.
- package/dist/api.js +4 -1
- package/dist/cli.js +4 -6
- package/dist/preset.js +11 -0
- package/dist/prompts.js +2 -6
- package/dist/scaffold.js +15 -9
- package/dist/settings-gen.js +7 -7
- package/dist/utils.d.ts +8 -0
- package/dist/utils.js +25 -0
- package/dist/zfb-config-gen.js +11 -50
- package/package.json +1 -1
- package/templates/base/pages/_data.ts +10 -23
- package/templates/base/pages/docs/[[...slug]].tsx +27 -168
- package/templates/base/pages/lib/_doc-content-header.tsx +24 -4
- package/templates/base/pages/lib/_doc-history-area.tsx +21 -5
- package/templates/base/pages/lib/_doc-metainfo-area.tsx +22 -2
- package/templates/base/pages/lib/_doc-page-renderer.tsx +192 -0
- package/templates/base/pages/lib/_doc-page-shell.tsx +3 -2
- package/templates/base/pages/lib/_doc-route-entries.ts +188 -0
- package/templates/base/pages/lib/_doc-tags-area.tsx +7 -2
- package/templates/base/pages/lib/_footer-with-defaults.tsx +38 -27
- package/templates/base/pages/lib/_head-with-defaults.tsx +7 -10
- package/templates/base/pages/lib/_header-with-defaults.tsx +51 -89
- package/templates/base/pages/lib/_inline-version-switcher.tsx +5 -4
- package/templates/base/pages/lib/_nav-data-prep.ts +137 -0
- package/templates/base/pages/lib/_nav-source-docs.ts +10 -6
- package/templates/base/pages/lib/_search-widget-script.ts +32 -9
- package/templates/base/pages/lib/_sidebar-with-defaults.tsx +15 -60
- package/templates/base/pages/lib/locale-merge.ts +1 -1
- package/templates/base/pages/lib/route-enumerators.ts +11 -7
- package/templates/base/plugins/connect-adapter.mjs +30 -1
- package/templates/base/plugins/copy-public-plugin.mjs +10 -2
- package/templates/base/plugins/search-index-plugin.mjs +20 -8
- package/templates/base/src/components/sidebar-toggle.tsx +1 -1
- package/templates/base/src/components/sidebar-tree.tsx +10 -4
- package/templates/base/src/config/color-schemes.ts +4 -0
- package/templates/base/src/config/docs-schema.ts +94 -0
- package/templates/base/src/config/i18n.ts +10 -3
- package/templates/base/src/styles/global.css +14 -0
- package/templates/base/src/types/docs-entry.ts +8 -26
- package/templates/base/src/utils/base.ts +5 -3
- package/templates/base/src/utils/docs.ts +144 -169
- package/templates/features/claudeResources/files/plugins/claude-resources-plugin.mjs +20 -110
- package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +62 -38
- package/templates/features/designTokenPanel/files/src/config/design-token-panel-config.ts +34 -8
- package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +27 -45
- package/templates/features/docHistory/files/src/components/doc-history.tsx +28 -8
- package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +6 -74
- package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +6 -77
- package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +7 -69
- package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -76
- package/templates/features/docTags/files/pages/lib/_tag-pages.tsx +201 -0
- package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +41 -179
- package/templates/features/i18n/files/pages/[locale]/index.tsx +5 -5
- package/templates/features/llmsTxt/files/plugins/llms-txt-plugin.mjs +33 -21
- package/templates/features/sidebarToggle/files/src/components/desktop-sidebar-toggle.tsx +1 -1
- package/templates/features/versioning/files/pages/[locale]/docs/versions.tsx +5 -59
- package/templates/features/versioning/files/pages/docs/versions.tsx +8 -66
- package/templates/features/versioning/files/pages/lib/_versions-page.tsx +79 -0
- package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +46 -191
- package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +31 -173
- package/templates/base/src/components/content/heading-h3.tsx +0 -20
- package/templates/base/src/components/theme-toggle.tsx +0 -107
- package/templates/base/src/hooks/use-active-heading.ts +0 -133
- package/templates/base/src/plugins/docs-source-map.ts +0 -103
- package/templates/base/src/plugins/hast-utils.ts +0 -10
- package/templates/base/src/plugins/rehype-code-title.ts +0 -50
- package/templates/base/src/plugins/rehype-heading-links.ts +0 -53
- package/templates/base/src/plugins/rehype-mermaid.ts +0 -41
- package/templates/base/src/plugins/url-utils.ts +0 -4
- package/templates/base/src/utils/dedent.ts +0 -24
- package/templates/features/docHistory/files/src/utils/doc-history.ts +0 -180
- 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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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:
|
|
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
|
|
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
|
|
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/${
|
|
133
|
-
: `/${locale}/docs/tags/${
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
27
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
250
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
}
|