create-zudo-doc 0.2.0-next.7 → 0.2.0-next.8
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/scaffold.js +2 -2
- package/dist/zfb-config-gen.js +2 -0
- package/package.json +1 -1
- package/templates/base/pages/docs/[[...slug]].tsx +5 -0
- package/templates/base/pages/index.tsx +3 -1
- package/templates/base/pages/lib/_category-nav.tsx +13 -9
- package/templates/base/pages/lib/_doc-history-area.tsx +22 -2
- package/templates/base/pages/lib/_footer-with-defaults.tsx +9 -3
- package/templates/base/pages/lib/_head-with-defaults.tsx +3 -0
- package/templates/base/pages/lib/route-enumerators.ts +18 -3
- package/templates/base/src/components/client-router-bootstrap.tsx +55 -5
- package/templates/base/src/components/sidebar-toggle.tsx +106 -48
- package/templates/base/src/components/theme-toggle.tsx +15 -1
- package/templates/base/src/config/frontmatter-preview-defaults.ts +2 -0
- package/templates/base/src/styles/global.css +38 -11
- package/templates/base/src/types/docs-entry.ts +7 -0
- package/templates/base/src/utils/docs.ts +41 -5
- package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +172 -13
- package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +34 -12
- package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +1 -0
- package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +4 -2
- package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +7 -1
- package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +7 -1
- package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +6 -1
- package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -1
- package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +5 -0
- package/templates/features/i18n/files/pages/[locale]/index.tsx +3 -1
- package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +4 -0
- package/templates/features/versioning/files/pages/v/[version]/docs/[[...slug]].tsx +4 -0
package/dist/scaffold.js
CHANGED
|
@@ -273,7 +273,7 @@ function generatePackageJson(choices) {
|
|
|
273
273
|
// .github/workflows/publish-zudo-doc.yml. The pin here is bumped in
|
|
274
274
|
// lockstep by scripts/release-create-zudo-doc.sh whenever zudo-doc's
|
|
275
275
|
// version moves, so a fresh scaffold pulls the version we just published.
|
|
276
|
-
"@takazudo/zudo-doc": "^0.2.0-next.
|
|
276
|
+
"@takazudo/zudo-doc": "^0.2.0-next.8",
|
|
277
277
|
// zod — used by the generated zfb.config.ts. zfb-config-gen emits
|
|
278
278
|
// `import { z } from "zod"` for the content-collection schema +
|
|
279
279
|
// `z.toJSONSchema(...)` conversion. Without this dep, the consumer
|
|
@@ -331,7 +331,7 @@ function generatePackageJson(choices) {
|
|
|
331
331
|
// @takazudo/zudo-doc/integrations/doc-history which in turn imports
|
|
332
332
|
// @takazudo/zudo-doc-history-server/git-history. Without this dep the
|
|
333
333
|
// plugin host fails at init with ERR_MODULE_NOT_FOUND — W8A (#1739).
|
|
334
|
-
deps["@takazudo/zudo-doc-history-server"] = "^0.2.0-next.
|
|
334
|
+
deps["@takazudo/zudo-doc-history-server"] = "^0.2.0-next.8";
|
|
335
335
|
// W7A (#1736): doc-history-plugin.mjs spawns `tsx -e <inline-script>` to
|
|
336
336
|
// run the v2 runtime in a TS-aware Node subprocess; without tsx the
|
|
337
337
|
// plugin's preBuild step exits with ENOENT before zfb finishes config
|
package/dist/zfb-config-gen.js
CHANGED
|
@@ -71,6 +71,8 @@ export function generateZfbConfig(choices) {
|
|
|
71
71
|
lines.push(` standalone: z.boolean().optional(),`);
|
|
72
72
|
lines.push(` slug: z.string().optional(),`);
|
|
73
73
|
lines.push(` generated: z.boolean().optional(),`);
|
|
74
|
+
lines.push(` category_no_page: z.boolean().optional(),`);
|
|
75
|
+
lines.push(` category_sort_order: z.enum(["asc", "desc"]).optional(),`);
|
|
74
76
|
lines.push(` })`);
|
|
75
77
|
lines.push(` .passthrough();`);
|
|
76
78
|
lines.push(``);
|
package/package.json
CHANGED
|
@@ -90,6 +90,11 @@ export function paths(): Array<{
|
|
|
90
90
|
|
|
91
91
|
// Regular doc pages
|
|
92
92
|
for (const entry of docs) {
|
|
93
|
+
// A `category_no_page` index.mdx carries category metadata only — keep it
|
|
94
|
+
// in the nav tree (built above, used for breadcrumbs) but emit NO route for
|
|
95
|
+
// it. zfb's walker retains every .mdx as a collection entry, so without
|
|
96
|
+
// this explicit skip the metadata file would silently add a route.
|
|
97
|
+
if (entry.data.category_no_page === true) continue;
|
|
93
98
|
const slug = entry.data.slug ?? toRouteSlug(entry.slug);
|
|
94
99
|
const navSection = getNavSectionForSlug(slug);
|
|
95
100
|
const subtree = getNavSubtree(tree, navSection);
|
|
@@ -46,8 +46,10 @@ export default function IndexPage(): JSX.Element {
|
|
|
46
46
|
const categoryOrder = getCategoryOrder();
|
|
47
47
|
const groupedTree = groupSatelliteNodes(tree, categoryOrder);
|
|
48
48
|
|
|
49
|
+
// Drop category_no_page index files so the count matches the number of tag
|
|
50
|
+
// pages actually built (the tag routes exclude them too).
|
|
49
51
|
const tagCount = collectTags(
|
|
50
|
-
navDocs,
|
|
52
|
+
navDocs.filter((d) => !d.data.category_no_page),
|
|
51
53
|
(id, data) => data.slug ?? toRouteSlug(id),
|
|
52
54
|
).size;
|
|
53
55
|
|
|
@@ -21,9 +21,9 @@ import type { NavNode as V2NavNode } from "@takazudo/zudo-doc/nav-indexing/types
|
|
|
21
21
|
import {
|
|
22
22
|
buildNavTree,
|
|
23
23
|
findNode,
|
|
24
|
+
firstRoutedHref,
|
|
24
25
|
} from "@/utils/docs";
|
|
25
26
|
import { defaultLocale, type Locale } from "@/config/i18n";
|
|
26
|
-
import { docsUrl } from "@/utils/base";
|
|
27
27
|
import { resolveNavSource } from "./_nav-source-docs";
|
|
28
28
|
|
|
29
29
|
export interface CategoryNavWrapperProps {
|
|
@@ -39,8 +39,9 @@ export interface CategoryNavWrapperProps {
|
|
|
39
39
|
* of "claude", not children). Each slug is resolved to its nav node; nodes
|
|
40
40
|
* not found in the tree are silently skipped.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* A `category_no_page` category has no route of its own, so its card links to
|
|
43
|
+
* the first routed descendant page (via firstRoutedHref); categories with no
|
|
44
|
+
* reachable page are skipped rather than emitting a dead link.
|
|
44
45
|
*/
|
|
45
46
|
categories?: string[];
|
|
46
47
|
/**
|
|
@@ -61,8 +62,8 @@ export interface CategoryNavWrapperProps {
|
|
|
61
62
|
* - `categories`: resolves an explicit list of top-level slugs as cards.
|
|
62
63
|
* Use this when the target categories are siblings in the nav tree rather
|
|
63
64
|
* than children of a common parent (e.g. claude-md / claude-skills are
|
|
64
|
-
* top-level peers of claude, not children of it).
|
|
65
|
-
*
|
|
65
|
+
* top-level peers of claude, not children of it). A noPage category card
|
|
66
|
+
* links to its first routed descendant page (it has no route of its own).
|
|
66
67
|
*
|
|
67
68
|
* Returns null when no visible children are resolved.
|
|
68
69
|
*/
|
|
@@ -85,14 +86,17 @@ export function CategoryNavWrapper({
|
|
|
85
86
|
let children: V2NavNode[];
|
|
86
87
|
|
|
87
88
|
if (categories !== undefined) {
|
|
88
|
-
// Explicit slug list mode: resolve each slug to its nav node and build
|
|
89
|
-
//
|
|
90
|
-
//
|
|
89
|
+
// Explicit slug list mode: resolve each slug to its nav node and build a
|
|
90
|
+
// card for it. A `category_no_page` category has no route of its own
|
|
91
|
+
// (collectAutoIndexNodes skips noPage nodes), so its card links to the
|
|
92
|
+
// first routed descendant page; categories with no reachable page are
|
|
93
|
+
// skipped rather than emitting a dead link.
|
|
91
94
|
children = categories
|
|
92
95
|
.map((slug): V2NavNode | null => {
|
|
93
96
|
const node = findNode(tree, slug);
|
|
94
97
|
if (!node) return null;
|
|
95
|
-
const href = node.href ??
|
|
98
|
+
const href = node.href ?? firstRoutedHref(node);
|
|
99
|
+
if (!href) return null;
|
|
96
100
|
return {
|
|
97
101
|
label: node.label,
|
|
98
102
|
description: node.description,
|
|
@@ -67,6 +67,17 @@ interface DocHistoryAreaProps {
|
|
|
67
67
|
* view-source GitHub URL. Omit to suppress the view-source link.
|
|
68
68
|
*/
|
|
69
69
|
contentDir?: string;
|
|
70
|
+
/**
|
|
71
|
+
* True when this locale page falls back to the base EN collection
|
|
72
|
+
* (i.e. the slug has no translation for the active locale). When true,
|
|
73
|
+
* the history data-path derivations use defaultLocale so the island
|
|
74
|
+
* fetches the correct bare-slug JSON and the SSR manifest lookup hits
|
|
75
|
+
* the bare key — both of which only exist for EN-origin files.
|
|
76
|
+
* Display labels (t() calls) still use the active locale so JA users
|
|
77
|
+
* see JA labels on fallback pages. Omit (or false) for translated pages
|
|
78
|
+
* and all other call sites (EN route, tag pages) — behavior unchanged.
|
|
79
|
+
*/
|
|
80
|
+
isFallback?: boolean;
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
/**
|
|
@@ -93,6 +104,7 @@ export function DocHistoryArea({
|
|
|
93
104
|
locale,
|
|
94
105
|
entrySlug,
|
|
95
106
|
contentDir,
|
|
107
|
+
isFallback,
|
|
96
108
|
}: DocHistoryAreaProps): VNode | null {
|
|
97
109
|
if (!settings.docHistory) return null;
|
|
98
110
|
|
|
@@ -106,11 +118,18 @@ export function DocHistoryArea({
|
|
|
106
118
|
// collectContentFiles walk in packages/doc-history-server. (#1891)
|
|
107
119
|
const historySlug = toHistorySlug(slug);
|
|
108
120
|
|
|
121
|
+
// On EN-fallback locale pages the history data exists only at the bare
|
|
122
|
+
// (non-locale-prefixed) path — the prebuild/server writes locale-prefixed
|
|
123
|
+
// keys/paths only for files physically present in the locale collection.
|
|
124
|
+
// Use defaultLocale for data lookups when isFallback is true; keep locale
|
|
125
|
+
// for all display label calls (t()) so JA users see JA labels.
|
|
126
|
+
const effectiveHistoryLocale = isFallback ? defaultLocale : locale;
|
|
127
|
+
|
|
109
128
|
// Look up the build-time manifest entry for this page. The composedSlug
|
|
110
129
|
// matches the key written by the prebuild step: bare slug for the default
|
|
111
130
|
// locale, "<localeKey>/<slug>" for non-default locales.
|
|
112
131
|
const composedSlug =
|
|
113
|
-
|
|
132
|
+
effectiveHistoryLocale === defaultLocale ? historySlug : `${effectiveHistoryLocale}/${historySlug}`;
|
|
114
133
|
type MetaEntry = { author: string; createdDate: string; updatedDate: string };
|
|
115
134
|
const meta = (docHistoryMeta as Record<string, MetaEntry>)[composedSlug];
|
|
116
135
|
|
|
@@ -120,7 +139,8 @@ export function DocHistoryArea({
|
|
|
120
139
|
const historyLabel = t("doc.history", locale);
|
|
121
140
|
|
|
122
141
|
// Real-component props — locale omitted for the default locale.
|
|
123
|
-
|
|
142
|
+
// Use effectiveHistoryLocale so fallback pages fetch the bare (non-ja/) path.
|
|
143
|
+
const docHistoryLocale = effectiveHistoryLocale === defaultLocale ? undefined : effectiveHistoryLocale;
|
|
124
144
|
const docHistoryBasePath = settings.base ?? "/";
|
|
125
145
|
|
|
126
146
|
// Build the SSR fallback with only the sr-only metadata block so the
|
|
@@ -113,20 +113,26 @@ export function FooterWithDefaults({
|
|
|
113
113
|
// Load docs synchronously (zfb ADR-004 — synchronous content snapshot).
|
|
114
114
|
let docs: DocsEntry[];
|
|
115
115
|
if (lang === defaultLocale) {
|
|
116
|
-
|
|
116
|
+
// category_no_page index files build no route — drop them so the footer
|
|
117
|
+
// taglist matches the tag-route pages (which all now filter too).
|
|
118
|
+
docs = loadDocs("docs").filter(
|
|
119
|
+
(d) => !d.data.draft && !d.data.unlisted && !d.data.category_no_page,
|
|
120
|
+
);
|
|
117
121
|
} else {
|
|
118
122
|
// Apply the default-locale-only filter so the footer taglist only counts
|
|
119
123
|
// tags that have a locale-routable tag page — matching the tag-route
|
|
120
124
|
// pages ([tag].tsx / tags/index.tsx) and enumerateTagsRoutes, which all
|
|
121
125
|
// now filter. Without this, the footer would link to /{locale}/docs/tags/
|
|
122
126
|
// pages that are never built for tags living only on default-locale-only
|
|
123
|
-
// prefix pages.
|
|
127
|
+
// prefix pages. category_no_page is dropped for the same reason — AFTER
|
|
128
|
+
// the merge, so a locale override carrying the flag first wins the merge
|
|
129
|
+
// (pre-merge filtering would let the unflagged base doc resurface).
|
|
124
130
|
const result = mergeLocaleDocs({
|
|
125
131
|
baseDocs: loadDocs("docs").filter((d) => !d.data.draft),
|
|
126
132
|
localeDocs: loadDocs(`docs-${lang}`).filter((d) => !d.data.draft),
|
|
127
133
|
applyDefaultLocaleOnlyFilter: true,
|
|
128
134
|
});
|
|
129
|
-
docs = result.docs;
|
|
135
|
+
docs = result.docs.filter((d) => !d.data.category_no_page);
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
const tagMap = collectTags(
|
|
@@ -97,7 +97,10 @@ export function HeadWithDefaults({
|
|
|
97
97
|
<OgTags
|
|
98
98
|
title={composeMetaTitle(title)}
|
|
99
99
|
description={description}
|
|
100
|
+
ogType="website"
|
|
101
|
+
ogUrl={canonical}
|
|
100
102
|
ogImage={ogImageUrl}
|
|
103
|
+
ogSiteName={settings.siteName}
|
|
101
104
|
/>
|
|
102
105
|
{/* og:image:width / og:image:height / og:image:alt — not in OgTags API;
|
|
103
106
|
emitted here directly to avoid expanding the shared HeadProps surface.
|
|
@@ -60,6 +60,10 @@ export function enumerateDocsRoutes(locale: string): string[] {
|
|
|
60
60
|
const tree = buildNavTree(navDocs, locale as Locale, categoryMeta);
|
|
61
61
|
|
|
62
62
|
for (const doc of allDocs) {
|
|
63
|
+
// A `category_no_page` index.mdx is metadata-only — no route, so no sitemap
|
|
64
|
+
// URL. Same exclusion the doc-route paths() apply (zfb retains every .mdx
|
|
65
|
+
// as a collection entry, so the skip must be explicit).
|
|
66
|
+
if (doc.data.category_no_page === true) continue;
|
|
63
67
|
// Canonical route slug via the one shared rule (@/utils/slug). `doc.id` is
|
|
64
68
|
// already `toRouteSlug(doc.slug)` (bridged through stripIndexSuffix in
|
|
65
69
|
// pages/_data.ts), so a bare root index.mdx is "" here → `/docs/` — the
|
|
@@ -100,17 +104,24 @@ export function enumerateTagsRoutes(locale: string): string[] {
|
|
|
100
104
|
urls.push(withBase(tagsBase));
|
|
101
105
|
|
|
102
106
|
// Collect tags from the same merged doc set the tag pages use.
|
|
103
|
-
// Filter unlisted + draft — mirrors the tag [tag].tsx
|
|
107
|
+
// Filter unlisted + draft + category_no_page — mirrors the tag [tag].tsx
|
|
108
|
+
// pages so the sitemap lists exactly the tag pages that get built (a
|
|
109
|
+
// category_no_page index has no route, so a tag it carries must not coin a
|
|
110
|
+
// tag page that links back to it). The category_no_page drop happens AFTER
|
|
111
|
+
// the locale merge so a locale override carrying the flag first wins the
|
|
112
|
+
// merge — pre-merge filtering would let the unflagged base doc resurface.
|
|
104
113
|
let docs: DocsEntry[];
|
|
105
114
|
if (locale === defaultLocale) {
|
|
106
|
-
docs = loadDocs("docs").filter(
|
|
115
|
+
docs = loadDocs("docs").filter(
|
|
116
|
+
(d) => !d.data.unlisted && !d.data.draft && !d.data.category_no_page,
|
|
117
|
+
);
|
|
107
118
|
} else {
|
|
108
119
|
const result = mergeLocaleDocs({
|
|
109
120
|
baseDocs: loadDocs("docs").filter((d) => !d.data.draft),
|
|
110
121
|
localeDocs: loadDocs(`docs-${locale}`).filter((d) => !d.data.draft),
|
|
111
122
|
applyDefaultLocaleOnlyFilter: true,
|
|
112
123
|
});
|
|
113
|
-
docs = result.docs;
|
|
124
|
+
docs = result.docs.filter((d) => !d.data.category_no_page);
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
|
|
@@ -157,6 +168,8 @@ export function enumerateVersionedRoutes(
|
|
|
157
168
|
const tree = buildNavTree(navDocs, "en", categoryMeta);
|
|
158
169
|
|
|
159
170
|
for (const doc of allDocs) {
|
|
171
|
+
// category_no_page index.mdx → no route, no sitemap URL (see paths()).
|
|
172
|
+
if (doc.data.category_no_page === true) continue;
|
|
160
173
|
const slug = doc.data.slug ?? toRouteSlug(doc.id);
|
|
161
174
|
urls.push(versionedDocsUrl(slug, version.slug));
|
|
162
175
|
}
|
|
@@ -178,6 +191,8 @@ export function enumerateVersionedRoutes(
|
|
|
178
191
|
const tree = buildNavTree(navDocs, locale as Locale, categoryMeta);
|
|
179
192
|
|
|
180
193
|
for (const doc of allDocs) {
|
|
194
|
+
// category_no_page index.mdx → no route, no sitemap URL (see paths()).
|
|
195
|
+
if (doc.data.category_no_page === true) continue;
|
|
181
196
|
const slug = doc.data.slug ?? toRouteSlug(doc.id);
|
|
182
197
|
urls.push(versionedDocsUrl(slug, version.slug, locale as string));
|
|
183
198
|
}
|
|
@@ -1,14 +1,64 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Client-side bootstrap for the @takazudo/zfb-runtime ClientRouter.
|
|
4
|
+
//
|
|
5
|
+
// Why this exists (zudolab/zudo-doc#1524, W7A verification):
|
|
6
|
+
// `<ClientRouter />` (mounted in doc-layout.tsx) emits SSR meta tags + CSS
|
|
7
|
+
// for the SPA soft-swap router, but the actual click/form intercept
|
|
8
|
+
// registration runs as a top-of-module side effect inside
|
|
9
|
+
// `@takazudo/zfb-runtime/src/client-router.ts`:
|
|
10
|
+
//
|
|
11
|
+
// if (typeof document !== "undefined") { init(); }
|
|
12
|
+
//
|
|
13
|
+
// The host's existing `import { ClientRouter } from "@takazudo/zfb-runtime"`
|
|
14
|
+
// in doc-layout.tsx happens during SSR (where typeof document ===
|
|
15
|
+
// "undefined"), so the side effect is silently skipped — the router
|
|
16
|
+
// module never reaches the client bundle and every navigation falls
|
|
17
|
+
// through to a full page load. W7A's Playwright harness confirmed this:
|
|
18
|
+
// pageswap.viewTransition is null, getAnimations() is empty during nav,
|
|
19
|
+
// and the sidebar DOM identity is destroyed on every click.
|
|
20
|
+
//
|
|
21
|
+
// The fix is a minimal "use client" island whose only job is to
|
|
22
|
+
// side-effect-import `@takazudo/zfb-runtime/client-router` so the same
|
|
23
|
+
// guard fires in the browser. The `init()` is idempotent (guarded by an
|
|
24
|
+
// `initialized` flag in router.ts), so even if the import path is
|
|
25
|
+
// reached again later the registration runs exactly once.
|
|
2
26
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
27
|
+
// Bundle cost: the only thing this island ships to the client is the
|
|
28
|
+
// client-router subgraph (router.ts + swap-functions.ts + events.ts +
|
|
29
|
+
// types.ts + cssesc.ts) plus its single dep on `@takazudo/zfb/runtime`'s
|
|
30
|
+
// island manager. The server-only zfb-runtime modules
|
|
31
|
+
// (createPageRouter, snapshot, framework adapter) are not transitively
|
|
32
|
+
// reachable from the client-router barrel and stay out of the bundle.
|
|
33
|
+
//
|
|
34
|
+
// Why not call init() in useEffect instead of a side-effect import:
|
|
35
|
+
// useEffect fires after Preact mounts, which is after the islands
|
|
36
|
+
// runtime fetches and executes the bundle. Module top-level code runs
|
|
37
|
+
// as soon as the bundle is parsed by the browser — earlier than mount,
|
|
38
|
+
// closer to Astro's <script type="module"> emission timing. The first
|
|
39
|
+
// click on a fresh page typically arrives 100ms+ after parse, so this
|
|
40
|
+
// timing is what makes the SPA-router actually intercept.
|
|
41
|
+
|
|
42
|
+
// Side-effect import — running this file's bundle in the browser
|
|
43
|
+
// triggers the `if (typeof document !== "undefined") { init(); }` guard
|
|
44
|
+
// at the top of `@takazudo/zfb-runtime/src/client-router.ts`.
|
|
45
|
+
import "@takazudo/zfb-runtime/client-router";
|
|
46
|
+
|
|
7
47
|
import type { JSX } from "preact";
|
|
8
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Renders nothing. The island marker exists only so zfb's island scanner
|
|
51
|
+
* walks page → BodyEndIslands → ClientRouterBootstrap and includes the
|
|
52
|
+
* client-router barrel in the per-island bundle, where the side-effect
|
|
53
|
+
* import above can fire on the client.
|
|
54
|
+
*/
|
|
9
55
|
function ClientRouterBootstrap(): JSX.Element | null {
|
|
10
56
|
return null;
|
|
11
57
|
}
|
|
58
|
+
|
|
59
|
+
// Stable marker name across SSR / scanner / hydration manifest. Required
|
|
60
|
+
// for production minification per the existing pattern in
|
|
61
|
+
// `pages/lib/_body-end-islands.tsx` (zfb PR #150 marker-name alignment).
|
|
12
62
|
ClientRouterBootstrap.displayName = "ClientRouterBootstrap";
|
|
13
63
|
|
|
14
64
|
export default ClientRouterBootstrap;
|
|
@@ -1,11 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Use preact hook entrypoints directly — the "react" → "preact/compat" alias
|
|
4
|
+
// lets us consume React-typed components in this Preact app (configured
|
|
5
|
+
// project-wide). Same pattern as src/components/sidebar-tree.tsx and
|
|
6
|
+
// packages/zudo-doc/src/theme/theme-toggle.tsx. Type references via
|
|
7
|
+
// the global React namespace still resolve via @types/react.
|
|
8
|
+
import { useState, useEffect } from "preact/hooks";
|
|
9
|
+
import clsx from "clsx";
|
|
10
|
+
// After zudolab/zudo-doc#1335 (E2 task 2 half B) the host components
|
|
11
|
+
// pull lifecycle event names from the v2 transitions module rather
|
|
12
|
+
// than hard-coding `astro:*` literals.
|
|
13
|
+
import { AFTER_NAVIGATE_EVENT } from "@takazudo/zudo-doc/transitions";
|
|
14
|
+
import SidebarTree from "@/components/sidebar-tree";
|
|
15
|
+
import type { NavNode } from "@/utils/docs";
|
|
16
|
+
import type { LocaleLink } from "@/types/locale";
|
|
17
|
+
// Types-only subpath (`./sidebar/types`) sidesteps the JSX type-graph
|
|
18
|
+
// pulled in by `./sidebar`'s runtime barrel.
|
|
19
|
+
import type { SidebarRootMenuItem } from "@takazudo/zudo-doc/sidebar/types";
|
|
20
|
+
|
|
21
|
+
// Mobile drawer hosts the SidebarTree directly (rather than receiving it as
|
|
22
|
+
// JSX children) so the tree's data props ride across the SSR → hydrate
|
|
23
|
+
// boundary inside the Island marker's `data-props` attribute. zfb's
|
|
24
|
+
// `Island()` only serialises a child component's *own* props (excluding
|
|
25
|
+
// `children`); when SidebarTree was passed as `children`, its data was
|
|
26
|
+
// dropped during hydration and Preact wiped the SSR-rendered tree DOM.
|
|
27
|
+
// Mirroring the desktop `<Sidebar treeComponent={SidebarTree} ...>` shape
|
|
28
|
+
// keeps the data attached to the wrapping island. zudolab/zudo-doc#1355
|
|
29
|
+
// wave 13.5.
|
|
3
30
|
|
|
4
31
|
interface SidebarToggleProps {
|
|
5
|
-
|
|
32
|
+
nodes: NavNode[];
|
|
33
|
+
currentSlug?: string;
|
|
34
|
+
rootMenuItems?: SidebarRootMenuItem[];
|
|
35
|
+
backToMenuLabel?: string;
|
|
36
|
+
localeLinks?: LocaleLink[];
|
|
37
|
+
themeDefaultMode?: "light" | "dark";
|
|
6
38
|
}
|
|
7
39
|
|
|
8
|
-
export default function SidebarToggle({
|
|
40
|
+
export default function SidebarToggle({
|
|
41
|
+
nodes,
|
|
42
|
+
currentSlug,
|
|
43
|
+
rootMenuItems,
|
|
44
|
+
backToMenuLabel,
|
|
45
|
+
localeLinks,
|
|
46
|
+
themeDefaultMode,
|
|
47
|
+
}: SidebarToggleProps) {
|
|
48
|
+
// Initial state must match SSR (`open=false`) so the hydration DOM
|
|
49
|
+
// matches the SSG output byte-for-byte. The backdrop and toggle-icon
|
|
50
|
+
// are rendered unconditionally below so the hydration tree has the
|
|
51
|
+
// same shape regardless of `open`, preventing Preact from re-mounting
|
|
52
|
+
// the subtree (which can drop click handlers on the hamburger button).
|
|
9
53
|
const [open, setOpen] = useState(false);
|
|
10
54
|
|
|
11
55
|
useEffect(() => {
|
|
@@ -24,61 +68,68 @@ export default function SidebarToggle({ children }: SidebarToggleProps) {
|
|
|
24
68
|
function handleSwap() {
|
|
25
69
|
setOpen(false);
|
|
26
70
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
document.addEventListener("DOMContentLoaded", handleSwap);
|
|
30
|
-
return () => document.removeEventListener("DOMContentLoaded", handleSwap);
|
|
71
|
+
document.addEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
|
|
72
|
+
return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
|
|
31
73
|
}, []);
|
|
32
74
|
|
|
33
75
|
return (
|
|
34
76
|
<>
|
|
35
|
-
{/* Hamburger button - visible only on mobile
|
|
77
|
+
{/* Hamburger button - visible only on mobile.
|
|
78
|
+
Both icons are always rendered so the SSR output has the same
|
|
79
|
+
DOM shape as the post-hydration tree. The closed-state icon is
|
|
80
|
+
hidden via `hidden` when open=true, and vice versa, so Preact's
|
|
81
|
+
hydration walk sees byte-stable markup and keeps the click
|
|
82
|
+
handler attached. */}
|
|
36
83
|
<button
|
|
37
84
|
type="button"
|
|
38
85
|
onClick={() => setOpen(!open)}
|
|
39
86
|
className="lg:hidden px-hsp-sm py-vsp-xs -ml-hsp-sm mr-hsp-sm text-muted hover:text-fg"
|
|
40
87
|
aria-label={open ? "Close sidebar" : "Open sidebar"}
|
|
88
|
+
aria-expanded={open}
|
|
41
89
|
>
|
|
42
|
-
{open
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
{/* X icon — visible only when open */}
|
|
91
|
+
<svg
|
|
92
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
93
|
+
className={clsx("h-icon-lg w-icon-lg", !open && "hidden")}
|
|
94
|
+
aria-hidden="true"
|
|
95
|
+
fill="none"
|
|
96
|
+
viewBox="0 0 24 24"
|
|
97
|
+
stroke="currentColor"
|
|
98
|
+
strokeWidth={2}
|
|
99
|
+
>
|
|
100
|
+
<path
|
|
101
|
+
strokeLinecap="round"
|
|
102
|
+
strokeLinejoin="round"
|
|
103
|
+
d="M6 18L18 6M6 6l12 12"
|
|
104
|
+
/>
|
|
105
|
+
</svg>
|
|
106
|
+
{/* Hamburger icon — visible only when closed */}
|
|
107
|
+
<svg
|
|
108
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
109
|
+
className={clsx("h-icon-lg w-icon-lg", open && "hidden")}
|
|
110
|
+
aria-hidden="true"
|
|
111
|
+
fill="none"
|
|
112
|
+
viewBox="0 0 24 24"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeWidth={2}
|
|
115
|
+
>
|
|
116
|
+
<path
|
|
117
|
+
strokeLinecap="round"
|
|
118
|
+
strokeLinejoin="round"
|
|
119
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
120
|
+
/>
|
|
121
|
+
</svg>
|
|
73
122
|
</button>
|
|
74
123
|
|
|
75
|
-
{/* Backdrop overlay - mobile only
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
124
|
+
{/* Backdrop overlay - mobile only.
|
|
125
|
+
Rendered unconditionally; CSS `hidden` toggles visibility so
|
|
126
|
+
the SSR DOM tree matches the hydrated tree (no subtree
|
|
127
|
+
mount/unmount across the hydration boundary). */}
|
|
128
|
+
<div
|
|
129
|
+
className={clsx("fixed inset-0 z-30 bg-overlay/30 lg:hidden", !open && "hidden")}
|
|
130
|
+
aria-hidden={!open}
|
|
131
|
+
onClick={() => setOpen(false)}
|
|
132
|
+
/>
|
|
82
133
|
|
|
83
134
|
{/* Sidebar panel - mobile only (desktop sidebar is in doc-layout) */}
|
|
84
135
|
<aside
|
|
@@ -90,7 +141,14 @@ export default function SidebarToggle({ children }: SidebarToggleProps) {
|
|
|
90
141
|
`}
|
|
91
142
|
>
|
|
92
143
|
<div className="flex-1 overflow-y-auto">
|
|
93
|
-
|
|
144
|
+
<SidebarTree
|
|
145
|
+
nodes={nodes}
|
|
146
|
+
currentSlug={currentSlug}
|
|
147
|
+
rootMenuItems={rootMenuItems}
|
|
148
|
+
backToMenuLabel={backToMenuLabel}
|
|
149
|
+
localeLinks={localeLinks}
|
|
150
|
+
themeDefaultMode={themeDefaultMode}
|
|
151
|
+
/>
|
|
94
152
|
</div>
|
|
95
153
|
</aside>
|
|
96
154
|
</>
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Use preact hook entrypoints directly — the "react" → "preact/compat" alias
|
|
4
|
+
// lets us consume React-typed components in this Preact app (configured
|
|
5
|
+
// project-wide). Same pattern as packages/zudo-doc/src/theme/theme-toggle.tsx.
|
|
6
|
+
import { useState, useEffect } from "preact/hooks";
|
|
2
7
|
|
|
3
8
|
const STORAGE_KEY = "zudo-doc-theme";
|
|
4
9
|
|
|
@@ -91,3 +96,12 @@ export default function ThemeToggle({ defaultMode = "dark" }: ThemeToggleProps)
|
|
|
91
96
|
</button>
|
|
92
97
|
);
|
|
93
98
|
}
|
|
99
|
+
// Pin the island marker name to "ThemeToggle" regardless of esbuild's
|
|
100
|
+
// identifier deduplication. Both this host component and the v2 package's
|
|
101
|
+
// ThemeToggleInner share the plain name "ThemeToggle"; when both land in the
|
|
102
|
+
// same SSR bundle esbuild renames one to "ThemeToggle2", making
|
|
103
|
+
// captureComponentName() emit "ThemeToggle2" — a name that has no entry in
|
|
104
|
+
// the island manifest. Setting displayName explicitly ensures Island() reads
|
|
105
|
+
// the attribute-level name (displayName is preferred over .name) and emits
|
|
106
|
+
// the correct data-zfb-island="ThemeToggle" marker. zudolab/zudo-doc#1446.
|
|
107
|
+
ThemeToggle.displayName = "ThemeToggle";
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
@import "tailwindcss/preflight";
|
|
2
2
|
@import "tailwindcss/utilities";
|
|
3
3
|
|
|
4
|
+
/* ========================================
|
|
5
|
+
* @takazudo/zudo-doc package safelist
|
|
6
|
+
*
|
|
7
|
+
* The package ships a build-generated safelist at `dist/safelist.css`
|
|
8
|
+
* (zudolab/zudo-doc#1993). Importing it via the module resolver is
|
|
9
|
+
* reliable across all consumer environments — same code path as the
|
|
10
|
+
* existing `@import "@takazudo/zdtp/styles.css"` below.
|
|
11
|
+
*
|
|
12
|
+
* Why not @source the package dist/ glob? The old approach
|
|
13
|
+
* (`@source "../../node_modules/@takazudo/zudo-doc/dist/**"`) was
|
|
14
|
+
* unreliable: pnpm stores packages in a content-addressable store and
|
|
15
|
+
* surfaces them to node_modules via symlinks. Tailwind v4's file
|
|
16
|
+
* scanner does not reliably traverse those symlinks, so arbitrary-value
|
|
17
|
+
* candidates (e.g. `w-[var(--zd-sidebar-w)]`, `h-[calc(100vh-3.5rem)]`)
|
|
18
|
+
* were intermittently dropped across rebuilds (zudolab/zudo-doc#1971,
|
|
19
|
+
* #1989). The package-generated `@source inline()` in dist/safelist.css
|
|
20
|
+
* contains the full set, extracted at package build time — no drift, no
|
|
21
|
+
* symlink traversal, auto-syncs on package upgrade (zudolab/zudo-doc#1994).
|
|
22
|
+
* ======================================== */
|
|
23
|
+
|
|
24
|
+
@import "@takazudo/zudo-doc/safelist.css";
|
|
25
|
+
|
|
4
26
|
/* ========================================
|
|
5
27
|
* Tailwind v4 content sources
|
|
6
28
|
*
|
|
@@ -8,16 +30,10 @@
|
|
|
8
30
|
* scanner falls back to explicit @source directives for class detection.
|
|
9
31
|
* Builds that run outside a git checkout (E2E fixtures, CI containers
|
|
10
32
|
* that copy the source tree without history, etc.) hit this fallback,
|
|
11
|
-
* and zfb's default content roots only cover `pages/`. Components
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `inset-0`, `top-[3.5rem]`) live outside pages/, so without these
|
|
16
|
-
* directives Tailwind drops every utility class those modules emit and
|
|
17
|
-
* the dist stylesheet collapses — breaking mobile-sidebar visibility,
|
|
18
|
-
* desktop-sidebar reveal, and mobile-toc accordion at non-git build
|
|
19
|
-
* targets. Keep these in sync with `src/styles/global.css` in the host
|
|
20
|
-
* repo (zudolab/zudo-doc#1355 wave 13 topic 2; pages/ added in #1444).
|
|
33
|
+
* and zfb's default content roots only cover `pages/`. Components live
|
|
34
|
+
* outside pages/, so these directives ensure the consumer's own code is
|
|
35
|
+
* always scanned for utility classes. The package's utilities are now
|
|
36
|
+
* covered by the @import above — these directives cover consumer code only.
|
|
21
37
|
* ======================================== */
|
|
22
38
|
|
|
23
39
|
@source "src/components/**/*.{tsx,ts,jsx,js,mdx,md}";
|
|
@@ -114,7 +130,8 @@
|
|
|
114
130
|
--spacing-hsp-xl: 1.5rem; /* 24px — generous padding */
|
|
115
131
|
--spacing-hsp-2xl: 2rem; /* 32px — large padding */
|
|
116
132
|
|
|
117
|
-
/* Vertical spacing (
|
|
133
|
+
/* Vertical spacing (8 steps) */
|
|
134
|
+
--spacing-vsp-3xs: 0.25rem; /* 4px — hairline gap */
|
|
118
135
|
--spacing-vsp-2xs: 0.4375rem; /* 7px — tight gap */
|
|
119
136
|
--spacing-vsp-xs: 0.875rem; /* 14px — small gap */
|
|
120
137
|
--spacing-vsp-sm: 1.25rem; /* 20px — compact gap */
|
|
@@ -134,6 +151,16 @@
|
|
|
134
151
|
/* Image overlay button chrome */
|
|
135
152
|
--spacing-image-overlay-inset: 0.5rem; /* 8px — overlay button corner inset + internal padding */
|
|
136
153
|
|
|
154
|
+
/* ========================================
|
|
155
|
+
* Elevation — the tight `@import "tailwindcss/utilities"` (no default
|
|
156
|
+
* @theme) drops Tailwind's default shadow scale, so `shadow-*` utilities
|
|
157
|
+
* generate nothing. Re-add the one elevation the components use: header
|
|
158
|
+
* and version-switcher dropdown panels apply `shadow-lg`. Value is
|
|
159
|
+
* Tailwind v4's default shadow-lg; the black is intentionally
|
|
160
|
+
* theme-independent (a drop shadow is absence of light, not a themed color).
|
|
161
|
+
* ======================================== */
|
|
162
|
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
163
|
+
|
|
137
164
|
/* ========================================
|
|
138
165
|
* Typography
|
|
139
166
|
* ======================================== */
|
|
@@ -33,6 +33,13 @@ export interface DocsEntry {
|
|
|
33
33
|
standalone?: boolean;
|
|
34
34
|
slug?: string;
|
|
35
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";
|
|
36
43
|
};
|
|
37
44
|
rendered?: RenderedContent;
|
|
38
45
|
filePath?: string;
|
|
@@ -70,8 +70,17 @@ function navTreeCacheKey(
|
|
|
70
70
|
: "_";
|
|
71
71
|
return `${lang}:${metaKey}:${docs
|
|
72
72
|
.map((d) => {
|
|
73
|
-
const {
|
|
74
|
-
|
|
73
|
+
const {
|
|
74
|
+
sidebar_position,
|
|
75
|
+
sidebar_label,
|
|
76
|
+
title,
|
|
77
|
+
description,
|
|
78
|
+
unlisted,
|
|
79
|
+
standalone,
|
|
80
|
+
slug,
|
|
81
|
+
category_no_page,
|
|
82
|
+
category_sort_order,
|
|
83
|
+
} = d.data;
|
|
75
84
|
return JSON.stringify([
|
|
76
85
|
d.id,
|
|
77
86
|
sidebar_position,
|
|
@@ -81,6 +90,8 @@ function navTreeCacheKey(
|
|
|
81
90
|
unlisted,
|
|
82
91
|
standalone,
|
|
83
92
|
slug,
|
|
93
|
+
category_no_page,
|
|
94
|
+
category_sort_order,
|
|
84
95
|
]);
|
|
85
96
|
})
|
|
86
97
|
.sort()
|
|
@@ -197,7 +208,9 @@ function toNavNodes(
|
|
|
197
208
|
for (const child of parent.children.values()) {
|
|
198
209
|
const doc = child.doc;
|
|
199
210
|
const meta = categoryMeta?.get(child.fullPath);
|
|
200
|
-
|
|
211
|
+
// Frontmatter wins over the `_category_.json` sidecar when both exist.
|
|
212
|
+
const noPage = doc?.data.category_no_page ?? meta?.noPage;
|
|
213
|
+
const sortOrder = doc?.data.category_sort_order ?? meta?.sortOrder ?? "asc";
|
|
201
214
|
const children = toNavNodes(child, lang, categoryMeta, sortOrder);
|
|
202
215
|
|
|
203
216
|
nodes.push({
|
|
@@ -206,12 +219,16 @@ function toNavNodes(
|
|
|
206
219
|
doc?.data.sidebar_label ?? doc?.data.title ?? meta?.label ?? toTitleCase(child.segment),
|
|
207
220
|
description: doc?.data.description ?? meta?.description,
|
|
208
221
|
position: doc?.data.sidebar_position ?? meta?.position ?? 999,
|
|
209
|
-
href:
|
|
222
|
+
href: noPage
|
|
210
223
|
? undefined
|
|
211
224
|
: doc || children.length > 0
|
|
212
225
|
? docsUrl(child.fullPath, lang)
|
|
213
226
|
: undefined,
|
|
214
|
-
hasPage
|
|
227
|
+
// A `category_no_page` index.mdx is metadata-only — force hasPage false so
|
|
228
|
+
// it matches a `_category_.json` noPage category (no backing file): a
|
|
229
|
+
// non-linked header that flattenTree drops from prev/next. Otherwise the
|
|
230
|
+
// route-excluded slug would surface as a 404 pagination target.
|
|
231
|
+
hasPage: !!doc && noPage !== true,
|
|
215
232
|
children,
|
|
216
233
|
sortOrder,
|
|
217
234
|
});
|
|
@@ -304,6 +321,25 @@ export function findNode(nodes: NavNode[], slug: string): NavNode | undefined {
|
|
|
304
321
|
return undefined;
|
|
305
322
|
}
|
|
306
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Return the href of the first routed descendant (a node with `hasPage` and an
|
|
326
|
+
* `href`), walking children depth-first in order. Returns undefined when the
|
|
327
|
+
* subtree has no routed page.
|
|
328
|
+
*
|
|
329
|
+
* Used by CategoryNav's `categories=` mode to give a `category_no_page` card a
|
|
330
|
+
* real destination: such a category has no route of its own (collectAutoIndexNodes
|
|
331
|
+
* skips noPage nodes), so a card linking to its own slug URL would be a dead
|
|
332
|
+
* link. Linking to the first child page keeps the route surface unchanged.
|
|
333
|
+
*/
|
|
334
|
+
export function firstRoutedHref(node: NavNode): string | undefined {
|
|
335
|
+
for (const child of node.children) {
|
|
336
|
+
if (child.hasPage && child.href) return child.href;
|
|
337
|
+
const nested = firstRoutedHref(child);
|
|
338
|
+
if (nested) return nested;
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
307
343
|
export interface BreadcrumbItem {
|
|
308
344
|
label: string;
|
|
309
345
|
href?: string;
|
|
@@ -91,7 +91,7 @@ describe("generateClaudeResourcesDocs", () => {
|
|
|
91
91
|
expect(fs.existsSync(path.join(docsDir, "claude-agents"))).toBe(true);
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
it("generates
|
|
94
|
+
it("generates index.mdx with category_no_page for sub-categories", () => {
|
|
95
95
|
generateClaudeResourcesDocs({
|
|
96
96
|
claudeDir,
|
|
97
97
|
projectRoot: tmpDir,
|
|
@@ -100,14 +100,14 @@ describe("generateClaudeResourcesDocs", () => {
|
|
|
100
100
|
|
|
101
101
|
const dirs = ["claude-md", "claude-commands", "claude-skills", "claude-agents"];
|
|
102
102
|
for (const dir of dirs) {
|
|
103
|
-
const
|
|
104
|
-
expect(fs.existsSync(
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
expect(
|
|
108
|
-
expect(
|
|
109
|
-
expect(
|
|
110
|
-
expect(
|
|
103
|
+
const indexPath = path.join(docsDir, dir, "index.mdx");
|
|
104
|
+
expect(fs.existsSync(indexPath)).toBe(true);
|
|
105
|
+
|
|
106
|
+
const parsed = matter(fs.readFileSync(indexPath, "utf8"));
|
|
107
|
+
expect(parsed.data).toHaveProperty("title");
|
|
108
|
+
expect(parsed.data).toHaveProperty("sidebar_position");
|
|
109
|
+
expect(parsed.data).toHaveProperty("description");
|
|
110
|
+
expect(parsed.data.category_no_page).toBe(true);
|
|
111
111
|
}
|
|
112
112
|
});
|
|
113
113
|
|
|
@@ -332,7 +332,7 @@ describe("generateClaudeResourcesDocs", () => {
|
|
|
332
332
|
// ---------------------------------------------------------------------------
|
|
333
333
|
|
|
334
334
|
describe("category metadata", () => {
|
|
335
|
-
it("
|
|
335
|
+
it("index.mdx sidebar_position values are ordered correctly", () => {
|
|
336
336
|
generateClaudeResourcesDocs({
|
|
337
337
|
claudeDir,
|
|
338
338
|
projectRoot: tmpDir,
|
|
@@ -340,10 +340,10 @@ describe("generateClaudeResourcesDocs", () => {
|
|
|
340
340
|
});
|
|
341
341
|
|
|
342
342
|
const readPos = (dir: string) => {
|
|
343
|
-
const
|
|
344
|
-
fs.readFileSync(path.join(docsDir, dir, "
|
|
343
|
+
const parsed = matter(
|
|
344
|
+
fs.readFileSync(path.join(docsDir, dir, "index.mdx"), "utf8"),
|
|
345
345
|
);
|
|
346
|
-
return
|
|
346
|
+
return parsed.data.sidebar_position;
|
|
347
347
|
};
|
|
348
348
|
|
|
349
349
|
expect(readPos("claude-md")).toBe(900);
|
|
@@ -351,6 +351,26 @@ describe("generateClaudeResourcesDocs", () => {
|
|
|
351
351
|
expect(readPos("claude-skills")).toBe(902);
|
|
352
352
|
expect(readPos("claude-agents")).toBe(903);
|
|
353
353
|
});
|
|
354
|
+
|
|
355
|
+
it("index.mdx has correct label as title for each sub-category", () => {
|
|
356
|
+
generateClaudeResourcesDocs({
|
|
357
|
+
claudeDir,
|
|
358
|
+
projectRoot: tmpDir,
|
|
359
|
+
docsDir,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const readTitle = (dir: string) => {
|
|
363
|
+
const parsed = matter(
|
|
364
|
+
fs.readFileSync(path.join(docsDir, dir, "index.mdx"), "utf8"),
|
|
365
|
+
);
|
|
366
|
+
return parsed.data.title;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
expect(readTitle("claude-md")).toBe("CLAUDE.md");
|
|
370
|
+
expect(readTitle("claude-commands")).toBe("Commands");
|
|
371
|
+
expect(readTitle("claude-skills")).toBe("Skills");
|
|
372
|
+
expect(readTitle("claude-agents")).toBe("Agents");
|
|
373
|
+
});
|
|
354
374
|
});
|
|
355
375
|
|
|
356
376
|
// ---------------------------------------------------------------------------
|
|
@@ -373,4 +393,143 @@ describe("generateClaudeResourcesDocs", () => {
|
|
|
373
393
|
});
|
|
374
394
|
});
|
|
375
395
|
});
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Slug collision detection tests
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
describe("slug collision detection", () => {
|
|
402
|
+
it("throws when two CLAUDE.md paths produce the same slug", () => {
|
|
403
|
+
// foo/bar/CLAUDE.md → slug "foo--bar"
|
|
404
|
+
// foo--bar/CLAUDE.md → slug "foo--bar" (collision)
|
|
405
|
+
fs.mkdirSync(path.join(tmpDir, "foo", "bar"), { recursive: true });
|
|
406
|
+
fs.writeFileSync(
|
|
407
|
+
path.join(tmpDir, "foo", "bar", "CLAUDE.md"),
|
|
408
|
+
"# foo/bar instructions",
|
|
409
|
+
);
|
|
410
|
+
fs.mkdirSync(path.join(tmpDir, "foo--bar"), { recursive: true });
|
|
411
|
+
fs.writeFileSync(
|
|
412
|
+
path.join(tmpDir, "foo--bar", "CLAUDE.md"),
|
|
413
|
+
"# foo--bar instructions",
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
expect(() =>
|
|
417
|
+
generateClaudeResourcesDocs({
|
|
418
|
+
claudeDir,
|
|
419
|
+
projectRoot: tmpDir,
|
|
420
|
+
docsDir,
|
|
421
|
+
}),
|
|
422
|
+
).toThrow(/slug collision/);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("names both colliding source paths in the error", () => {
|
|
426
|
+
fs.mkdirSync(path.join(tmpDir, "foo", "bar"), { recursive: true });
|
|
427
|
+
fs.writeFileSync(
|
|
428
|
+
path.join(tmpDir, "foo", "bar", "CLAUDE.md"),
|
|
429
|
+
"# foo/bar instructions",
|
|
430
|
+
);
|
|
431
|
+
fs.mkdirSync(path.join(tmpDir, "foo--bar"), { recursive: true });
|
|
432
|
+
fs.writeFileSync(
|
|
433
|
+
path.join(tmpDir, "foo--bar", "CLAUDE.md"),
|
|
434
|
+
"# foo--bar instructions",
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
let caughtMessage = "";
|
|
438
|
+
try {
|
|
439
|
+
generateClaudeResourcesDocs({
|
|
440
|
+
claudeDir,
|
|
441
|
+
projectRoot: tmpDir,
|
|
442
|
+
docsDir,
|
|
443
|
+
});
|
|
444
|
+
} catch (e) {
|
|
445
|
+
caughtMessage = (e as Error).message;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
expect(caughtMessage).toContain("foo--bar");
|
|
449
|
+
// Both source paths must appear in the message
|
|
450
|
+
expect(caughtMessage).toMatch(/foo.bar.CLAUDE\.md/);
|
|
451
|
+
expect(caughtMessage).toMatch(/foo--bar.CLAUDE\.md/);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("does not throw for a clean tree (no collisions)", () => {
|
|
455
|
+
// The default fixture has only root/CLAUDE.md — no collision
|
|
456
|
+
expect(() =>
|
|
457
|
+
generateClaudeResourcesDocs({
|
|
458
|
+
claudeDir,
|
|
459
|
+
projectRoot: tmpDir,
|
|
460
|
+
docsDir,
|
|
461
|
+
}),
|
|
462
|
+
).not.toThrow();
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// Reserved "index" slug guard tests
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
describe("reserved index slug guard", () => {
|
|
471
|
+
it("throws when a CLAUDE.md directory maps to the reserved index slug", () => {
|
|
472
|
+
// index/CLAUDE.md → slug "index" — reserved for category index.mdx
|
|
473
|
+
fs.mkdirSync(path.join(tmpDir, "index"), { recursive: true });
|
|
474
|
+
fs.writeFileSync(
|
|
475
|
+
path.join(tmpDir, "index", "CLAUDE.md"),
|
|
476
|
+
"# index dir instructions",
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
expect(() =>
|
|
480
|
+
generateClaudeResourcesDocs({
|
|
481
|
+
claudeDir,
|
|
482
|
+
projectRoot: tmpDir,
|
|
483
|
+
docsDir,
|
|
484
|
+
}),
|
|
485
|
+
).toThrow(/reserved slug "index"/);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("throws when a command file is named index.md", () => {
|
|
489
|
+
fs.writeFileSync(
|
|
490
|
+
path.join(claudeDir, "commands", "index.md"),
|
|
491
|
+
'---\ndescription: "Index command"\n---\n\nIndex body.',
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
expect(() =>
|
|
495
|
+
generateClaudeResourcesDocs({
|
|
496
|
+
claudeDir,
|
|
497
|
+
projectRoot: tmpDir,
|
|
498
|
+
docsDir,
|
|
499
|
+
}),
|
|
500
|
+
).toThrow(/reserved name "index"/);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("throws when a skill directory is named index", () => {
|
|
504
|
+
const indexSkillDir = path.join(claudeDir, "skills", "index");
|
|
505
|
+
fs.mkdirSync(indexSkillDir, { recursive: true });
|
|
506
|
+
fs.writeFileSync(
|
|
507
|
+
path.join(indexSkillDir, "SKILL.md"),
|
|
508
|
+
'---\nname: index\ndescription: "Index skill"\n---\n\nIndex skill body.',
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
expect(() =>
|
|
512
|
+
generateClaudeResourcesDocs({
|
|
513
|
+
claudeDir,
|
|
514
|
+
projectRoot: tmpDir,
|
|
515
|
+
docsDir,
|
|
516
|
+
}),
|
|
517
|
+
).toThrow(/reserved name "index"/);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("throws when an agent file is named index.md", () => {
|
|
521
|
+
fs.writeFileSync(
|
|
522
|
+
path.join(claudeDir, "agents", "index.md"),
|
|
523
|
+
'---\nname: index-agent\ndescription: "Index agent"\nmodel: sonnet\n---\n\nIndex agent body.',
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
expect(() =>
|
|
527
|
+
generateClaudeResourcesDocs({
|
|
528
|
+
claudeDir,
|
|
529
|
+
projectRoot: tmpDir,
|
|
530
|
+
docsDir,
|
|
531
|
+
}),
|
|
532
|
+
).toThrow(/reserved name "index"/);
|
|
533
|
+
});
|
|
534
|
+
});
|
|
376
535
|
});
|
package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts
CHANGED
|
@@ -76,19 +76,21 @@ function listFiles(dir: string): string[] {
|
|
|
76
76
|
.sort();
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function
|
|
79
|
+
function writeCategoryIndex(
|
|
80
80
|
outputDir: string,
|
|
81
81
|
label: string,
|
|
82
82
|
position: number,
|
|
83
83
|
description: string,
|
|
84
|
-
noPage = true,
|
|
85
84
|
) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
85
|
+
const mdx = `---
|
|
86
|
+
title: "${escapeTitle(label)}"
|
|
87
|
+
description: "${escapeTitle(description)}"
|
|
88
|
+
sidebar_position: ${position}
|
|
89
|
+
category_no_page: true
|
|
90
|
+
generated: true
|
|
91
|
+
---
|
|
92
|
+
`;
|
|
93
|
+
fs.writeFileSync(path.join(outputDir, "index.mdx"), mdx);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
// ---------------------------------------------------------------------------
|
|
@@ -174,6 +176,11 @@ function generateClaudemdDocs(
|
|
|
174
176
|
|
|
175
177
|
const emittedSlugs = new Map<string, string>();
|
|
176
178
|
items.forEach((item, index) => {
|
|
179
|
+
if (item.slug === "index") {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`claude-resources: "${item.relPath}" maps to the reserved slug "index", which is used for the category metadata file. Rename the directory to resolve the conflict.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
177
184
|
const previous = emittedSlugs.get(item.slug);
|
|
178
185
|
if (previous !== undefined) {
|
|
179
186
|
throw new Error(
|
|
@@ -197,7 +204,7 @@ ${escapeForMdx(content.trim())}
|
|
|
197
204
|
fs.writeFileSync(path.join(outputDir, `${item.slug}.mdx`), mdx);
|
|
198
205
|
});
|
|
199
206
|
|
|
200
|
-
|
|
207
|
+
writeCategoryIndex(outputDir, "CLAUDE.md", 900, "Project-specific instructions");
|
|
201
208
|
return items;
|
|
202
209
|
}
|
|
203
210
|
|
|
@@ -225,6 +232,11 @@ function generateCommandsDocs(config: ClaudeResourcesConfig): CommandItem[] {
|
|
|
225
232
|
if (!parsed) continue;
|
|
226
233
|
|
|
227
234
|
const name = file.replace(/\.md$/, "");
|
|
235
|
+
if (name === "index") {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`claude-resources: ".claude/commands/index.md" uses the reserved name "index", which is used for the category metadata file. Rename the command file to resolve the conflict.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
228
240
|
const description = (parsed.data.description as string) || "";
|
|
229
241
|
|
|
230
242
|
items.push({ name, description });
|
|
@@ -243,7 +255,7 @@ ${escapeForMdx(parsed.content.trim())}
|
|
|
243
255
|
|
|
244
256
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
245
257
|
|
|
246
|
-
|
|
258
|
+
writeCategoryIndex(outputDir, "Commands", 901, "Custom slash commands");
|
|
247
259
|
return items;
|
|
248
260
|
}
|
|
249
261
|
|
|
@@ -345,6 +357,11 @@ function generateSkillsDocs(config: ClaudeResourcesConfig): SkillItem[] {
|
|
|
345
357
|
const items: SkillItem[] = [];
|
|
346
358
|
|
|
347
359
|
for (const dir of dirs) {
|
|
360
|
+
if (dir === "index") {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`claude-resources: skill directory ".claude/skills/index/" uses the reserved name "index", which is used for the category metadata file. Rename the skill directory to resolve the conflict.`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
348
365
|
const content = fs.readFileSync(
|
|
349
366
|
path.join(skillsDir, dir, "SKILL.md"),
|
|
350
367
|
"utf8",
|
|
@@ -474,7 +491,7 @@ ${escapeForMdx(ref.content.trim())}
|
|
|
474
491
|
|
|
475
492
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
476
493
|
|
|
477
|
-
|
|
494
|
+
writeCategoryIndex(outputDir, "Skills", 902, "Skill packages");
|
|
478
495
|
return items;
|
|
479
496
|
}
|
|
480
497
|
|
|
@@ -505,6 +522,11 @@ function generateAgentsDocs(config: ClaudeResourcesConfig): AgentItem[] {
|
|
|
505
522
|
const description = (parsed.data.description as string) || "";
|
|
506
523
|
const model = (parsed.data.model as string) || "";
|
|
507
524
|
const fileSlug = file.replace(/\.md$/, "");
|
|
525
|
+
if (fileSlug === "index") {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`claude-resources: ".claude/agents/index.md" uses the reserved name "index", which is used for the category metadata file. Rename the agent file to resolve the conflict.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
508
530
|
|
|
509
531
|
items.push({ name, file: fileSlug, description, model });
|
|
510
532
|
|
|
@@ -525,7 +547,7 @@ ${escapeForMdx(parsed.content.trim())}
|
|
|
525
547
|
|
|
526
548
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
527
549
|
|
|
528
|
-
|
|
550
|
+
writeCategoryIndex(outputDir, "Agents", 903, "Custom subagents");
|
|
529
551
|
return items;
|
|
530
552
|
}
|
|
531
553
|
|
|
@@ -39,6 +39,7 @@ export const SPACING_TOKENS: readonly TokenDef[] = [
|
|
|
39
39
|
{ id: "hsp-2xl", cssVar: "--spacing-hsp-2xl", label: "hsp-2xl", group: "hsp", default: "2rem", step: 0.025, unit: "rem" },
|
|
40
40
|
|
|
41
41
|
// --- Vertical spacing ---
|
|
42
|
+
{ id: "vsp-3xs", cssVar: "--spacing-vsp-3xs", label: "vsp-3xs", group: "vsp", default: "0.25rem", step: 0.025, unit: "rem" },
|
|
42
43
|
{ id: "vsp-2xs", cssVar: "--spacing-vsp-2xs", label: "vsp-2xs", group: "vsp", default: "0.4375rem", step: 0.025, unit: "rem" },
|
|
43
44
|
{ id: "vsp-xs", cssVar: "--spacing-vsp-xs", label: "vsp-xs", group: "vsp", default: "0.875rem", step: 0.025, unit: "rem" },
|
|
44
45
|
{ id: "vsp-sm", cssVar: "--spacing-vsp-sm", label: "vsp-sm", group: "vsp", default: "1.25rem", step: 0.025, unit: "rem" },
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
// directly. Honours `SKIP_DOC_HISTORY=1` via `env: process.env`.
|
|
10
10
|
//
|
|
11
11
|
// postBuild — invokes `runDocHistoryPostBuild` to write
|
|
12
|
-
// `<outDir>/doc-history/<slug>.json` files.
|
|
13
|
-
//
|
|
12
|
+
// `<outDir>/doc-history/<slug>.json` files. Skipped by default
|
|
13
|
+
// on local builds (opt in with `GEN_DOC_HISTORY=1`); always runs
|
|
14
|
+
// in CI; `SKIP_DOC_HISTORY=1` suppresses it everywhere. The
|
|
15
|
+
// gating lives in `shouldGeneratePostBuild` (#1986).
|
|
14
16
|
//
|
|
15
17
|
// devMiddleware — reverse-proxies `/doc-history/*` requests to the
|
|
16
18
|
// standalone `@takazudo/zudo-doc-history-server` on port 4322.
|
|
@@ -48,11 +48,17 @@ export function paths(): Array<{
|
|
|
48
48
|
}> = [];
|
|
49
49
|
|
|
50
50
|
for (const locale of Object.keys(settings.locales)) {
|
|
51
|
-
const { docs } = mergeLocaleDocs({
|
|
51
|
+
const { docs: mergedDocs } = mergeLocaleDocs({
|
|
52
52
|
baseDocs: loadDocs("docs").filter((d) => !d.data.draft),
|
|
53
53
|
localeDocs: loadDocs(`docs-${locale}`).filter((d) => !d.data.draft),
|
|
54
54
|
applyDefaultLocaleOnlyFilter: true,
|
|
55
55
|
});
|
|
56
|
+
// category_no_page index files build no route — drop them AFTER the merge
|
|
57
|
+
// so a locale override carrying the flag first wins the merge (suppressing
|
|
58
|
+
// the base doc); pre-merge filtering would drop it from localeSlugSet and
|
|
59
|
+
// the unflagged base doc would resurface as a card linking to a locale
|
|
60
|
+
// route the docs route never builds.
|
|
61
|
+
const docs = mergedDocs.filter((d) => !d.data.category_no_page);
|
|
56
62
|
const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
|
|
57
63
|
|
|
58
64
|
for (const [tag, tagInfo] of tagMap.entries()) {
|
|
@@ -53,11 +53,17 @@ export default function LocaleTagsIndexPage({
|
|
|
53
53
|
const { locale } = params;
|
|
54
54
|
const pageTitle = t("doc.allTags", locale);
|
|
55
55
|
|
|
56
|
-
const { docs } = mergeLocaleDocs({
|
|
56
|
+
const { docs: mergedDocs } = mergeLocaleDocs({
|
|
57
57
|
baseDocs: loadDocs("docs").filter((d) => !d.data.draft),
|
|
58
58
|
localeDocs: loadDocs(`docs-${locale}`).filter((d) => !d.data.draft),
|
|
59
59
|
applyDefaultLocaleOnlyFilter: true,
|
|
60
60
|
});
|
|
61
|
+
// category_no_page index files build no route — drop them AFTER the merge
|
|
62
|
+
// so a locale override carrying the flag first wins the merge (suppressing
|
|
63
|
+
// the base doc); pre-merge filtering would drop it from localeSlugSet and
|
|
64
|
+
// the unflagged base doc would resurface as a card linking to a locale
|
|
65
|
+
// route the docs route never builds.
|
|
66
|
+
const docs = mergedDocs.filter((d) => !d.data.category_no_page);
|
|
61
67
|
const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
|
|
62
68
|
|
|
63
69
|
const labels: TagNavLabels = {
|
|
@@ -42,7 +42,12 @@ export function paths(): Array<{
|
|
|
42
42
|
props: { tagInfo: TagInfo };
|
|
43
43
|
}> {
|
|
44
44
|
const allDocs = bridgeDocsEntries(getCollection<ZfbDocsData>("docs"), "docs");
|
|
45
|
-
|
|
45
|
+
// category_no_page index.mdx builds no route — drop it so a tag it carries
|
|
46
|
+
// doesn't render a DocCard linking to a non-existent /docs/<cat>/ page.
|
|
47
|
+
const docs = allDocs.filter(
|
|
48
|
+
(doc) =>
|
|
49
|
+
!doc.data.unlisted && !doc.data.draft && !doc.data.category_no_page,
|
|
50
|
+
);
|
|
46
51
|
const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
|
|
47
52
|
|
|
48
53
|
return [...tagMap.entries()].map(([tag, tagInfo]) => ({
|
|
@@ -39,7 +39,12 @@ export default function DocsTagsIndexPage(): JSX.Element {
|
|
|
39
39
|
const pageTitle = t("doc.allTags", locale);
|
|
40
40
|
|
|
41
41
|
const allDocs = bridgeDocsEntries(getCollection<ZfbDocsData>("docs"), "docs");
|
|
42
|
-
|
|
42
|
+
// category_no_page index.mdx builds no route — drop it so a tag it carries
|
|
43
|
+
// doesn't inflate the tag list with a card linking to a non-existent page.
|
|
44
|
+
const docs = allDocs.filter(
|
|
45
|
+
(doc) =>
|
|
46
|
+
!doc.data.unlisted && !doc.data.draft && !doc.data.category_no_page,
|
|
47
|
+
);
|
|
43
48
|
const tagMap = collectTags(docs, (id, data) => data.slug ?? toRouteSlug(id));
|
|
44
49
|
|
|
45
50
|
const labels: TagNavLabels = {
|
|
@@ -118,6 +118,10 @@ export function paths(): Array<{
|
|
|
118
118
|
|
|
119
119
|
// Regular doc pages
|
|
120
120
|
for (const entry of allDocs) {
|
|
121
|
+
// A `category_no_page` index.mdx is metadata-only — kept in the nav tree
|
|
122
|
+
// for breadcrumbs but emits no route (zfb retains every .mdx as a
|
|
123
|
+
// collection entry, so the skip must be explicit).
|
|
124
|
+
if (entry.data.category_no_page === true) continue;
|
|
121
125
|
// Canonical route slug via the one shared rule (@/utils/slug). `entry.id`
|
|
122
126
|
// is already `toRouteSlug(entry.slug)` (bridgeEntries → stripIndexSuffix →
|
|
123
127
|
// toRouteSlug), so this is identical to the previous `entry.id` form for
|
|
@@ -259,6 +263,7 @@ export default function LocaleDocsPage(props: PageArgs): JSX.Element {
|
|
|
259
263
|
locale={locale}
|
|
260
264
|
entrySlug={props.entry.slug}
|
|
261
265
|
contentDir={contentDir}
|
|
266
|
+
isFallback={isFallback}
|
|
262
267
|
/>
|
|
263
268
|
) : null
|
|
264
269
|
}
|
|
@@ -84,8 +84,10 @@ export default function LocaleIndexPage({ params }: PageArgs): JSX.Element {
|
|
|
84
84
|
const categoryOrder = getCategoryOrder();
|
|
85
85
|
const groupedTree = groupSatelliteNodes(tree, categoryOrder);
|
|
86
86
|
|
|
87
|
+
// Drop category_no_page index files so the count matches the number of tag
|
|
88
|
+
// pages actually built (the tag routes exclude them too).
|
|
87
89
|
const tagCount = collectTags(
|
|
88
|
-
navDocs,
|
|
90
|
+
navDocs.filter((d) => !d.data.category_no_page),
|
|
89
91
|
(id, data) => data.slug ?? toRouteSlug(id),
|
|
90
92
|
).size;
|
|
91
93
|
|
|
@@ -128,6 +128,10 @@ export function paths(): Array<{
|
|
|
128
128
|
|
|
129
129
|
// Regular doc pages
|
|
130
130
|
for (const entry of allDocs) {
|
|
131
|
+
// A `category_no_page` index.mdx is metadata-only — kept in the nav
|
|
132
|
+
// tree for breadcrumbs but emits no route (zfb retains every .mdx as a
|
|
133
|
+
// collection entry, so the skip must be explicit).
|
|
134
|
+
if (entry.data.category_no_page === true) continue;
|
|
131
135
|
const slug = entry.data.slug ?? toRouteSlug(entry.slug);
|
|
132
136
|
const isFallback = fallbackSlugs.has(slug);
|
|
133
137
|
const entryContentDir = isFallback ? version.docsDir : (localeDir ?? version.docsDir);
|
|
@@ -106,6 +106,10 @@ export function paths(): Array<{
|
|
|
106
106
|
|
|
107
107
|
// Regular doc pages
|
|
108
108
|
for (const entry of allDocs) {
|
|
109
|
+
// A `category_no_page` index.mdx is metadata-only — kept in the nav tree
|
|
110
|
+
// for breadcrumbs but emits no route (zfb retains every .mdx as a
|
|
111
|
+
// collection entry, so the skip must be explicit).
|
|
112
|
+
if (entry.data.category_no_page === true) continue;
|
|
109
113
|
const slug = entry.data.slug ?? toRouteSlug(entry.slug);
|
|
110
114
|
const navSection = getNavSectionForSlug(slug);
|
|
111
115
|
const subtree = getNavSubtree(tree, navSection);
|