@takazudo/zudo-doc 7.1.0 → 8.1.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 (49) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +1 -1
  3. package/dist/chrome/derive.js +1 -0
  4. package/dist/compiled.css +25 -2
  5. package/dist/config-assertions/index.d.ts +13 -0
  6. package/dist/config-assertions/index.js +25 -0
  7. package/dist/config.d.ts +21 -1
  8. package/dist/config.js +2 -0
  9. package/dist/content.css +13 -2
  10. package/dist/doclayout/doc-layout.js +5 -1
  11. package/dist/features.css +24 -0
  12. package/dist/header/header.d.ts +9 -0
  13. package/dist/header/header.js +21 -2
  14. package/dist/header-with-defaults/index.d.ts +3 -1
  15. package/dist/header-with-defaults/index.js +1 -0
  16. package/dist/home-page/index.d.ts +1 -1
  17. package/dist/home-page/index.js +3 -2
  18. package/dist/nav-indexing/note-tray-index-parts/index-list.js +2 -2
  19. package/dist/preset.d.ts +4 -1
  20. package/dist/preset.js +3 -14
  21. package/dist/settings-defaults.js +2 -0
  22. package/dist/settings.d.ts +23 -0
  23. package/dist/site-tree-nav/index.d.ts +2 -0
  24. package/dist/site-tree-nav/index.js +2 -0
  25. package/dist/site-tree-nav/normalize-island-props.d.ts +1 -0
  26. package/dist/site-tree-nav/normalize-island-props.js +6 -0
  27. package/dist/site-tree-nav-island/index.d.ts +1 -1
  28. package/dist/site-tree-nav-island/index.js +15 -11
  29. package/dist/theme-packs/drift/pack.css +6 -0
  30. package/dist/theme-packs/foundry/pack.css +6 -0
  31. package/dist/theme-packs/futura-editorial/pack.css +6 -0
  32. package/dist/theme-packs/hollow/pack.css +3 -0
  33. package/dist/theme-packs/ledger/pack.css +3 -0
  34. package/dist/theme-packs/matcha/pack.css +3 -0
  35. package/dist/theme-packs/observatory/pack.css +3 -0
  36. package/dist/theme-packs/sakura/pack.css +3 -0
  37. package/dist/theme-packs/scandi/pack.css +3 -0
  38. package/dist/theme-packs/solar/pack.css +3 -0
  39. package/dist/theme-packs/tidepool/pack.css +3 -0
  40. package/dist/theme-packs/washi/pack.css +3 -0
  41. package/dist/transitions/persisted-focus-settle-bootstrap.d.ts +2 -0
  42. package/dist/transitions/persisted-focus-settle-bootstrap.js +22 -0
  43. package/dist/tree-nav-shared/index.d.ts +2 -0
  44. package/dist/tree-nav-shared/index.js +8 -1
  45. package/eject/header/header.tsx +40 -0
  46. package/eject/site-tree-nav-island/index.tsx +21 -12
  47. package/package.json +12 -16
  48. package/dist/plugins/optional-peers.d.ts +0 -3
  49. package/dist/plugins/optional-peers.js +0 -34
package/CHANGELOG.md CHANGED
@@ -8,6 +8,44 @@ The format is based on Keep a Changelog, and release notes are generated from th
8
8
 
9
9
  No unreleased changes.
10
10
 
11
+ ## [8.1.0] - 2026-10-25
12
+
13
+ ### Features
14
+
15
+ - Add the opt-in `headerBrand` setting (`false | { icon: string }`, default `false`): a decorative square icon painted with the text colour (CSS mask) before the site name in the header. `/`-prefixed paths receive the site `base` once, other URLs are used as-is, and an empty or missing `icon` (or `true`) throws at config resolution. The site name stays the link's accessible name, long names still ellipsize, and theme-pack leading emblems are hidden while an icon is set. (fb3358ca)
16
+ - Add a `data-zd-site-name` attribute to the home hero `<h1>` as a styling hook for the site name. (fb3358ca)
17
+
18
+ ### Bug Fixes
19
+
20
+ - With dynamic page transitions enabled, navigating from a footer link (or any focused link in persisted chrome) now finishes at the top of the destination page on current Chromium builds instead of staying near the footer. A package-owned inline head script settles the browser's queued focus reveal after the persisted DOM move and before the router applies its top, hash or history scroll. Focus, persistence, transitions and scroll restoration are unchanged. Sites that pin inline-script hashes in a Content Security Policy need to add the new script's hash. (524a440f)
21
+ - In `SiteTreeNav`, labels now always start a small gap after the end of their dashed branch. On wide viewports the double-width homepage branches used to run into the label text. (3e6e720f)
22
+
23
+ ### Other Changes
24
+
25
+ - Requires zfb 5.1.2: the `@takazudo/zfb`, `zfb-runtime` and `zfb-md-wasm` peer floors move from `^5.0.1` to `^5.1.2`. zfb 5.1.0 adds an islands build guard: the build now fails if an emitted islands chunk imports the islands entry, because that import would 404 in production. zfb 5.1.1 is a fix release for Windows hosts (bare-package subpath aliases and `pnpm.cmd` spawning) and for projects built from a symlinked path. zfb 5.1.2 ships rebuilt native engine and WASM binaries with no change to the JavaScript API. Chunking is otherwise unchanged. Upgrade the zfb family (and `zfb-adapter-cloudflare` when used) to 5.1.2 together with this release. (6eef9416, 44d7684b)
26
+
27
+ ## [8.0.0] - 2026-10-24
28
+
29
+ ### Breaking Changes
30
+
31
+ - Remove the `@takazudo/zudo-doc/plugins/optional-peers` export. Remove any explicit registration of that workaround plugin; ZFB now handles rejection-handled optional imports directly.
32
+ - Require ZFB 5.0.1 or later in the 5.x line. Upgrade the ZFB engine, runtime, and Markdown WASM packages together. When using document history, upgrade `@takazudo/zudo-doc-history-server` to 8.x as well. Node.js 24 remains the minimum.
33
+
34
+ ### Features
35
+
36
+ - Add `siteTreeNavInitiallyCollapsed` to start exact nested category slugs collapsed in home-page and MDX site trees while preserving links and toggles. (fb4a6e3cf)
37
+
38
+ ### Bug Fixes
39
+
40
+ - Adopt ZFB 5.0.1 to fix lazy island chunks requesting an unpublished `islands.js` asset.
41
+ - Restore prose spacing inside tab panels. (5c2f52b92)
42
+ - Clarify adjacent heading spacing and heading hierarchy across theme packs. (2969bd803)
43
+
44
+ ### Other Changes
45
+
46
+ - Accept Node.js 24 or later without an upper version cap. Node 24 LTS remains the CI-verified line. (d5336a24b)
47
+ - Update the MCP SDK to 1.32.2.
48
+
11
49
  ## [7.1.0] - 2026-10-23
