@takazudo/zudo-doc 5.5.2 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +39 -0
  3. package/dist/chrome/derive.d.ts +46 -0
  4. package/dist/chrome/derive.js +6 -2
  5. package/dist/config-assertions/index.d.ts +31 -0
  6. package/dist/config-assertions/index.js +24 -0
  7. package/dist/config.d.ts +23 -2
  8. package/dist/config.js +3 -0
  9. package/dist/current-path/index.d.ts +27 -0
  10. package/dist/current-path/index.js +11 -0
  11. package/dist/design-token-panel-bootstrap.d.ts +89 -23
  12. package/dist/design-token-panel-bootstrap.js +19 -6
  13. package/dist/doc-page-props/index.d.ts +5 -67
  14. package/dist/doc-route-entries/index.d.ts +10 -95
  15. package/dist/doc-route-entries/index.js +1 -78
  16. package/dist/doc-route-paths/index.d.ts +1 -1
  17. package/dist/head-with-defaults/index.d.ts +3 -1
  18. package/dist/head-with-defaults/index.js +76 -4
  19. package/dist/header/nav-active.d.ts +28 -0
  20. package/dist/header/nav-active.js +2 -1
  21. package/dist/header/nav-overflow-script.js +30 -17
  22. package/dist/header-with-defaults/index.js +11 -2
  23. package/dist/i18n-version/language-switcher.d.ts +6 -0
  24. package/dist/i18n-version/language-switcher.js +3 -1
  25. package/dist/i18n-version/version-switcher.d.ts +6 -0
  26. package/dist/i18n-version/version-switcher.js +3 -1
  27. package/dist/nav-source-docs/index.d.ts +7 -11
  28. package/dist/plugins/route-pages-candidates.d.ts +19 -0
  29. package/dist/plugins/route-pages-candidates.js +17 -0
  30. package/dist/plugins/routes.d.ts +46 -0
  31. package/dist/plugins/routes.js +72 -18
  32. package/dist/preset.d.ts +12 -1
  33. package/dist/preset.js +2 -0
  34. package/dist/route-context/index.js +2 -2
  35. package/dist/routes/_chrome.d.ts +1 -1
  36. package/dist/routes/_chrome.js +4 -0
  37. package/dist/routes/_context.d.ts +3 -3
  38. package/dist/routes/_design-token-panel-bootstrap.d.ts +18 -0
  39. package/dist/routes/_design-token-panel-bootstrap.js +11 -0
  40. package/dist/routes/_docs-helpers.d.ts +1 -36
  41. package/dist/routes/_docs-helpers.js +0 -138
  42. package/dist/safelist.css +1 -1
  43. package/dist/search-widget-script/generated-script.d.ts +8 -0
  44. package/dist/search-widget-script/generated-script.js +465 -0
  45. package/dist/search-widget-script/index.d.ts +1 -18
  46. package/dist/search-widget-script/index.js +1 -443
  47. package/dist/settings.d.ts +82 -1
  48. package/dist/sidebar-tree/category-meta.d.ts +9 -0
  49. package/dist/sidebar-tree/category-meta.js +21 -12
  50. package/dist/sidebar-tree-island/index.d.ts +8 -1
  51. package/dist/sidebar-tree-island/index.js +16 -14
  52. package/dist/site-schema/doc-route-entries.d.ts +89 -0
  53. package/dist/site-schema/doc-route-entries.js +83 -0
  54. package/dist/site-schema/index.d.ts +17 -0
  55. package/dist/site-schema/index.js +46 -0
  56. package/dist/site-schema/nav-tree.d.ts +28 -0
  57. package/dist/site-schema/nav-tree.js +138 -0
  58. package/dist/site-schema/types.d.ts +97 -0
  59. package/dist/site-schema/types.js +0 -0
  60. package/dist/theme/theme-pack-provider.d.ts +34 -3
  61. package/dist/theme/theme-pack-provider.js +30 -2
  62. package/eject/header/nav-active.ts +13 -1
  63. package/eject/header/nav-overflow-script.ts +30 -17
  64. package/eject/sidebar-tree-island/index.tsx +44 -20
  65. package/package.json +22 -12
  66. package/routes-src/_chrome.tsx +21 -9
  67. package/routes-src/_design-token-panel-bootstrap.tsx +63 -0
  68. package/routes-src/_docs-helpers.ts +18 -225
  69. package/routes-src/_virtual.d.ts +5 -2
  70. package/virtual-modules.d.ts +5 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable changes to `@takazudo/zudo-doc` are documented in this file.
4
4
 
5
5
  The format is based on Keep a Changelog, and release notes are generated from the changelog MDX pages.
6
6
 
