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.
Files changed (29) hide show
  1. package/dist/scaffold.js +2 -2
  2. package/dist/zfb-config-gen.js +2 -0
  3. package/package.json +1 -1
  4. package/templates/base/pages/docs/[[...slug]].tsx +5 -0
  5. package/templates/base/pages/index.tsx +3 -1
  6. package/templates/base/pages/lib/_category-nav.tsx +13 -9
  7. package/templates/base/pages/lib/_doc-history-area.tsx +22 -2
  8. package/templates/base/pages/lib/_footer-with-defaults.tsx +9 -3
  9. package/templates/base/pages/lib/_head-with-defaults.tsx +3 -0
  10. package/templates/base/pages/lib/route-enumerators.ts +18 -3
  11. package/templates/base/src/components/client-router-bootstrap.tsx +55 -5
  12. package/templates/base/src/components/sidebar-toggle.tsx +106 -48
  13. package/templates/base/src/components/theme-toggle.tsx +15 -1
  14. package/templates/base/src/config/frontmatter-preview-defaults.ts +2 -0
  15. package/templates/base/src/styles/global.css +38 -11
  16. package/templates/base/src/types/docs-entry.ts +7 -0
  17. package/templates/base/src/utils/docs.ts +41 -5
  18. package/templates/features/claudeResources/files/src/integrations/claude-resources/__tests__/generate.test.ts +172 -13
  19. package/templates/features/claudeResources/files/src/integrations/claude-resources/generate.ts +34 -12
  20. package/templates/features/designTokenPanel/files/src/config/design-tokens-manifest.ts +1 -0
  21. package/templates/features/docHistory/files/plugins/doc-history-plugin.mjs +4 -2
  22. package/templates/features/docTags/files/pages/[locale]/docs/tags/[tag].tsx +7 -1
  23. package/templates/features/docTags/files/pages/[locale]/docs/tags/index.tsx +7 -1
  24. package/templates/features/docTags/files/pages/docs/tags/[tag].tsx +6 -1
  25. package/templates/features/docTags/files/pages/docs/tags/index.tsx +6 -1
  26. package/templates/features/i18n/files/pages/[locale]/docs/[[...slug]].tsx +5 -0
  27. package/templates/features/i18n/files/pages/[locale]/index.tsx +3 -1
  28. package/templates/features/versioning/files/pages/v/[version]/[locale]/docs/[[...slug]].tsx +4 -0
  29. 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.7",
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.7";
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zudo-doc",
3
- "version": "0.2.0-next.7",
3
+ "version": "0.2.0-next.8",
4
4
  "description": "Create a new zudo-doc documentation site",
5
5
  "license": "MIT",
6
6
  "author": "Takeshi Takatsudo",