12
50
 
13
51
  ### Bug Fixes
package/README.md CHANGED
@@ -109,7 +109,7 @@ If your project renders **semi-trusted or user-submitted** HTML in a preview, ov
109
109
 
110
110
  **6.0.0** is the coordinated major release of `@takazudo/zudo-doc`,
111
111
  `@takazudo/zudo-doc-history-server` and `create-zudo-doc`. The current workspace
112
- targets the published **zfb 4.7.0** family (peer floor **^4.7.0**).
112
+ targets the published **zfb 5.1.2** family (peer floor **^5.1.2**).
113
113
  [Upstream #4097](https://github.com/Takazudo/zudo-front-builder/issues/4097) is
114
114
  resolved; its strict published 4.3.1 consumer control passed 3/3 with no skips or
115
115
  retries in the [source-bound verification](../../docs/findings/zfb-4.3.1/verification.md).
@@ -322,6 +322,7 @@ function deriveMdxComponents(ctx) {
322
322
  getCategoryOrder: ctx.getCategoryOrder,
323
323
  versionedDocsUrl: ctx.versionedDocsUrl,
324
324
  categoryIgnore: ctx.settings.siteTreeNavIgnore,
325
+ initiallyCollapsedCategorySlugs: ctx.settings.siteTreeNavInitiallyCollapsed,
325
326
  dateFormat: ctx.settings.dateFormat
326
327
  });