7
+ ## [5.6.0] - 2026-08-19
8
+
9
+ ### Features
10
+
11
+ - Added a configurable `favicon` setting. It accepts a plain path string or an object form for multiple slots, with slot types inferred from the file extension rather than hardcoded. (0a8376421, ad6ecb8d4, 5d389b887)
12
+ - Added a browser-safe `@takazudo/zudo-doc/site-schema` subpath so navigation-tree types and helpers can be imported from client code without pulling in Node built-ins. (303cffbc4, 2dde43bcd)
13
+ - Threaded an explicit current-route input through the four active-nav read sites instead of having each one rediscover the path. (9e6461aac, 85aee0625)
14
+ - Added a scaffold pin freshness gate that catches a generated project pinning an unpublished `@takazudo/*` version, and wired it into the publish and prepare flows. (2ba9b5d78, 87eafbddd)
15
+ - The design-token panel now warns when `designTokenPanelConfigModule`'s routes are fully shadowed by host routes. (f846f1d25)
16
+
17
+ ### Bug Fixes
18
+
19
+ - Theme-pack bootstrap no longer uses `document.write` — it appends to `<head>` and arms the anti-FOUC latch only while `document.readyState` is `loading`. (192976c7a, 0007c33b3)
20
+ - A stub page can no longer decide the whole session's design-token panel config; the DTP shadow diagnostic is now scoped to reader-facing routes, gated on `designTokenPanel`, and its file reads are guarded so it can never fail a build. (1f45c135e, 6c807e6bb, e649d957b, 506b1264d, 904f29f20)
21
+ - Empty-string `favicon` and `logo` values are now rejected at config resolution instead of emitting a broken tag. (fbff0f6be)
22
+ - `category-meta` resolves `node:fs` / `node:path` at call time, so importing it from a browser bundle no longer fails. (fd220bfd0)
23
+ - The search-widget inline script is now generated with esbuild `transformSync` and committed as a prebuilt static literal, guarded by a drift gate. (3f55098be, dbfe84092, f40faab3c)
24
+ - The scaffold pin freshness gate reports skipped-with-warning for differing same-core prerelease pins, and no longer has a blind spot when no `next` tag exists. (814a04229, 36dfce205, 86abf4d64)
25
+
26
+ ### Other Changes
27
+
28
+ - Bumped the zfb family to 2.7.1, including `zfb-adapter-cloudflare`, and asserted a collision-free build. (918bffb75, d6be0800e, be7b507ee)
29
+ - Bumped `@takazudo/zdtp` to 0.4.11. (be76be2b8)
30
+ - Added a path-filtered A2 no-stub parity gate to PR checks, plus a chrome eval-graph guard and theme-pack write-time link guard. (c5583f116, 54f4e55ea, e77d992df)
31
+ - Documented the favicon setting, the site-schema subpath, and the theme-pack embedder link-ordering contract in both EN and JA. (417c3b058, 7b36984f1, d1a80cb32)
32
+
33
+ ## [5.5.3] - 2026-08-17
34
+
35
+ ### Bug Fixes
36
+
37
+ - Upgraded the zfb toolchain to 2.5.2, which fixes markdown directive nesting across blank lines, collapsed-run recognition on split shapes, fence-state carryover into the sibling scan, and JSX reconstruction swallowing nested line breaks. (f69f5c7b6)
38
+
39
+ ### Other Changes
40
+
41
+ - Bumped the generated scaffold's zfb pins to 2.5.2 so a fresh project picks up the same fixes. (f69f5c7b6)
42
+
7
43
  ## [5.5.2] - 2026-08-16
8
44
 
9
45
  ### Bug Fixes
package/README.md CHANGED
@@ -17,6 +17,45 @@ This package provides the missing-by-design framework concerns:
17
17
  - **View Transitions** (`./transitions`) — native View Transitions API shim (Chrome/Edge/Safari 18+); persistent regions via `view-transition-name`. No-op fallback in Firefox.
18
18
  - **Head injection** (`./head`) — canonical, og:\*, twitter:\*, robots, preload hints, RSS link, sitemap link, and theme-color output.
19
19
  - **SSR-skip wrappers** (`./ssr-skip`) — `<AiChatModalIsland>`, `<ImageEnlargeIsland>`, `<DesignTokenTweakPanelIsland>`, `<MockInitIsland>` — wrap zfb's `<Island ssrFallback>` with the right fallback markup so doc pages don't have to re-implement the SSR-skip pattern.
20
+ - **Site schema** (`./site-schema`) — browser-safe nav tree / breadcrumb / pager domain, with zero zfb engine or filesystem coupling. See below.
21
+
22
+ ## `./site-schema` — the browser-safe site-shape domain
23
+
24
+ Everything needed to answer "what is the shape of this documentation site?" — which routes exist, how they nest, what the previous/next page is, and what breadcrumb trail leads to a slug — without any of the rendering, disk access, or zfb engine coupling the rest of the package carries. A browser bundle, a Cloudflare Worker, or any non-zfb tool can compute the same answers the SSG build computes.
25
+
26
+ **What it exports.** The route-existence builder `createDocRouteEntries`; the nav-tree builder `buildNavTree`; the blessed breadcrumb builder `buildBreadcrumbs`; the pager resolver `resolveDocPrevNext`; tree-walking helpers (`findNode`, `firstRoutedHref`, `flattenTree`, `flattenSubtree`, `collectAutoIndexNodes`, `groupSatelliteNodes`, `isNavVisible`, `rewriteNavHref`, `remapNavChildHrefs`); the `headerNav` scoping helpers `getCategoryOrder` / `getNavSectionForSlug` / `getNavSubtree`; the underlying `buildSidebarTree` primitive; the TOC helper `extractHeadings`; and the `schemaVersion` contract constant. Every props/route type is generic over the entry shape (defaulting to a structural `DocEntryLike`) so the subpath never needs to import zfb's `CollectionEntry` — see `API.md` for the full function and type reference.
27
+
28
+ **Derivation contracts** — the rules a consumer relies on, not just the function names:
29
+
30
+ - **Route emission.** An entry with `category_no_page: true` carries category metadata only and emits NO route. Every category with children but no `index.mdx` emits one synthesized auto-index route.
31
+ - **The blessed breadcrumb rule.** `buildBreadcrumbs` is THE route-time slug-split walk that produces `props.breadcrumbs` — the same contract the SSG build uses. The presentation-layer `findPath` / `buildBreadcrumbItems` pair stays a component-side detail and is deliberately NOT exported; consumers reconstruct breadcrumbs through this function, not by re-walking the tree themselves.
32
+ - **Category-scoped prev/next.** `resolveDocPrevNext` resolves prev/next against the route's OWN flattened subtree, not the whole site — so a category's last page has no `next` (and its first page has no `prev`). Frontmatter `pagination_prev` / `pagination_next` overrides resolve against that same caller-supplied tree, never a foreign one.
33
+
34
+ **The `schemaVersion` fail-closed contract.** `schemaVersion` (currently `1`) is a contract-version constant, not a feature flag — check it before trusting the shape of anything else the subpath exports, and fail closed (refuse to render, or warn loudly) rather than silently mis-reading a shape change after a package upgrade. The pattern is deliberately the same one `@takazudo/zudo-doc/catalog` already established for its `ThemePacksIndexManifest.schemaVersion`.
35
+
36
+ **Browser-safety guarantees.** Nothing reachable from `./site-schema` — through the bundled JS graph OR the transitive `.d.ts` graph — may be a `node:*` builtin, `preact`, a `.css` file, a `virtual:` module, or an `@takazudo/zfb*` package. Three guards hold that line:
37
+
38
+ 1. `src/__tests__/site-schema.test.ts` bundles the barrel with esbuild `platform: "neutral"` and walks the emitted declaration graph for the same violations.
39
+ 2. `scripts/check-site-schema.mjs` repeats the bundle check against the built `dist/site-schema/index.js` in the `prepack` chain, so a publish cannot ship a graph the source-level guard would have rejected.
40
+ 3. The `package.json#exports` keyset snapshot in `src/__tests__/public-api-snapshot.test.ts` pins the subpath's presence and shape.
41
+
42
+ **Consumer story.** Import `@takazudo/zudo-doc/site-schema` anywhere you need zudo-doc's site semantics without a zfb build behind you — an SPA shell rendering its own nav chrome, a Worker computing breadcrumbs for an API response, or a script that needs to know what page comes next:
43
+
44
+ ```ts
45
+ import {
46
+ schemaVersion,
47
+ buildNavTree,
48
+ buildBreadcrumbs,
49
+ resolveDocPrevNext,
50
+ } from "@takazudo/zudo-doc/site-schema";
51
+
52
+ if (schemaVersion !== 1) {
53
+ throw new Error(`Unsupported @takazudo/zudo-doc/site-schema version: ${schemaVersion}`);
54
+ }
55
+
56
+ const tree = buildNavTree(docs, "en", categoryMeta, buildHref);
57
+ const breadcrumbs = buildBreadcrumbs(tree, "guides/color", homeHref);
58
+ ```
20
59
 