@@ -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
- * For nodes with noPage=true (no index.mdx), the href falls back to the
43
- * auto-generated category index URL via docsUrl(slug, lang).
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). Nodes with noPage=true
65
- * get their href computed via docsUrl() since auto-index pages exist.
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
- // a card for it. noPage nodes have no href in the tree but their
90
- // auto-generated category index page is reachable via docsUrl().
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 ?? docsUrl(slug, locale);
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
- locale === defaultLocale ? historySlug : `${locale}/${historySlug}`;
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
- const docHistoryLocale = locale === defaultLocale ? undefined : locale;
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
- docs = loadDocs("docs").filter((d) => !d.data.draft && !d.data.unlisted);
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 pages which do the same.
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((d) => !d.data.unlisted && !d.data.draft);
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
- // W6A stub — no-op default export.
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
- // The host installs a tiny client-side router bootstrap that re-runs
4
- // island lifecycle code across zfb navigations. Generated projects ship
5
- // the no-op so unconditional page imports (`pages/lib/_body-end-islands`)
6
- // resolve without dragging the routing bridge into every scaffold.
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
- import { useState, useEffect } from "preact/compat";
2
- import type { ComponentChildren } from "preact";
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
- children: ComponentChildren;
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({ children }: SidebarToggleProps) {
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
- // zfb's `<ViewTransitions />` does a real page load on every
28
- // navigation, so `DOMContentLoaded` is the post-navigate signal.
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
- <svg
44
- xmlns="http://www.w3.org/2000/svg"
45
- className="h-icon-lg w-icon-lg"
46
- fill="none"
47
- viewBox="0 0 24 24"
48
- stroke="currentColor"
49
- strokeWidth={2}
50
- >
51
- <path
52
- strokeLinecap="round"
53
- strokeLinejoin="round"
54
- d="M6 18L18 6M6 6l12 12"
55
- />
56
- </svg>
57
- ) : (
58
- <svg
59
- xmlns="http://www.w3.org/2000/svg"
60
- className="h-icon-lg w-icon-lg"
61
- fill="none"
62
- viewBox="0 0 24 24"
63
- stroke="currentColor"
64
- strokeWidth={2}
65
- >
66
- <path
67
- strokeLinecap="round"
68
- strokeLinejoin="round"
69
- d="M4 6h16M4 12h16M4 18h16"
70
- />
71
- </svg>
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
- {open && (
77
- <div
78
- className="fixed inset-0 z-30 bg-overlay/30 lg:hidden"
79
- onClick={() => setOpen(false)}
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
- {children}
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
- import { useState, useEffect } from "preact/compat";
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";
@@ -21,4 +21,6 @@ export const DEFAULT_FRONTMATTER_IGNORE_KEYS: string[] = [
21
21
  "standalone",
22
22
  "slug",
23
23
  "generated",
24
+ "category_no_page",
25
+ "category_sort_order",
24
26
  ];
@@ -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 and
12
- * shared package code that hold responsive variants (`lg:hidden`,
13
- * `lg:block`, `xl:hidden`), design-token utilities (`h-icon-lg`,
14
- * `bg-bg`, `text-fg`), and absolute-position primitives (`sticky`,
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 (7 steps) */
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 { sidebar_position, sidebar_label, title, description, unlisted, standalone, slug } =
74
- d.data;
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
- const sortOrder = meta?.sortOrder ?? "asc";
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: meta?.noPage
222
+ href: noPage
210
223
  ? undefined
211
224
  : doc || children.length > 0
212
225
  ? docsUrl(child.fullPath, lang)
213
226
  : undefined,
214
- hasPage: !!doc,
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 _category_.json with noPage for sub-categories", () => {
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 catPath = path.join(docsDir, dir, "_category_.json");
104
- expect(fs.existsSync(catPath)).toBe(true);
105
-
106
- const cat = JSON.parse(fs.readFileSync(catPath, "utf8"));
107
- expect(cat).toHaveProperty("label");
108
- expect(cat).toHaveProperty("position");
109
- expect(cat).toHaveProperty("description");
110
- expect(cat.noPage).toBe(true);
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("_category_.json positions are ordered correctly", () => {
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 cat = JSON.parse(
344
- fs.readFileSync(path.join(docsDir, dir, "_category_.json"), "utf8"),
343
+ const parsed = matter(
344
+ fs.readFileSync(path.join(docsDir, dir, "index.mdx"), "utf8"),
345
345
  );
346
- return cat.position;
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
  });
@@ -76,19 +76,21 @@ function listFiles(dir: string): string[] {
76
76
  .sort();
77
77
  }
78
78
 
79
- function writeCategoryMeta(
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 meta: Record<string, unknown> = { label, position, description };
87
- if (noPage) meta.noPage = true;
88
- fs.writeFileSync(
89
- path.join(outputDir, "_category_.json"),
90
- JSON.stringify(meta, null, 2) + "\n",
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
- writeCategoryMeta(outputDir, "CLAUDE.md", 900, "Project-specific instructions");
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
- writeCategoryMeta(outputDir, "Commands", 901, "Custom slash commands");
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
- writeCategoryMeta(outputDir, "Skills", 902, "Skill packages");
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
- writeCategoryMeta(outputDir, "Agents", 903, "Custom subagents");
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. Also honours
13
- // `SKIP_DOC_HISTORY=1` (the runner returns early when set).
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
- const docs = allDocs.filter((doc) => !doc.data.unlisted && !doc.data.draft);
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
- const docs = allDocs.filter((doc) => !doc.data.unlisted && !doc.data.draft);
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);