327
328
  const NoteTrayIndexWrapper = createNoteTrayIndexWrapper({
package/dist/compiled.css CHANGED
@@ -630,11 +630,15 @@ body {
630
630
  }
631
631
 
632
632
  @layer zd-flow {
633
- .zd-content > :where(* + *) {
633
+ .zd-content > :where(* + *), .zd-content :where(.tab-panel) > :where(* + *) {
634
634
  margin-top: var(--flow-space, var(--spacing-vsp-md));
635
635
  }
636
636
  }
637
637
 
638
+ .zd-content :where(.tab-panel) {
639
+ --flow-space: var(--spacing-vsp-md);
640
+ }
641
+
638
642
  .zd-content :where(h2) {
639
643
  --flow-space: var(--spacing-vsp-2xl);
640
644
  }
@@ -658,11 +662,15 @@ body {
658
662
  --flow-space: var(--spacing-vsp-xs);
659
663
  }
660
664
 
665
+ .zd-content :where(h2) + :where(h3) {
666
+ --flow-space: var(--spacing-vsp-md);
667
+ }
668
+
661
669
  .zd-content :where(h2, h3, h4, h5, h6) + :where(:not(h2, h3, h4, h5, h6)) {
662
670
  --flow-space: var(--spacing-vsp-xs);
663
671
  }
664
672
 
665
- .zd-content > :first-child {
673
+ .zd-content > :first-child, .zd-content :where(.tab-panel) > :first-child {
666
674
  margin-top: 0;
667
675
  }
668
676
 
@@ -1856,6 +1864,21 @@ nav[data-zd-toc], div[data-zd-mobile-toc] {
1856
1864
  font-family: var(--zdc-toc-font, var(--zdc-chrome-font, var(--font-sans)));
1857
1865
  }
1858
1866
 
1867
+ [data-header-brand-icon] {
1868
+ display: inline-block;
1869
+ width: 1.1em;
1870
+ height: 1.1em;
1871
+ vertical-align: -.2em;
1872
+ margin-inline-end: var(--spacing-hsp-sm);
1873
+ background-color: currentColor;
1874
+ -webkit-mask: var(--zd-header-brand-icon) center/contain no-repeat;
1875
+ mask: var(--zd-header-brand-icon) center/contain no-repeat;
1876
+ }
1877
+
1878
+ html[data-theme-pack] [data-header-logo][data-header-brand]:before {
1879
+ content: none;
1880
+ }
1881
+
1859
1882
  button[data-zd-pending] {
1860
1883
  opacity: .7;
1861
1884
  pointer-events: none;
@@ -55,6 +55,19 @@ export interface EmptyStringFaviconOrLogoSubject {
55
55
  * object is not an empty string).
56
56
  */
57
57
  export declare function assertNoEmptyStringFaviconOrLogo(settings: EmptyStringFaviconOrLogoSubject): void;
58
+ /** Structural subset of `Settings` {@link assertValidHeaderBrand} reads. */
59
+ export interface HeaderBrandSubject {
60
+ headerBrand?: unknown;
61
+ }
62
+ /**
63
+ * Validate `headerBrand`: `undefined`, `false`, or `{ icon: <non-empty string> }`.
64
+ *
65
+ * The value is typed `false | HeaderBrandConfig`, but JavaScript callers and
66
+ * casted values can still pass `true`, a bare string, an array, or an object
67
+ * whose `icon` is missing/empty — each of which would render a broken mask in
68
+ * every page header. Fail loudly at config resolution, naming the field.
69
+ */
70
+ export declare function assertValidHeaderBrand(settings: HeaderBrandSubject): void;
58
71
  /** Structural subset of `Settings` the zdtp-bundling helpers read. */
59
72
  export interface ZdtpBundlingSubject {
60
73
  designTokenPanel?: boolean;
@@ -32,6 +32,30 @@ function assertNoEmptyStringFaviconOrLogo(settings) {
32
32
  }
33
33
  }
34
34
  }
35
+ function assertValidHeaderBrand(settings) {
36
+ const value = settings.headerBrand;
37
+ if (value === void 0 || value === false) return;
38
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
39
+ throw new TypeError(
40
+ `Invalid headerBrand ${describeValue(value)}: pass false (or omit it) for no icon, or an object { icon: "/img/icon.svg" }.`
41
+ );
42
+ }
43
+ const icon = value.icon;
44
+ if (typeof icon !== "string" || icon === "") {
45
+ throw new TypeError(
46
+ `Invalid headerBrand.icon ${describeValue(icon)}: must be a non-empty string (a "/"-prefixed path, a data: URL, or an absolute URL).`
47
+ );
48
+ }
49
+ }
50
+ function describeValue(value) {
51
+ if (value === void 0) return "undefined";
52
+ if (Array.isArray(value)) return "(array)";
53
+ try {
54
+ return JSON.stringify(value) ?? String(value);
55
+ } catch {
56
+ return String(value);
57
+ }
58
+ }
35
59
  function resolvesBundleZdtp(settings) {
36
60
  return settings.bundleZdtp ?? settings.designTokenPanel === true;
37
61
  }
@@ -92,6 +116,7 @@ function warnAmbiguousDropdownCategoryMatch(headerNav, logger = console) {
92
116
  export {
93
117
  assertAgentDocsConsistent,
94
118
  assertNoEmptyStringFaviconOrLogo,
119
+ assertValidHeaderBrand,
95
120
  assertValidSearchMaxBodyLength,
96
121
  assertZdtpBundlingConsistent,
97
122
  resolvesBundleZdtp,
package/dist/config.d.ts CHANGED
@@ -70,7 +70,7 @@ import type { ZfbConfig, BundleConfig, WindConfig } from "@takazudo/zfb/config";
70
70
  import type { ZodType } from "zod";
71
71
  import type { DirectiveVocabulary, PresetTranslations, PresetTagVocabularyEntry } from "./preset.js";
72
72
  import type { ColorScheme } from "./color-scheme-utils.js";
73
- import type { Settings, ColorModeConfig, LocaleConfig, MetaTagsConfig, SiteHeadConfig, TagPlacement, TagGovernanceMode, VersionConfig, FooterConfig, HeaderNavItem, HeaderRightItem, HomeConfig, AssetViewerIndexingConfig, FrontmatterPreviewConfig, BodyFootUtilAreaConfig, HtmlPreviewConfig, FaviconConfig, DateFormatSetting } from "./settings.js";
73
+ import type { Settings, ColorModeConfig, LocaleConfig, MetaTagsConfig, SiteHeadConfig, TagPlacement, TagGovernanceMode, VersionConfig, FooterConfig, HeaderNavItem, HeaderRightItem, HomeConfig, AssetViewerIndexingConfig, FrontmatterPreviewConfig, BodyFootUtilAreaConfig, HtmlPreviewConfig, FaviconConfig, HeaderBrandConfig, DateFormatSetting } from "./settings.js";
74
74
  /** The `settings.claudeResources` block (or `false` when disabled). */
75
75
  type ClaudeResourcesConfig = {
76
76
  claudeDir: string;
@@ -132,6 +132,17 @@ export interface ZudoDocConfig {
132
132
  * @default undefined
133
133
  */
134
134
  favicon?: string | FaviconConfig | false;
135
+ /**
136
+ * Header brand icon rendered before the site name in the header logo
137
+ * anchor, as a decorative `currentColor` CSS mask (`aria-hidden`; the
138
+ * anchor's accessible name stays `siteName`). `false` renders the site name
139
+ * alone. A `HeaderBrandConfig` (`{ icon }`) paints `icon`: a value starting
140
+ * with `/` is prefixed with `base`, anything else (`data:`, `https://…`) is
141
+ * used verbatim. `true`, arrays, other non-objects, and a missing,
142
+ * non-string or empty `icon` throw a `TypeError` at config resolution.
143
+ * @default false
144
+ */
145
+ headerBrand?: false | HeaderBrandConfig;
135
146
  /**
136
147
  * Public URL sub-path prefix mounted in front of every absolute asset URL
137
148
  * (e.g. `"/pj/my-site/"`). `"/"` = root-mounted.
@@ -158,6 +169,15 @@ export interface ZudoDocConfig {
158
169
  * @default []
159
170
  */
160
171
  siteTreeNavIgnore?: string[];
172
+ /**
173
+ * Exact full `node.slug` values of categories that start collapsed at any
174
+ * depth in package-owned home pages and `<SiteTreeNav />` / `<SiteTreeNavDemo />`
175
+ * MDX tags. Use slugs such as `"zudo-wind/utilities"`, without `/docs`, a
176
+ * locale prefix, or leading/trailing slashes. Visitors can toggle them normally.
177
+ * Does not affect sidebar expansion.
178
+ * @default []
179
+ */
180
+ siteTreeNavInitiallyCollapsed?: string[];
161
181
  /**
162
182
  * Top-level category slugs moved OUT of the package-owned home-page category
163
183
  * grid on `/` and locale homes and rendered instead as one row of small
package/dist/config.js CHANGED
@@ -6,6 +6,7 @@ import { defaultColorSchemes } from "./color-schemes-defaults/index.js";
6
6
  import { assertNoCommaInVersionSlugs } from "./version-availability/index.js";
7
7
  import {
8
8
  assertNoEmptyStringFaviconOrLogo,
9
+ assertValidHeaderBrand,
9
10
  assertZdtpBundlingConsistent,
10
11
  assertValidSearchMaxBodyLength,
11
12
  assertAgentDocsConsistent
@@ -79,6 +80,7 @@ function zudoDoc(user = {}) {
79
80
  );
80
81
  assertNoCommaInVersionSlugs(settings.versions);
81
82
  assertNoEmptyStringFaviconOrLogo(settings);
83
+ assertValidHeaderBrand(settings);
82
84
  assertZdtpBundlingConsistent(settings);
83
85
  assertValidSearchMaxBodyLength(settings.searchMaxBodyLength);
84
86
  assertAgentDocsConsistent(settings);
package/dist/content.css CHANGED
@@ -74,11 +74,17 @@
74
74
  * layer-independent, so the per-heading flow gaps are unaffected.
75
75
  * See zudolab/zudo-doc#2082 / #2109. */
76
76
  @layer zd-flow {
77
- .zd-content > :where(* + *) {
77
+ .zd-content > :where(* + *),
78
+ .zd-content :where(.tab-panel) > :where(* + *) {
78
79
  margin-top: var(--flow-space, var(--spacing-vsp-md));
79
80
  }
80
81
  }
81
82
 
83
+ /* Tabs can inherit a heading-following gap; start a new prose flow inside. */
84
+ .zd-content :where(.tab-panel) {
85
+ --flow-space: var(--spacing-vsp-md);
86
+ }
87
+
82
88
  /* ── Headings ── */
83
89
 
84
90
  /* Heading flow-space (structural — consumed by flow spacing system) */
@@ -107,13 +113,18 @@
107
113
  --flow-space: var(--spacing-vsp-xs);
108
114
  }
109
115
 
116
+ .zd-content :where(h2) + :where(h3) {
117
+ --flow-space: var(--spacing-vsp-md);
118
+ }
119
+
110
120
  /* Content immediately after a heading: tight gap (replaces heading margin-bottom) */
111
121
  .zd-content :where(h2, h3, h4, h5, h6) + :where(:not(h2, h3, h4, h5, h6)) {
112
122
  --flow-space: var(--spacing-vsp-xs);
113
123
  }
114
124
 
115
125
  /* First child never needs top margin */
116
- .zd-content > :first-child {
126
+ .zd-content > :first-child,
127
+ .zd-content :where(.tab-panel) > :first-child {
117
128
  margin-top: 0;
118
129
  }
119
130
 
@@ -1,6 +1,7 @@
1
1
  import { jsx, jsxs } from "@takazudo/zfb/zudo-react/jsx-runtime";
2
2
  import { ClientRouter } from "@takazudo/zfb-runtime";
3
3
  import { buildChromeTransitionBootstrap } from "../transitions/chrome-transition-bootstrap.js";
4
+ import { buildPersistedFocusSettleBootstrap } from "../transitions/persisted-focus-settle-bootstrap.js";
4
5
  const DESKTOP_SIDEBAR_ID = "desktop-sidebar";
5
6
  function DocLayout(props) {
6
7
  const {
@@ -71,7 +72,10 @@ function DocLayout(props) {
71
72
  );
72
73
  }
73
74
  if (head !== void 0) headChildren.push(head);
74
- if (enableClientRouter !== false) headChildren.push(/* @__PURE__ */ jsx("script", { rawHtml: buildChromeTransitionBootstrap() }));
75
+ if (enableClientRouter !== false) {
76
+ headChildren.push(/* @__PURE__ */ jsx("script", { rawHtml: buildChromeTransitionBootstrap() }));
77
+ headChildren.push(/* @__PURE__ */ jsx("script", { rawHtml: buildPersistedFocusSettleBootstrap() }));
78
+ }
75
79
  return /* @__PURE__ */ jsxs("html", { ...htmlAttrs, children: [
76
80
  /* @__PURE__ */ jsx("head", { children: headChildren }),
77
81
  /* @__PURE__ */ jsxs("body", { class: "min-h-screen antialiased", children: [
package/dist/features.css CHANGED
@@ -1026,6 +1026,30 @@ nav[data-zd-toc], div[data-zd-mobile-toc] {
1026
1026
 
1027
1027
  /* END --zdc-* chrome component tokens */
1028
1028
 
1029
+ /* ======== Header brand icon (settings.headerBrand) ======== */
1030
+
1031
+ /* Decorative icon inside the site-name anchor. Sized in `em` so it follows a
1032
+ * theme pack's logo font-size inside the fixed-height header; painted as a
1033
+ * currentColor mask so it always matches the site-name ink. The anchor stays
1034
+ * a truncating flow box — the icon is an inline-block run-in, not a flex item. */
1035
+ [data-header-brand-icon] {
1036
+ display: inline-block;
1037
+ width: 1.1em;
1038
+ height: 1.1em;
1039
+ vertical-align: -0.2em;
1040
+ margin-inline-end: var(--spacing-hsp-sm);
1041
+ background-color: currentColor;
1042
+ -webkit-mask: var(--zd-header-brand-icon) center/contain no-repeat;
1043
+ mask: var(--zd-header-brand-icon) center/contain no-repeat;
1044
+ }
1045
+
1046
+ /* A brand icon replaces a theme pack's decorative logo emblem rather than
1047
+ * doubling it. Specificity (0,3,2) beats every pack's `[data-header-logo]::before`
1048
+ * rule (0,2,2); `::after` marks are left alone. */
1049
+ html[data-theme-pack] [data-header-logo][data-header-brand]::before {
1050
+ content: none;
1051
+ }
1052
+
1029
1053
  button[data-zd-pending] {
1030
1054
  opacity: 0.7;
1031
1055
  pointer-events: none;
@@ -2,6 +2,7 @@
2
2
  import type { Child } from "@takazudo/zfb/zudo-react";
3
3
  import type { JSX } from "@takazudo/zfb/zudo-react/jsx-runtime";
4
4
  import type { HeaderNavItem, HeaderRightComponentRegistry, HeaderRightItem, Locale } from "./types.js";
5
+ import type { HeaderBrandConfig } from "../settings.js";
5
6
  /**
6
7
  * Boundary helpers the host injects into `<Header>`. These are the URL
7
8
  * builders the legacy header.tsx used to import from `@/utils/base` —
@@ -92,6 +93,14 @@ export interface HeaderProps {
92
93
  persistKey: string;
93
94
  /** Site-name string shown in the logo anchor (host `settings.siteName`). */
94
95
  siteName: string;
96
+ /**
97
+ * Optional brand icon rendered before `siteName` inside the logo anchor
98
+ * (host `settings.headerBrand`). Omitted or `false` renders the anchor
99
+ * exactly as before. The icon is a decorative `currentColor` CSS mask; an
100
+ * `icon` starting with `/` goes through `urlHelpers.withBase()`, anything
101
+ * else is used verbatim.
102
+ */
103
+ headerBrand?: false | HeaderBrandConfig;
95
104
  /** Header-nav items in render order (host `settings.headerNav`). */
96
105
  headerNav: HeaderNavItem[];
97
106
  /**
@@ -32,6 +32,7 @@ function Header(props) {
32
32
  search,
33
33
  persistKey,
34
34
  siteName,
35
+ headerBrand,
35
36
  headerNav,
36
37
  headerRightItems,
37
38
  headerRightComponents,
@@ -48,6 +49,8 @@ function Header(props) {
48
49
  const pathWithoutVersion = stripCurrentVersionPrefix(pathWithoutBase, currentVersion);
49
50
  const matchPath = pathForMatch(pathWithoutVersion, lang, i18n.defaultLocale);
50
51
  const activeNavPath = computeActiveNavPath(headerNav, matchPath);
52
+ const brandIcon = headerBrand && typeof headerBrand.icon === "string" && headerBrand.icon !== "" ? headerBrand.icon : null;
53
+ const brandIconUrl = brandIcon === null ? null : brandIcon.startsWith("/") && !brandIcon.startsWith("//") ? urlHelpers.withBase(brandIcon) : brandIcon;
51
54
  const rightItemDispatch = createRightItemDispatch(headerRightComponents);
52
55
  const rightItems = headerRightItems.map((item, i) => ({
53
56
  item,
@@ -98,14 +101,27 @@ function Header(props) {
98
101
  // (mobile sidebar contents stay accessible to assistive tech
99
102
  // even without the toggle island).
100
103
  /* @__PURE__ */ jsx(SidebarSlotFallback, { children: sidebarSlot }),
101
- /* @__PURE__ */ jsx(
104
+ /* @__PURE__ */ jsxs(
102
105
  "a",
103
106
  {
104
107
  href: urlHelpers.withBase(isNonDefaultLocale ? `/${lang}/` : "/"),
105
108
  class: "min-w-0 truncate text-title font-bold text-fg hover:underline focus:underline",
106
109
  title: siteName,
107
110
  "data-header-logo": true,
108
- children: siteName
111
+ "data-header-brand": brandIconUrl !== null ? true : void 0,
112
+ children: [
113
+ brandIconUrl !== null && // Painted by features.css as a currentColor mask so it follows the
114
+ // site-name ink; decorative, the anchor's name stays the site name.
115
+ /* @__PURE__ */ jsx(
116
+ "span",
117
+ {
118
+ "aria-hidden": "true",
119
+ "data-header-brand-icon": true,
120
+ style: { "--zd-header-brand-icon": cssUrl(brandIconUrl) }
121
+ }
122
+ ),
123
+ siteName
124
+ ]
109
125
  }
110
126
  ),
111
127
  /* @__PURE__ */ jsxs(
@@ -486,6 +502,9 @@ function renderRightItem(item, index, ctx, dispatch) {
486
502
  }
487
503
  return null;
488
504
  }
505
+ function cssUrl(url) {
506
+ return `url("${url.replace(/[\\"]/g, "\\$&").replace(/[\n\r\f]/g, "\\a ")}")`;
507
+ }
489
508
  export {
490
509
  Header
491
510
  };
@@ -1,7 +1,7 @@
1
1
  /** @jsxRuntime automatic */
2
2
  import type { JSX } from "@takazudo/zfb/zudo-react/jsx-runtime";
3
3
  import type { ChromeContext } from "../factory-context/index.js";
4
- import type { Settings } from "../settings.js";
4
+ import type { HeaderBrandConfig, Settings } from "../settings.js";
5
5
  import type { SidebarNavNode } from "../sidebar/types.js";
6
6
  export interface HeaderWithDefaultsProps {
7
7
  /** Active locale; defaults to the configured defaultLocale. */
@@ -44,6 +44,8 @@ export interface HeaderVersionEntry {
44
44
  /** Settings subset read by {@link createHeaderWithDefaults}. */
45
45
  export interface HeaderWithDefaultsSettings {
46
46
  siteName: string;
47
+ /** Optional header brand icon — forwarded verbatim to `<Header>`. */
48
+ headerBrand?: false | HeaderBrandConfig;
47
49
  /** Site base path — fed to the language-switcher SPA re-wire config. */
48
50
  base: string;
49
51
  /** Trailing-slash policy — fed to the language-switcher SPA re-wire config. */
@@ -194,6 +194,7 @@ function createHeaderWithDefaults(ctx) {
194
194
  languageSwitcher,
195
195
  persistKey,
196
196
  siteName: settings.siteName,
197
+ headerBrand: settings.headerBrand,
197
198
  headerNav: settings.headerNav,
198
199
  headerRightItems,
199
200
  headerRightComponents: ctx.hostBindings.headerRightComponents,
@@ -43,7 +43,7 @@ export interface HomePageViewProps {
43
43
  tree: DocNavNode[];
44
44
  /** Ordered category prefixes, passed through to `SiteTreeNav`. */
45
45
  categoryOrder: string[];
46
- /** Root-category slugs that should start collapsed in `SiteTreeNav`. */
46
+ /** Exact full category slugs at any depth that should start collapsed in `SiteTreeNav`. Overrides `settings.siteTreeNavInitiallyCollapsed`, including when `[]`. */
47
47
  initiallyCollapsedCategorySlugs?: string[];
48
48
  /** Unique tag count for the current locale — gates the "Tags" section
49
49
  * together with `settings.docTags`. Part of the documented public
@@ -87,6 +87,7 @@ function createHomePageView(ctx) {
87
87
  wide
88
88
  }) {
89
89
  const prefix = locale === defaultLocale ? "" : `/${locale}`;
90
+ const effectiveInitiallyCollapsedCategorySlugs = initiallyCollapsedCategorySlugs ?? settings.siteTreeNavInitiallyCollapsed;
90
91
  const rawDescription = settings.locales[locale]?.description ?? settings.siteDescription;
91
92
  const description = rawDescription?.trim() ? rawDescription : void 0;
92
93
  const ctaNav = settings.headerNav[0] ?? null;
@@ -173,7 +174,7 @@ function createHomePageView(ctx) {
173
174
  }
174
175
  ) : null,
175
176
  /* @__PURE__ */ jsxs("div", { class: "zd-home-copy min-w-0 lg:flex-1", children: [
176
- /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold mb-vsp-2xs zd-wrap-anywhere", children: settings.siteName }),
177
+ /* @__PURE__ */ jsx("h1", { class: "text-heading font-bold mb-vsp-2xs zd-wrap-anywhere", "data-zd-site-name": true, children: settings.siteName }),
177
178
  /* @__PURE__ */ jsx("p", { class: "text-muted text-small mb-vsp-sm", children: rawDescription }),
178
179
  /* @__PURE__ */ jsx("div", { class: "zd-home-links flex flex-wrap items-center justify-center lg:justify-start gap-hsp-md text-small", children: rowContent })
179
180
  ] })
@@ -193,7 +194,7 @@ function createHomePageView(ctx) {
193
194
  tree,
194
195
  categoryOrder,
195
196
  categoryIgnore: [...categoryIgnore, ...movedNodes.map((n) => n.slug)],
196
- ...initiallyCollapsedCategorySlugs !== void 0 ? { initiallyCollapsedCategorySlugs } : {},
197
+ ...effectiveInitiallyCollapsedCategorySlugs !== void 0 ? { initiallyCollapsedCategorySlugs: effectiveInitiallyCollapsedCategorySlugs } : {},
197
198
  locale,
198
199
  updatedLabel: t("doc.updated", locale),
199
200
  dateFormats: dateFormatsFor(locale)
@@ -5,12 +5,12 @@ function IndexList(props) {
5
5
  const width = rankWidth(props.items);
6
6
  return /* @__PURE__ */ jsx("ol", { class: "zd-list-items-no-margin", children: props.items.map((item) => {
7
7
  const rowClass = item.href ? "relative -mt-px first:mt-0 border-y border-muted hover:z-local-1 hover:border-accent focus-within:z-local-1 focus-within:border-accent" : "relative -mt-px first:mt-0 border-y border-muted";
8
- const rankClass = item.href ? "tabular-nums text-heading leading-none text-muted group-hover:text-fg group-focus-visible:text-fg" : "tabular-nums text-heading leading-none text-muted";
8
+ const rankClass = item.href ? "whitespace-nowrap tabular-nums text-heading leading-none text-muted group-hover:text-fg group-focus-visible:text-fg" : "whitespace-nowrap tabular-nums text-heading leading-none text-muted";
9
9
  const labelClass = item.href ? "font-medium text-fg group-hover:text-accent group-hover:underline group-focus-visible:text-accent group-focus-visible:underline" : "font-medium text-fg";
10
10
  const detailClass = item.href ? "mt-vsp-2xs block text-small text-muted group-hover:text-accent group-hover:underline group-focus-visible:text-accent group-focus-visible:underline" : "mt-vsp-2xs block text-small text-muted";
11
11
  const dateClass = item.href ? "mt-vsp-2xs block text-caption text-muted group-hover:text-accent group-hover:underline group-focus-visible:text-accent group-focus-visible:underline" : "mt-vsp-2xs block text-caption text-muted";
12
12
  const rowContent = /* @__PURE__ */ jsxs(Fragment, { children: [
13
- /* @__PURE__ */ jsx("span", { class: rankClass, style: { width: `${width}ch` }, children: item.rank === void 0 ? "" : String(item.rank).padStart(width, "0") }),
13
+ /* @__PURE__ */ jsx("span", { class: rankClass, style: { "min-width": `${width}ch` }, children: item.rank === void 0 ? "" : String(item.rank).padStart(width, "0") }),
14
14
  /* @__PURE__ */ jsxs("span", { class: "min-w-0", children: [
15
15
  /* @__PURE__ */ jsx("span", { class: labelClass, children: item.label }),
16
16
  item.description && /* @__PURE__ */ jsx("span", { class: detailClass, children: item.description }),
package/dist/preset.d.ts CHANGED
@@ -36,7 +36,7 @@
36
36
  import { z } from "zod";
37
37
  import type { DirectiveSpec, ZfbConfig } from "@takazudo/zfb/config";
38
38
  import type { ColorScheme } from "./color-scheme-utils.js";
39
- import type { AssetViewerIndexingConfig, TagVocabularyEntry, FaviconConfig, HomeConfig } from "./settings.js";
39
+ import type { AssetViewerIndexingConfig, TagVocabularyEntry, FaviconConfig, HeaderBrandConfig, HomeConfig } from "./settings.js";
40
40
  /** A single locale's content directory (`settings.locales[code]`). */
41
41
  export interface PresetLocaleConfig {
42
42
  dir: string;
@@ -109,6 +109,9 @@ export interface PresetSettings {
109
109
  /** Favicon links. Same rationale as {@link PresetSettings.logo} — carried
110
110
  * only for `assertNoEmptyStringFaviconOrLogo` (#3474). */
111
111
  favicon?: string | FaviconConfig | false;
112
+ /** Header brand icon. Same rationale as {@link PresetSettings.logo} — carried
113
+ * only for `assertValidHeaderBrand`; rendering happens in the header. */
114
+ headerBrand?: false | HeaderBrandConfig;
112
115
  /** GitHub repository URL used by asset backlink metadata. */
113
116
  githubUrl?: string | false;
114
117
  siteUrl: string;
package/dist/preset.js CHANGED
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { assertNoCommaInVersionSlugs } from "./version-availability/index.js";
3
3
  import {
4
4
  assertNoEmptyStringFaviconOrLogo,
5
+ assertValidHeaderBrand,
5
6
  assertZdtpBundlingConsistent,
6
7
  assertValidSearchMaxBodyLength,
7
8
  assertAgentDocsConsistent,
@@ -19,6 +20,7 @@ function zudoDocPreset({
19
20
  }) {
20
21
  assertNoCommaInVersionSlugs(settings.versions);
21
22
  assertNoEmptyStringFaviconOrLogo(settings);
23
+ assertValidHeaderBrand(settings);
22
24
  assertZdtpBundlingConsistent(settings);
23
25
  assertValidSearchMaxBodyLength(settings.searchMaxBodyLength);
24
26
  assertAgentDocsConsistent(settings);
@@ -130,16 +132,6 @@ function buildMarkdownFeatures(settings, directiveVocabulary) {
130
132
  headingIds: { strategy: "hierarchical" }
131
133
  };
132
134
  }
133
- function optionalPeerShadows(settings) {
134
- const peers = [
135
- ["katex", Boolean(settings.math)],
136
- ["diff", Boolean(settings.docHistory)]
137
- ];
138
- return {
139
- shadow: peers.filter(([, on]) => !on).map(([name]) => name),
140
- shadowIfMissing: peers.filter(([, on]) => on).map(([name]) => name)
141
- };
142
- }
143
135
  function buildPlugins(settings, routeContext) {
144
136
  const localeArray = Object.entries(settings.locales).map(([code, locale]) => ({
145
137
  code,
@@ -347,10 +339,7 @@ function buildPlugins(settings, routeContext) {
347
339
  // `design-token-panel-bootstrap` (#4261). Preset-level rather than inside
348
340
  // the routes plugin because the bootstrap is reachable from chrome even
349
341
  // when packageOwnedRoutes is off.
350
- ...resolvesBundleZdtp(settings) ? [] : [{ name: "@takazudo/zudo-doc/plugins/zdtp-loader", options: {} }],
351
- // Absent or disabled katex/diff → throwing shadows, so they cannot fail a
352
- // Rolldown build (Takazudo/zudo-front-builder#4429).
353
- { name: "@takazudo/zudo-doc/plugins/optional-peers", options: optionalPeerShadows(settings) }
342
+ ...resolvesBundleZdtp(settings) ? [] : [{ name: "@takazudo/zudo-doc/plugins/zdtp-loader", options: {} }]
354
343
  ];
355
344
  }
356
345
  export {
@@ -10,10 +10,12 @@ const DEFAULT_SETTINGS = {
10
10
  siteDescription: "",
11
11
  logo: "auto",
12
12
  favicon: void 0,
13
+ headerBrand: false,
13
14
  base: "/",
14
15
  trailingSlash: false,
15
16
  home: { wide: false, introMarkdown: "", sitemapHeading: "" },
16
17
  siteTreeNavIgnore: [],
18
+ siteTreeNavInitiallyCollapsed: [],
17
19
  siteTreeNavSecondary: [],
18
20
  minifyHtml: true,
19
21
  docsDir: "src/content/docs",
@@ -260,6 +260,19 @@ export interface FaviconConfig {
260
260
  png16?: string;
261
261
  ico?: string;
262
262
  }
263
+ /**
264
+ * Opt-in header brand icon rendered before the site name in the header logo
265
+ * anchor. The icon is painted as a `currentColor` CSS mask, so it follows the
266
+ * site-name ink in every color scheme and theme pack; it is decorative
267
+ * (`aria-hidden`) and the anchor's accessible name stays the site name.
268
+ *
269
+ * `icon` values starting with `/` are passed through `withBase()`; anything
270
+ * else (`data:`, `https://…`) is used verbatim. The value must be a non-empty
271
+ * string — anything else throws a `TypeError` at config resolution.
272
+ */
273
+ export interface HeaderBrandConfig {
274
+ icon: string;
275
+ }
263
276
  /**
264
277
  * Site-wide custom `<head>` extras injected into every page via
265
278
  * {@link HeadWithDefaults}. All fields are JSON-serializable (no VNodes or
@@ -341,12 +354,22 @@ export interface Settings {
341
354
  * resolves to the current document — pass `false` or omit instead (#3471).
342
355
  */
343
356
  favicon?: string | FaviconConfig | false;
357
+ /**
358
+ * Header brand icon shown before the site name in the header logo anchor.
359
+ * Omitted or `false` (the default) renders the site name alone, exactly as
360
+ * before; a {@link HeaderBrandConfig} renders its `icon` as a decorative
361
+ * `currentColor` mask. Invalid values (`true`, a non-object, an array, a
362
+ * missing/non-string/empty `icon`) throw a `TypeError` at config resolution.
363
+ */
364
+ headerBrand?: false | HeaderBrandConfig;
344
365
  base: string;
345
366
  trailingSlash: boolean;
346
367
  /** Package-owned home-page layout. Narrow when omitted. */
347
368
  home?: HomeConfig;
348
369
  /** Top-level category slugs hidden from the package home-page grid and the `<SiteTreeNav />` MDX tag. Nothing hidden when omitted. */
349
370
  siteTreeNavIgnore?: string[];
371
+ /** Exact full category slugs to start collapsed at any depth in home and MDX SiteTreeNav. Defaults to `[]`. */
372
+ siteTreeNavInitiallyCollapsed?: string[];
350
373
  /** Top-level category slugs moved from the package home-page grid into the secondary link row under it. Nothing moved when omitted. */
351
374
  siteTreeNavSecondary?: string[];
352
375
  /** Minify production HTML output from `zfb build`. Defaults to `true` when omitted. */
@@ -82,6 +82,8 @@ export interface SiteTreeNavDeps {
82
82
  * package chrome passes `settings.siteTreeNavIgnore`.
83
83
  */
84
84
  categoryIgnore?: string[];
85
+ /** Exact full category slugs to start collapsed at any depth. Optional for existing factory callers. */
86
+ initiallyCollapsedCategorySlugs?: string[];
85
87
  /**
86
88
  * The raw `dateFormat` setting (`ctx.settings.dateFormat`). Resolved to
87
89
  * per-role patterns for the render locale and forwarded to the island as
@@ -13,6 +13,7 @@ function createSiteTreeNavWrapper(deps) {
13
13
  getCategoryOrder,
14
14
  versionedDocsUrl,
15
15
  categoryIgnore,
16
+ initiallyCollapsedCategorySlugs,
16
17
  dateFormat
17
18
  } = deps;
18
19
  function SiteTreeNavWrapper({
@@ -43,6 +44,7 @@ function createSiteTreeNavWrapper(deps) {
43
44
  tree,
44
45
  categoryOrder,
45
46
  ...categoryIgnore !== void 0 ? { categoryIgnore } : {},
47
+ ...initiallyCollapsedCategorySlugs !== void 0 ? { initiallyCollapsedCategorySlugs } : {},
46
48
  ...ariaLabel !== void 0 ? { ariaLabel } : {},
47
49
  locale,
48
50
  dateFormats: resolveDateFormats(dateFormat, locale)
@@ -4,6 +4,7 @@ export interface SiteTreeNavIslandProps {
4
4
  tree: SidebarNavNode[];
5
5
  categoryOrder: string[];
6
6
  categoryIgnore?: string[];
7
+ initiallyCollapsedCategorySlugs?: string[];
7
8
  ariaLabel?: string;
8
9
  locale: string;
9
10
  dateFormats: ResolvedDateFormats;