21
60
  ## Optional peer dependency: `@takazudo/zfb-md-wasm`
22
61
 
@@ -45,6 +45,44 @@ export declare function deriveSearchWidgetSlot(ctx: ChromeContext): (props: {
45
45
  loadingIndexText: string;
46
46
  noResultsText: string;
47
47
  }) => JSX.Element;
48
+ /**
49
+ * Whether the package-default `DesignTokenPanelBootstrap` must NOT be used as
50
+ * the slot default (#3414, the fix for #3406's entry-page-dependent latch).
51
+ *
52
+ * `runDesignTokenPanelBootstrapOnce` configures zdtp at most once per browser
53
+ * session and both islands share that latch, so on a host that set
54
+ * `designTokenPanelConfigModule` the FIRST page loaded decides the config for
55
+ * the whole SPA session. A self-contained `pages/` stub (the locked-manifest
56
+ * shape, #2653) calls `createChrome` with no hostBindings and would otherwise
57
+ * mount the package default here — hard-load such a page first and the host's
58
+ * builder never applies to any page, silently.
59
+ *
60
+ * Skipping the slot default on exactly that combination makes the configured
61
+ * island the genuine first caller on every session, whatever the entry page.
62
+ * Two deliberate boundaries:
63
+ *
64
+ * - An explicit `ctx.hostBindings.DesignTokenPanelBootstrap` still wins —
65
+ * `deriveBodyEndIslands` consults this only once that slot came back empty.
66
+ * That covers the injected-route path too, where `routes/_chrome.tsx`
67
+ * supplies `ConfiguredDesignTokenPanelBootstrap` through the same slot.
68
+ * - `packageOwnedRoutes: false` is excluded: without the routes plugin there is
69
+ * no configured island anywhere, and `designTokenPanelConfigModule` is inert
70
+ * by its own contract — skipping there would drop the panel from every page
71
+ * rather than defer it to a better-configured one.
72
+ *
73
+ * The slot resolves to `undefined` rather than a null-rendering stub component:
74
+ * `createDesignTokenPanelIsland` already treats an absent component as "render
75
+ * nothing at all", so no `<Island>` marker and no pre-hydration toggle shim
76
+ * reach the HTML. A stub component would emit a marker under a name zfb's
77
+ * island registry does not carry, which zfb reports as an unmatched marker.
78
+ *
79
+ * Exported because `header-with-defaults` must apply the SAME predicate to the
80
+ * `trigger:design-token-panel` header item: a page whose slot resolved to
81
+ * `undefined` mounts no island AND no pre-hydration toggle shim, so a header
82
+ * toggle button rendered there would dispatch into a document with no listener
83
+ * and no click queue — a dead button. The two gates must stay in lockstep.
84
+ */
85
+ export declare function skipsPackageDefaultDesignTokenPanel(settings: Settings): boolean;
48
86
  /**
49
87
  * Derive the body-end islands. The package owns the settings-gated
50
88
  * DesignTokenPanelBootstrap + ThemePackSwitcher mounts even when a host
@@ -59,6 +97,14 @@ export declare function deriveSearchWidgetSlot(ctx: ChromeContext): (props: {
59
97
  * still replace the DTP component through
60
98
  * `hostBindings.DesignTokenPanelBootstrap`, but the package remains the sole
61
99
  * owner of the mounts and settings gates.
100
+ *
101
+ * Since #3396 the injected package routes use that same slot: `routes/_chrome.tsx`
102
+ * supplies `ConfiguredDesignTokenPanelBootstrap`, which is identical to the
103
+ * default except that its builder comes from
104
+ * `virtual:zudo-doc-design-token-panel-config` (i.e. a host's
105
+ * `designTokenPanelConfigModule`). The default below is what every non-routes
106
+ * `createChrome` caller keeps getting — EXCEPT under the one condition
107
+ * {@link skipsPackageDefaultDesignTokenPanel} describes.
62
108
  */
63
109
  export declare function deriveBodyEndIslands(ctx: ChromeContext): (props: import("../doc-body-end-islands/index.js").BodyEndIslandsProps) => JSX.Element;
64
110
  /** Derive the DocHistory island: `ctx.hostBindings.DocHistory` when supplied,
@@ -168,9 +168,12 @@ function deriveThemePackSwitcherProps(ctx) {
168
168
  base: ctx.withBase("/")
169
169
  };
170
170
  }
171
+ function skipsPackageDefaultDesignTokenPanel(settings) {
172
+ return Boolean(settings.designTokenPanelConfigModule) && settings.packageOwnedRoutes !== false;
173
+ }
171
174
  function deriveBodyEndIslands(ctx) {
172
175
  const designTokenPanelDeps = {
173
- DesignTokenPanelBootstrap: ctx.hostBindings.DesignTokenPanelBootstrap ?? DesignTokenPanelBootstrap
176
+ DesignTokenPanelBootstrap: ctx.hostBindings.DesignTokenPanelBootstrap ?? (skipsPackageDefaultDesignTokenPanel(ctx.settings) ? void 0 : DesignTokenPanelBootstrap)
174
177
  };
175
178
  const themePackSwitcherDeps = {
176
179
  themePackSwitcherProps: deriveThemePackSwitcherProps(ctx),
@@ -311,5 +314,6 @@ export {
311
314
  deriveInlineVersionSwitcher,
312
315
  deriveMdxComponents,
313
316
  deriveNavDataPrep,
314
- deriveSearchWidgetSlot
317
+ deriveSearchWidgetSlot,
318
+ skipsPackageDefaultDesignTokenPanel
315
319
  };
@@ -0,0 +1,31 @@
1
+ import type { FaviconConfig } from "../settings.js";
2
+ /** Structural subset of `Settings` {@link assertNoEmptyStringFaviconOrLogo} reads. */
3
+ export interface EmptyStringFaviconOrLogoSubject {
4
+ logo?: string | false;
5
+ favicon?: string | FaviconConfig | false;
6
+ }
7
+ /**
8
+ * Reject the exact empty string `""` for `logo` and `favicon` — including
9
+ * `favicon`'s object-form slots (`svg`, `ico`, `png32`, `png16`).
10
+ *
11
+ * Per the HTML spec an empty `href` resolves to the CURRENT document, so
12
+ * `favicon: ""` silently emits `<link rel="icon" href="">` — every page
13
+ * tells the browser its own HTML is the favicon, a wasted request per page
14
+ * and a garbage tab icon — and `logo: ""` renders a theme-adaptive CSS mask
15
+ * of an empty path (#3471). `process.env.X ?? ""` is the ordinary way a
16
+ * config value gets wired from the environment, so this is not a contrived
17
+ * shape; failing loudly at config resolution turns a silent per-page waste
18
+ * into a build-time error naming the field, matching this repo's fail-loud
19
+ * habit (`resolveHostModuleOverride` in `plugins/routes.ts` fails at plugin
20
+ * setup rather than falling back silently).
21
+ *
22
+ * Only the EXACT empty string is rejected — whitespace-only (`" "`) does
23
+ * NOT throw. Keep this narrow: #3471's decision covers the one value the
24
+ * HTML spec treats specially, not "falsy-looking" strings in general. Do
25
+ * not tighten this to a `.trim() === ""` check later.
26
+ *
27
+ * No-ops for `false`, `undefined`, `"auto"`, any other non-empty string, and
28
+ * an empty `favicon` OBJECT (`{}`, which validly emits no links — an empty
29
+ * object is not an empty string).
30
+ */
31
+ export declare function assertNoEmptyStringFaviconOrLogo(settings: EmptyStringFaviconOrLogoSubject): void;
@@ -0,0 +1,24 @@
1
+ function assertNoEmptyStringFaviconOrLogo(settings) {
2
+ if (settings.logo === "") {
3
+ throw new TypeError(
4
+ 'Invalid logo "": pass false to hide the logo, or omit the field for the default ("auto").'
5
+ );
6
+ }
7
+ if (settings.favicon === "") {
8
+ throw new TypeError(
9
+ 'Invalid favicon "": pass false for no favicon links, or omit the field for the default four-link set.'
10
+ );
11
+ }
12
+ if (typeof settings.favicon === "object" && settings.favicon !== null) {
13
+ for (const slot of ["svg", "ico", "png32", "png16"]) {
14
+ if (settings.favicon[slot] === "") {
15
+ throw new TypeError(
16
+ `Invalid favicon.${slot} "": omit the slot to fall back to the default, or supply a non-empty path.`
17
+ );
18
+ }
19
+ }
20
+ }
21
+ }
22
+ export {
23
+ assertNoEmptyStringFaviconOrLogo
24
+ };
package/dist/config.d.ts CHANGED
@@ -20,6 +20,9 @@
20
20
  * object (e.g. `colorMode`, `metaTags`) REPLACES the default wholesale; it is
21
21
  * NOT deep-merged key-by-key. This is safe because every nested config type is
22
22
  * all-required-fields, so a caller supplying one supplies all of its fields.
23
+ * ONE exception: `FaviconConfig` is all-optional — wholesale replacement IS its
24
+ * semantics (only the supplied slots emit a `<link rel="icon">`), and its
25
+ * default is `undefined`, so there is nothing for a partial object to clobber.
23
26
  * `zudoDoc()` also supplies the Wave-3 package defaults
24
27
  * (`buildDocsSchema`/`directiveVocabulary`/`translations`/`colorSchemes`/tag
25
28
  * vocabulary) unless overridden, and returns a **complete `ZfbConfig`** — the
@@ -62,7 +65,7 @@ import type { ZfbConfig, BundleConfig } from "@takazudo/zfb/config";
62
65
  import type { ZodType } from "zod";
63
66
  import type { DirectiveVocabulary, PresetTranslations, PresetTagVocabularyEntry } from "./preset.js";
64
67
  import type { ColorScheme } from "./color-scheme-utils.js";
65
- import type { Settings, ColorModeConfig, LocaleConfig, MetaTagsConfig, SiteHeadConfig, TagPlacement, TagGovernanceMode, VersionConfig, FooterConfig, HeaderNavItem, HeaderRightItem, HomeConfig, FrontmatterPreviewConfig, BodyFootUtilAreaConfig, HtmlPreviewConfig } from "./settings.js";
68
+ import type { Settings, ColorModeConfig, LocaleConfig, MetaTagsConfig, SiteHeadConfig, TagPlacement, TagGovernanceMode, VersionConfig, FooterConfig, HeaderNavItem, HeaderRightItem, HomeConfig, FrontmatterPreviewConfig, BodyFootUtilAreaConfig, HtmlPreviewConfig, FaviconConfig } from "./settings.js";
66
69
  /** The `settings.claudeResources` block (or `false` when disabled). */
67
70
  type ClaudeResourcesConfig = {
68
71
  claudeDir: string;
@@ -96,10 +99,28 @@ export interface ZudoDocConfig {
96
99
  * Home-hero logo. `"auto"` renders a generated deterministic SVG seeded by
97
100
  * `siteName` (no asset needed); a path string (e.g. `"/img/logo.svg"`)
98
101
  * renders that asset as a theme-adaptive CSS mask; `false` hides the logo
99
- * block entirely.
102
+ * block entirely. The empty string `""` throws a `TypeError` at config
103
+ * resolution instead of silently rendering a mask of an empty path — pass
104
+ * `false` or omit the field instead (#3471).
100
105
  * @default "auto"
101
106
  */
102
107
  logo?: string | false;
108
+ /**
109
+ * Favicon `<link rel="icon">` set. Omitted keeps the four-file convention
110
+ * (`/favicon.svg`, `/favicon.ico`, `/favicon-32x32.png`,
111
+ * `/favicon-16x16.png` — what `create-zudo-doc` ships in `public/`);
112
+ * `"auto"` emits one inline SVG data-URL icon generated from `siteName`,
113
+ * the same glyph `logo: "auto"` renders; any other string emits a single
114
+ * link with the `type` inferred from its extension; a `FaviconConfig`
115
+ * object emits only the slots it supplies (drop `ico` when your `public/`
116
+ * has no `favicon.ico` and the dead link is never emitted); `false` emits
117
+ * no favicon links at all. The empty string `""` (top-level or any
118
+ * `FaviconConfig` slot) throws a `TypeError` at config resolution instead
119
+ * of silently emitting `<link rel="icon" href="">`, which the HTML spec
120
+ * resolves to the current document — pass `false` or omit instead (#3471).
121
+ * @default undefined
122
+ */
123
+ favicon?: string | FaviconConfig | false;
103
124
  /**
104
125
  * Public URL sub-path prefix mounted in front of every absolute asset URL
105
126
  * (e.g. `"/pj/my-site/"`). `"/"` = root-mounted.
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ import { defaultDirectiveVocabulary } from "./directive-vocabulary-defaults/inde
4
4
  import { defaultTranslations } from "./i18n-defaults/index.js";
5
5
  import { defaultColorSchemes } from "./color-schemes-defaults/index.js";
6
6
  import { assertNoCommaInVersionSlugs } from "./version-availability/index.js";
7
+ import { assertNoEmptyStringFaviconOrLogo } from "./config-assertions/index.js";
7
8
  const DEFAULT_SETTINGS = {
8
9
  colorScheme: "Default Dark",
9
10
  colorMode: {
@@ -15,6 +16,7 @@ const DEFAULT_SETTINGS = {
15
16
  siteName: "Docs",
16
17
  siteDescription: "",
17
18
  logo: "auto",
19
+ favicon: void 0,
18
20
  base: "/",
19
21
  trailingSlash: false,
20
22
  home: { wide: false },
@@ -102,6 +104,7 @@ function zudoDoc(user = {}) {
102
104
  } = user;
103
105
  const settings = { ...DEFAULT_SETTINGS, ...settingsOverrides };
104
106
  assertNoCommaInVersionSlugs(settings.versions);
107
+ assertNoEmptyStringFaviconOrLogo(settings);
105
108
  const fragment = zudoDocPreset({
106
109
  settings,
107
110
  buildDocsSchema: userBuildDocsSchema ?? (() => defaultBuildDocsSchema({
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The `dataset` property name of the current-route override — i.e. the
3
+ * `data-zd-current-path` attribute on `<html>`.
4
+ */
5
+ export declare const CURRENT_PATH_DATASET_KEY = "zdCurrentPath";
6
+ /**
7
+ * Resolve the current route. Read order is explicit input → dataset override →
8
+ * `window.location.pathname`; `||` (not `??`) so an empty string at any step
9
+ * falls through to the next source rather than winning.
10
+ *
11
+ * `datasetKey` is a parameter rather than a closed-over reference to
12
+ * {@link CURRENT_PATH_DATASET_KEY} so the function body stays self-contained
13
+ * when {@link CURRENT_PATH_SCRIPT_PRELUDE} serializes it into a client script.
14
+ *
15
+ * Returns `undefined` only when no source resolves (SSR); callers that treat a
16
+ * route as optional must handle that, and a resolved value may still match no
17
+ * route (e.g. the literal "srcdoc").
18
+ */
19
+ export declare function readCurrentPath(datasetKey: string, explicit?: string): string | undefined;
20
+ /**
21
+ * JS source declaring `readCurrentPath` and `CURRENT_PATH_DATASET_KEY` inside
22
+ * an inline client script, so a string-embedded reader calls
23
+ * `readCurrentPath(CURRENT_PATH_DATASET_KEY)` with the exact same key the
24
+ * TypeScript call sites use. Splice this into the script body before the first
25
+ * call.
26
+ */
27
+ export declare const CURRENT_PATH_SCRIPT_PRELUDE: string;
@@ -0,0 +1,11 @@
1
+ const CURRENT_PATH_DATASET_KEY = "zdCurrentPath";
2
+ function readCurrentPath(datasetKey, explicit) {
3
+ const override = typeof document === "undefined" ? void 0 : document.documentElement.dataset[datasetKey];
4
+ return explicit || override || (typeof window === "undefined" ? void 0 : window.location.pathname);
5
+ }
6
+ const CURRENT_PATH_SCRIPT_PRELUDE = `var CURRENT_PATH_DATASET_KEY=${JSON.stringify(CURRENT_PATH_DATASET_KEY)};var readCurrentPath=${readCurrentPath.toString()};`;
7
+ export {
8
+ CURRENT_PATH_DATASET_KEY,
9
+ CURRENT_PATH_SCRIPT_PRELUDE,
10
+ readCurrentPath
11
+ };
@@ -27,27 +27,56 @@
27
27
  * bootstrapDesignTokenPanel(buildDesignTokenPanelConfig);
28
28
  *
29
29
  * `DesignTokenPanelBootstrap` (below) is the PACKAGE-OWNED island component
30
- * that wires the mode-scoped builder for package-owned routes with no host
31
- * config file — the #2658 "Approach (a)" package default. It resolves its
32
- * builder from the `virtual:zudo-doc-design-token-panel-config` virtual
33
- * module the routes plugin registers (`../plugins/routes.ts`): absent a host
34
- * `designTokenPanelConfigModule` override, that resolves to
35
- * `@takazudo/zudo-doc/design-token-panel-config`'s `buildDesignTokenPanelConfig`.
30
+ * that wires the mode-scoped builder for every `createChrome` consumer with no
31
+ * host config file — the #2658 "Approach (a)" package default. Since #3396 it
32
+ * binds the package-default builder DIRECTLY
33
+ * (`./design-token-panel-config/index.js`), not the routes plugin's
34
+ * `virtual:zudo-doc-design-token-panel-config` module.
36
35
  * `packages/zudo-doc/src/chrome/derive.tsx` statically imports it so zfb's
37
36
  * island scanner walks route → chrome → derive → here (mirrors the DocHistory
38
37
  * #2480 static-import contract). A host that ejects entirely can call
39
- * `bootstrapDesignTokenPanel` with its own builder, but current package-owned
40
- * routes use this component directly.
41
- *
42
- * KNOWN COUPLING: `DesignTokenPanelBootstrap`'s top-level import of
43
- * `virtual:zudo-doc-design-token-panel-config` requires the routes plugin
44
- * (`settings.packageOwnedRoutes`, default `true`) to be active in the
45
- * consuming project, since that plugin is what registers the virtual module.
46
- * A project that explicitly sets `packageOwnedRoutes: false` AND wants to
47
- * reuse the bare `bootstrapDesignTokenPanel` export for its own component is
48
- * unaffected that import graph never reaches `DesignTokenPanelBootstrap`
49
- * but a `packageOwnedRoutes: false` project must not import the package island
50
- * component itself.
38
+ * `bootstrapDesignTokenPanel` with its own builder. Package-owned INJECTED
39
+ * routes do not render this component — they render the configured wrapper
40
+ * described below — but every other `createChrome` caller does.
41
+ *
42
+ * COUPLING REMOVED (#3396, epic #3394): this module no longer imports any
43
+ * `virtual:` specifier, so `@takazudo/zudo-doc/chrome` (which reaches here via
44
+ * `chrome/derive.tsx`) bundles OUTSIDE a zfb build with zero shims, and a
45
+ * `packageOwnedRoutes: false` host needs no alias for the config specifier.
46
+ *
47
+ * WHERE THE HOST OVERRIDE ENTERS NOW: `settings.designTokenPanelConfigModule`
48
+ * still travels through `virtual:zudo-doc-design-token-panel-config`, but the
49
+ * ONLY module that imports that specifier is the routes-only wrapper
50
+ * `routes/_design-token-panel-bootstrap.tsx`. It calls
51
+ * {@link runDesignTokenPanelBootstrapOnce} with the resolved builder and is
52
+ * threaded into `hostBindings.DesignTokenPanelBootstrap` by `routes/_chrome.tsx`,
53
+ * so `deriveBodyEndIslands` renders it instead of the package default on
54
+ * injected routes. The wrapper is a component-level seam INSIDE the client
55
+ * island bundle — deliberately NOT a module-level setter: the route/SSR graph
56
+ * and the hydrated island bundle are separate module graphs, so a setter called
57
+ * during route evaluation would configure only the server instance while the
58
+ * client re-evaluated this module and kept the default.
59
+ *
60
+ * SELF-CONTAINED `pages/` STUBS (was a KNOWN GAP in #3396; closed by #3414).
61
+ * A host that renders docs through a self-contained `pages/` stub (the
62
+ * locked-manifest shape, #2653 — it calls `createChrome` directly rather than
63
+ * going through `routes/_chrome.tsx`) reaches the package default here, so
64
+ * `designTokenPanelConfigModule` does not apply to those pages. Because
65
+ * {@link runDesignTokenPanelBootstrapOnce}'s latch is per SESSION, not per
66
+ * page, that used to make the whole SPA session's panel config depend on which
67
+ * page was hard-loaded first (#3406). Two things now bound it:
68
+ *
69
+ * - `chrome/derive.tsx` SKIPS this package default as the slot default when
70
+ * `settings.designTokenPanelConfigModule` is set and the host supplied no
71
+ * explicit `chromeBindings.DesignTokenPanelBootstrap` (and
72
+ * `packageOwnedRoutes` is on). Stub-rendered pages then mount NO panel island
73
+ * at all, so they can no longer win the latch with the wrong builder — the
74
+ * configured island is the genuine first caller whatever the entry page. The
75
+ * absence on stub pages is deliberate; such a host threads its own builder
76
+ * via `chromeBindings.DesignTokenPanelBootstrap` to get a panel there too.
77
+ * - Any residual default-then-configured race that skip does not close warns at
78
+ * runtime, naming both the setting and that workaround — see
79
+ * {@link runDesignTokenPanelBootstrapOnce}.
51
80
  *
52
81
  * Package-first wiring landed in S9a zudolab/zudo-doc#2333; mode-scoped rebuild
53
82
  * wiring was added in zudolab/zudo-doc#2610 and the package-default island in
@@ -103,20 +132,57 @@ export declare function withPackScopedStoragePrefix(config: PanelConfig, activeP
103
132
  * mode-scoped rebuilds.
104
133
  */
105
134
  export declare function bootstrapDesignTokenPanel(buildConfig: PanelConfigBuilder): void;
135
+ /**
136
+ * Which island won the once-latch below (#3414).
137
+ *
138
+ * - `"default"` — the package-default {@link DesignTokenPanelBootstrap}, bound
139
+ * to the package-default builder.
140
+ * - `"configured"` — the routes-only wrapper
141
+ * (`routes/_design-token-panel-bootstrap.tsx`), bound to whatever
142
+ * `virtual:zudo-doc-design-token-panel-config` resolved to (a host's
143
+ * `settings.designTokenPanelConfigModule` when set, the package default
144
+ * otherwise).
145
+ */
146
+ export type DesignTokenPanelBootstrapOrigin = "default" | "configured";
147
+ /**
148
+ * Run {@link bootstrapDesignTokenPanel} at most once per module instance, with
149
+ * whichever mode-scoped builder the caller supplies.
150
+ *
151
+ * Exists so the routes-only configured wrapper
152
+ * (`routes/_design-token-panel-bootstrap.tsx`, #3396) gets the same one-shot
153
+ * semantics as {@link DesignTokenPanelBootstrap} while supplying the
154
+ * host-overridable builder from `virtual:zudo-doc-design-token-panel-config`.
155
+ * The guard is deliberately shared across both callers: `deriveBodyEndIslands`
156
+ * renders exactly ONE of them, and if a host ever mounted both, configuring the
157
+ * panel twice would re-mount it rather than layer the two configs.
158
+ *
159
+ * `origin` tags WHICH island is calling (#3414). It is optional, defaulting to
160
+ * `"default"`, because this function is reachable from the frozen
161
+ * `./design-token-panel-bootstrap` subpath (API.md) — an added required
162
+ * parameter would break an ejected host that calls it. Both in-package callers
163
+ * pass it explicitly.
164
+ *
165
+ * This is a CALL, not a module-level setter — the builder is passed in at
166
+ * render time inside whichever bundle the island actually hydrates in, so it
167
+ * never has to cross the route-graph → island-bundle boundary.
168
+ */
169
+ export declare function runDesignTokenPanelBootstrapOnce(build: PanelConfigBuilder, origin?: DesignTokenPanelBootstrapOrigin): void;
106
170
  /**
107
171
  * The package-default `<DesignTokenPanelBootstrap/>` island: mounted (via
108
172
  * `Island({ when: "load" })`, no `ssrFallback` — it renders nothing on either
109
173
  * side) by `../doc-body-end-islands/index.tsx` when `settings.designTokenPanel`
110
174
  * is on, gated the same way `AiChatModal`/`ImageEnlarge`/`MermaidEnlarge` are.
111
- * On first render, calls `bootstrapDesignTokenPanel` with the mode-scoped
112
- * `buildDesignTokenPanelConfig` builder resolved from
113
- * `virtual:zudo-doc-design-token-panel-config` (package default, or the
114
- * host's `designTokenPanelConfigModule` override).
175
+ * On first render, calls `bootstrapDesignTokenPanel` with the PACKAGE-DEFAULT
176
+ * mode-scoped `buildDesignTokenPanelConfig` builder. A host's
177
+ * `designTokenPanelConfigModule` override does not reach this component — it
178
+ * arrives through the routes-only wrapper described in the module docblock.
115
179
  *
116
180
  * `displayName` pinned explicitly (belt-and-braces, matching
117
181
  * `AiChatModal`/`ImageEnlarge`/`MermaidEnlarge`/`DocHistory`) so zfb's
118
182
  * `captureComponentName` emits a stable `data-zfb-island="DesignTokenPanelBootstrap"`
119
- * marker independent of minification.
183
+ * marker independent of minification. It MUST keep matching the exported
184
+ * binding identifier — zfb's island scanner registers by the scanner-visible
185
+ * export name and warns when a `displayName` diverges from it.
120
186
  */
121
187
  export declare function DesignTokenPanelBootstrap(): JSX.Element | null;
122
188
  export declare namespace DesignTokenPanelBootstrap {
@@ -8,7 +8,7 @@ import {
8
8
  THEME_PACK_CHANGED_EVENT,
9
9
  readThemePackFromDom
10
10
  } from "./theme-pack-switcher/theme-pack-sync.js";
11
- import { buildDesignTokenPanelConfig } from "virtual:zudo-doc-design-token-panel-config";
11
+ import { buildDesignTokenPanelConfig } from "./design-token-panel-config/index.js";
12
12
  const COLOR_SCHEME_CHANGED_EVENT = "color-scheme-changed";
13
13
  const TOGGLE_PANEL_EVENT = "toggle-design-token-panel";
14
14
  const ZDTP_DEFAULT_STORAGE_PREFIX = "zudo-design-token-panel";
@@ -262,17 +262,30 @@ function bootstrapDesignTokenPanel(buildConfig) {
262
262
  refreshInstanceToggleChannel(readActiveConfig());
263
263
  });
264
264
  }
265
- let bootstrapped = false;
266
- function DesignTokenPanelBootstrap() {
267
- if (!bootstrapped) {
268
- bootstrapped = true;
269
- bootstrapDesignTokenPanel(buildDesignTokenPanelConfig);
265
+ let bootstrapWinner = null;
266
+ let warnedLostRace = false;
267
+ function warnLostBootstrapRace(winnerOrigin, loserOrigin) {
268
+ if (typeof window === "undefined" || warnedLostRace) return;
269
+ warnedLostRace = true;
270
+ const detail = loserOrigin === "configured" && winnerOrigin === "default" ? "The package-default island configured the panel earlier in this browser session, so `designTokenPanelConfigModule` did not apply on this page. This happens when the session first hard-loaded a page rendered by a self-contained `pages/` stub, which reaches the package default instead of the package-injected route. Thread your builder through `chromeBindings.DesignTokenPanelBootstrap` to make it apply on every page regardless of entry point." : `The ${winnerOrigin} island configured the panel first; this ${loserOrigin} island's builder was ignored. zdtp is configured once per session \u2014 mount exactly one design-token-panel bootstrap, e.g. via \`chromeBindings.DesignTokenPanelBootstrap\`.`;
271
+ console.warn(`[zudo-doc] design-token panel already configured. ${detail}`);
272
+ }
273
+ function runDesignTokenPanelBootstrapOnce(build, origin = "default") {
274
+ if (bootstrapWinner) {
275
+ if (bootstrapWinner.build !== build) warnLostBootstrapRace(bootstrapWinner.origin, origin);
276
+ return;
270
277
  }
278
+ bootstrapWinner = { origin, build };
279
+ bootstrapDesignTokenPanel(build);
280
+ }
281
+ function DesignTokenPanelBootstrap() {
282
+ runDesignTokenPanelBootstrapOnce(buildDesignTokenPanelConfig, "default");
271
283
  return null;
272
284
  }
273
285
  DesignTokenPanelBootstrap.displayName = "DesignTokenPanelBootstrap";
274
286
  export {
275
287
  DesignTokenPanelBootstrap,
276
288
  bootstrapDesignTokenPanel,
289
+ runDesignTokenPanelBootstrapOnce,
277
290
  withPackScopedStoragePrefix
278
291
  };
@@ -1,77 +1,15 @@
1
1
  export type { HeadingItem } from "../extract-headings/index.js";
2
- import type { HeadingItem } from "../extract-headings/index.js";
3
- import type { BreadcrumbItem } from "../breadcrumb/types.js";
4
2
  import type { CollectionEntry } from "@takazudo/zfb/content";
5
- /**
6
- * Nav tree node shape as consumed by doc-route pages.
7
- *
8
- * Structurally identical to the host project's `NavNode` from
9
- * `src/utils/docs.ts`. Defined here so the package types do not import
10
- * the host `@/` alias. The host's `NavNode` is a structural subtype and
11
- * assignable wherever `DocNavNode` is expected.
12
- */
13
- export interface DocNavNode {
14
- slug: string;
15
- label: string;
16
- description?: string;
17
- position: number;
18
- href?: string;
19
- hasPage: boolean;
20
- children: DocNavNode[];
21
- sortOrder?: "asc" | "desc";
22
- collapsed?: boolean;
23
- }
24
- /**
25
- * Minimal docs frontmatter shape consumed by the doc-page types.
26
- *
27
- * A structural subset of the host's `DocsData` — only the fields that
28
- * `DocPageEntry` and `DocPageBaseProps` actually read. The full `DocsData`
29
- * (from `src/config/docs-schema.ts`) is a structural supertype and is
30
- * assignable here.
31
- */
32
- export interface DocPageFrontmatter {
33
- title: string;
34
- description?: string;
35
- slug?: string;
36
- draft?: boolean;
37
- unlisted?: boolean;
38
- standalone?: boolean;
39
- sidebar_position?: number;
40
- sidebar_label?: string;
41
- category_no_page?: boolean;
42
- category_sort_order?: "asc" | "desc";
43
- pagination_prev?: string | null;
44
- pagination_next?: string | null;
45
- tags?: string[];
46
- [key: string]: unknown;
47
- }
3
+ import type { AutoIndexNode, DocNavNode, DocPageAutoIndexProps as DocPageAutoIndexPropsBase, DocPageBaseProps as DocPageBasePropsGeneric, DocPageEntryProps as DocPageEntryPropsGeneric, DocPageFrontmatter } from "../site-schema/types.js";
4
+ export type { AutoIndexNode, DocNavNode, DocPageFrontmatter };
48
5
  /**
49
6
  * The native zfb collection entry with docs frontmatter. Route consumers
50
7
  * derive canonical route slugs from `entry.data.slug ?? toRouteSlug(entry.slug)`.
51
8
  */
52
9
  export type DocPageEntry = CollectionEntry<DocPageFrontmatter>;
53
- export interface AutoIndexNode extends DocNavNode {
54
- children: DocNavNode[];
55
- }
56
- /** Shared fields present in every doc-page route. */
57
- interface DocPagePropsBase {
58
- breadcrumbs: BreadcrumbItem[];
59
- prev: DocNavNode | null;
60
- next: DocNavNode | null;
61
- /** Depth-2/3/4 headings extracted from the MDX body, for SSG TOC links. */
62
- headings: HeadingItem[];
63
- }
64
10
  /** Branch: a real content entry. `autoIndex` is absent. */
65
- export interface DocPageEntryProps extends DocPagePropsBase {
66
- kind: "entry";
67
- entry: DocPageEntry;
68
- autoIndex?: undefined;
69
- }
11
+ export type DocPageEntryProps = DocPageEntryPropsGeneric<DocPageEntry>;
70
12
  /** Branch: an auto-generated category index. `entry` is absent. */
71
- export interface DocPageAutoIndexProps extends DocPagePropsBase {
72
- kind: "autoIndex";
73
- autoIndex: AutoIndexNode;
74
- entry?: undefined;
75
- }
13
+ export type DocPageAutoIndexProps = DocPageAutoIndexPropsBase;
76
14
  /** Discriminated union for the `kind` prop. Narrow via `props.kind === "entry"`. */
77
- export type DocPageBaseProps = DocPageEntryProps | DocPageAutoIndexProps;
15
+ export type DocPageBaseProps = DocPageBasePropsGeneric<DocPageEntry>;