@takazudo/zudo-doc 5.12.0 → 5.13.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 +27 -0
  2. package/bin/tags-audit-runner.ts +2 -2
  3. package/dist/catalog.d.ts +42 -10
  4. package/dist/catalog.js +66 -2
  5. package/dist/chrome/derive.js +4 -2
  6. package/dist/color-scheme-utils.d.ts +2 -50
  7. package/dist/compiled.css +3622 -0
  8. package/dist/doc-body-end-islands/index.d.ts +6 -0
  9. package/dist/doc-body-end-islands/index.js +2 -1
  10. package/dist/doc-body-end-islands/theme-pack-switcher-island.d.ts +6 -0
  11. package/dist/doc-body-end-islands/theme-pack-switcher-island.js +8 -1
  12. package/dist/factory-context/index.d.ts +3 -35
  13. package/dist/features.css +5 -0
  14. package/dist/header-with-defaults/index.js +1 -1
  15. package/dist/hydration-pending.d.ts +5 -0
  16. package/dist/hydration-pending.js +9 -0
  17. package/dist/plugins/internal/theme-packs/resolve.d.ts +1 -1
  18. package/dist/plugins/internal/theme-packs/resolve.js +2 -4
  19. package/dist/plugins/routes.js +2 -4
  20. package/dist/route-context-payload/index.d.ts +60 -0
  21. package/dist/route-context-payload/index.js +48 -0
  22. package/dist/route-context-payload/types.d.ts +115 -0
  23. package/dist/route-context-payload/types.js +0 -0
  24. package/dist/safelist.css +1 -1
  25. package/dist/settings.d.ts +1 -17
  26. package/dist/sidebar-tree-island/index.js +1 -1
  27. package/dist/tags-audit.d.ts +3 -2
  28. package/dist/tags-audit.js +21 -0
  29. package/dist/theme-cli/registry.d.ts +1 -1
  30. package/dist/theme-cli/registry.js +1 -3
  31. package/dist/theme-pack-switcher/index.d.ts +3 -1
  32. package/dist/theme-pack-switcher/index.js +14 -2
  33. package/dist/theme-packs-registry/build-registry.d.ts +41 -0
  34. package/dist/theme-packs-registry/build-registry.js +62 -0
  35. package/dist/theme-packs-registry/index.d.ts +2 -1
  36. package/dist/theme-packs-registry/index.js +6 -4
  37. package/dist/theme-packs-registry/load-registry.d.ts +2 -13
  38. package/dist/theme-packs-registry/meta-schema.d.ts +1 -21
  39. package/dist/theme-packs-registry/resolve-enabled-packs.d.ts +1 -1
  40. package/dist/theme-packs-registry/types.d.ts +1 -0
  41. package/dist/theme-packs-registry/types.js +0 -0
  42. package/dist/theme-toggle/index.d.ts +3 -1
  43. package/dist/theme-toggle/index.js +7 -1
  44. package/dist/transitions/nested-island-props-refresh.d.ts +3 -1
  45. package/dist/transitions/nested-island-props-refresh.js +89 -13
  46. package/eject/sidebar-tree-island/index.tsx +3 -1
  47. package/eject/theme-toggle/hydration-pending.ts +13 -0
  48. package/eject/theme-toggle/index.tsx +8 -0
  49. package/package.json +19 -12
@@ -1,6 +1,4 @@
1
- import {
2
- loadThemePackRegistry
3
- } from "../theme-packs-registry/index.js";
1
+ import { loadThemePackRegistry } from "../theme-packs-registry/load-registry.js";
4
2
  function defaultThemePacksDir() {
5
3
  return new URL("../theme-packs/", import.meta.url);
6
4
  }
@@ -18,6 +18,8 @@ export interface ThemePackSwitcherProps {
18
18
  /** Base prefix WITH trailing slash (`ctx.withBase("/")`) — forwarded to
19
19
  * the browse-all dialog for its `{base}theme-packs/index.json` fetch. */
20
20
  base: string;
21
+ /** Keep launcher activation pending until the first successful mount. @default true */
22
+ pendingUntilHydrated?: boolean;
21
23
  }
22
24
  /** Props of the browse-all dialog mounted through {@link ThemePackDialogSlot}
23
25
  * — the #2825 contract. The dialog fetches `{base}theme-packs/index.json`
@@ -43,7 +45,7 @@ export type ThemePackDialogComponent = (props: ThemePackDialogProps) => JSX.Elem
43
45
  * `Island({ when: "load" })` — see
44
46
  * `../doc-body-end-islands/theme-pack-switcher-island.tsx`.
45
47
  */
46
- export declare function ThemePackSwitcher({ active, order, base }: ThemePackSwitcherProps): JSX.Element;
48
+ export declare function ThemePackSwitcher({ active, order, base, pendingUntilHydrated, }: ThemePackSwitcherProps): JSX.Element;
47
49
  export declare namespace ThemePackSwitcher {
48
50
  var displayName: string;
49
51
  }
@@ -2,6 +2,7 @@
2
2
  import { jsx, jsxs } from "preact/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from "preact/hooks";
4
4
  import { ChevronLeft, ChevronRight, Close } from "../icons/index.js";
5
+ import { useHydrationPending } from "../hydration-pending.js";
5
6
  import { ThemePackDialog } from "../theme-pack-dialog/index.js";
6
7
  import { applyThemePack } from "./theme-pack-sync.js";
7
8
  import {
@@ -61,7 +62,13 @@ function PaletteIcon({ className }) {
61
62
  }
62
63
  const CONTROL_BUTTON_CLASS = "flex items-center gap-hsp-xs rounded border border-muted bg-bg px-hsp-md py-hsp-2xs text-caption text-fg transition-colors hover:border-accent focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2 disabled:opacity-50 disabled:pointer-events-none";
63
64
  const ICON_BUTTON_CLASS = "flex items-center justify-center rounded p-hsp-2xs text-muted transition-colors hover:text-fg focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2 disabled:opacity-50 disabled:pointer-events-none";
64
- function ThemePackSwitcher({ active, order, base }) {
65
+ function ThemePackSwitcher({
66
+ active,
67
+ order,
68
+ base,
69
+ pendingUntilHydrated = true
70
+ }) {
71
+ const pending = useHydrationPending(pendingUntilHydrated);
65
72
  const [open, setOpen] = useState(false);
66
73
  const [dialogOpen, setDialogOpen] = useState(false);
67
74
  const [activeSlug, setActiveSlug] = useState(active);
@@ -178,9 +185,14 @@ function ThemePackSwitcher({ active, order, base }) {
178
185
  "aria-haspopup": "dialog",
179
186
  "aria-expanded": open,
180
187
  "aria-label": "Theme pack switcher",
188
+ "aria-disabled": pending ? "true" : void 0,
181
189
  title: "Theme packs",
182
190
  "data-switcher-launcher": true,
183
- onClick: () => setOpen(!open),
191
+ "data-zd-pending": pending ? "" : void 0,
192
+ onClick: () => {
193
+ if (pending) return;
194
+ setOpen(!open);
195
+ },
184
196
  class: "flex h-[2.5rem] w-[2.5rem] items-center justify-center rounded-full border border-muted bg-surface text-fg shadow-lg transition-colors hover:text-accent focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2",
185
197
  children: /* @__PURE__ */ jsx(PaletteIcon, { className: "h-icon-md w-icon-md" })
186
198
  }
@@ -0,0 +1,41 @@
1
+ import { type ResolveEnabledPacksSettings } from "./resolve-enabled-packs.js";
2
+ import type { ThemePackMeta, ThemePackRegistry } from "../route-context-payload/types.js";
3
+ /** Contract version for the `./theme-packs-registry` public surface. */
4
+ export declare const schemaVersion = 1;
5
+ /** One entry in the browser-facing catalog v2 manifest. */
6
+ export interface ThemePackCatalogEntry {
7
+ /** The pack directory slug. */
8
+ slug: string;
9
+ /** The schema-validated metadata for this pack. */
10
+ meta: ThemePackMeta;
11
+ /** Whether the pack ships a CSS stylesheet. */
12
+ hasStylesheet: boolean;
13
+ }
14
+ /**
15
+ * The complete catalog manifest accepted by {@link buildThemePackRegistry}.
16
+ *
17
+ * This is intentionally a whole-manifest parameter rather than a bare
18
+ * `packs` array: the catalog schema version is part of the input contract and
19
+ * must be checked before its entries are interpreted.
20
+ */
21
+ export interface ThemePacksCatalogManifest {
22
+ /** Catalog schema version, distinct from each meta.json schema version. */
23
+ schemaVersion: 2;
24
+ packs: ThemePackCatalogEntry[];
25
+ }
26
+ /** The `{ themePack, themePacks }` settings projection used by the resolver. */
27
+ export type ThemePackSettingsProjection = ResolveEnabledPacksSettings;
28
+ /** Alias emphasizing that this is the registry builder's settings input. */
29
+ export type ThemePackRegistrySettings = ThemePackSettingsProjection;
30
+ /**
31
+ * Build the resolved, enabled, ordered theme-pack registry for a browser
32
+ * consumer.
33
+ *
34
+ * The input is the complete catalog v2 manifest and the settings projection
35
+ * `{ themePack, themePacks }`. Catalog schema versions other than `2` are
36
+ * rejected with an error naming the received value. Once the boundary is
37
+ * validated, {@link resolveEnabledPacks} remains the single source of truth
38
+ * for default-first/alphabetical ordering and loud duplicate, unknown, and
39
+ * excluded-active-slug failures.
40
+ */
41
+ export declare function buildThemePackRegistry(catalog: ThemePacksCatalogManifest, settings: ThemePackSettingsProjection): ThemePackRegistry;
@@ -0,0 +1,62 @@
1
+ import { resolveEnabledPacks } from "./resolve-enabled-packs.js";
2
+ const schemaVersion = 1;
3
+ function describeCatalogValue(value) {
4
+ if (value === void 0) return "undefined";
5
+ try {
6
+ const json = JSON.stringify(value);
7
+ return json === void 0 ? String(value) : json;
8
+ } catch {
9
+ return String(value);
10
+ }
11
+ }
12
+ function isRecord(value) {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+ function validateCatalogManifest(value) {
16
+ if (!isRecord(value)) {
17
+ throw new TypeError(
18
+ `@takazudo/zudo-doc/theme-packs-registry: expected a catalog manifest object with schemaVersion 2; got ${describeCatalogValue(value)}.`
19
+ );
20
+ }
21
+ if (value.schemaVersion !== 2) {
22
+ throw new Error(
23
+ `@takazudo/zudo-doc/theme-packs-registry: unsupported catalog schemaVersion ${describeCatalogValue(value.schemaVersion)}; expected 2.`
24
+ );
25
+ }
26
+ if (!Array.isArray(value.packs)) {
27
+ throw new TypeError(
28
+ `@takazudo/zudo-doc/theme-packs-registry: catalog.packs must be an array; got ${describeCatalogValue(value.packs)}.`
29
+ );
30
+ }
31
+ for (const [index, entry] of value.packs.entries()) {
32
+ if (!isRecord(entry)) {
33
+ throw new TypeError(
34
+ `@takazudo/zudo-doc/theme-packs-registry: catalog.packs[${index}] must be an object; got ${describeCatalogValue(entry)}.`
35
+ );
36
+ }
37
+ if (typeof entry.slug !== "string") {
38
+ throw new TypeError(
39
+ `@takazudo/zudo-doc/theme-packs-registry: catalog.packs[${index}].slug must be a string; got ${describeCatalogValue(entry.slug)}.`
40
+ );
41
+ }
42
+ if (!isRecord(entry.meta)) {
43
+ throw new TypeError(
44
+ `@takazudo/zudo-doc/theme-packs-registry: catalog.packs[${index}].meta must be an object; got ${describeCatalogValue(entry.meta)}.`
45
+ );
46
+ }
47
+ if (typeof entry.hasStylesheet !== "boolean") {
48
+ throw new TypeError(
49
+ `@takazudo/zudo-doc/theme-packs-registry: catalog.packs[${index}].hasStylesheet must be a boolean; got ${describeCatalogValue(entry.hasStylesheet)}.`
50
+ );
51
+ }
52
+ }
53
+ return value;
54
+ }
55
+ function buildThemePackRegistry(catalog, settings) {
56
+ const manifest = validateCatalogManifest(catalog);
57
+ return resolveEnabledPacks(manifest.packs, settings);
58
+ }
59
+ export {
60
+ buildThemePackRegistry,
61
+ schemaVersion
62
+ };
@@ -1,5 +1,6 @@
1
1
  export { THEME_PACK_SLUG_RE, DEFAULT_THEME_PACK_SLUG, themePackMetaSchema, type ThemePackMeta, type ThemePackSwatches, } from "./meta-schema.js";
2
2
  export { KNOWN_THEME_PACK_TOKEN_NAMES, COMMERCIAL_FONT_DENYLIST, THEME_PACK_SOFT_BUDGET_BYTES, } from "./token-manifest.js";
3
3
  export { validateThemePack, type ThemePackValidationInput, type ThemePackValidationIssue, type ThemePackValidationResult, type ThemePackValidationSeverity, } from "./validator.js";
4
- export { loadThemePackRegistry, type ThemePackRegistry, type ThemePackRegistryEntry, } from "./load-registry.js";
4
+ export type { ThemePackRegistry, ThemePackRegistryEntry } from "./types.js";
5
5
  export { resolveEnabledPacks, type ResolveEnabledPacksSettings, } from "./resolve-enabled-packs.js";
6
+ export { buildThemePackRegistry, schemaVersion, type ThemePackCatalogEntry, type ThemePackRegistrySettings, type ThemePackSettingsProjection, type ThemePacksCatalogManifest, } from "./build-registry.js";
@@ -11,20 +11,22 @@ import {
11
11
  import {
12
12
  validateThemePack
13
13
  } from "./validator.js";
14
- import {
15
- loadThemePackRegistry
16
- } from "./load-registry.js";
17
14
  import {
18
15
  resolveEnabledPacks
19
16
  } from "./resolve-enabled-packs.js";
17
+ import {
18
+ buildThemePackRegistry,
19
+ schemaVersion
20
+ } from "./build-registry.js";
20
21
  export {
21
22
  COMMERCIAL_FONT_DENYLIST,
22
23
  DEFAULT_THEME_PACK_SLUG,
23
24
  KNOWN_THEME_PACK_TOKEN_NAMES,
24
25
  THEME_PACK_SLUG_RE,
25
26
  THEME_PACK_SOFT_BUDGET_BYTES,
26
- loadThemePackRegistry,
27
+ buildThemePackRegistry,
27
28
  resolveEnabledPacks,
29
+ schemaVersion,
28
30
  themePackMetaSchema,
29
31
  validateThemePack
30
32
  };
@@ -1,16 +1,5 @@
1
- import type { ThemePackMeta } from "./meta-schema.js";
2
- /** One entry in the aggregated, canonically-ordered bundled registry. */
3
- export interface ThemePackRegistryEntry {
4
- /** The pack's directory name (equals `meta.slug`). */
5
- slug: string;
6
- /** The pack's schema-validated `meta.json`. */
7
- meta: ThemePackMeta;
8
- /** Whether this pack ships a `pack.css` (always `false` for `"default"`). */
9
- hasStylesheet: boolean;
10
- }
11
- /** The full bundled registry, or a subset of it (after `resolveEnabledPacks`)
12
- * — an ordered array; order carries meaning (canonical / switcher order). */
13
- export type ThemePackRegistry = ThemePackRegistryEntry[];
1
+ import type { ThemePackRegistry } from "../route-context-payload/types.js";
2
+ export type { ThemePackRegistry, ThemePackRegistryEntry, } from "../route-context-payload/types.js";
14
3
  /**
15
4
  * Scan `dir` (a `theme-packs/` directory — `src/theme-packs/` in the
16
5
  * workspace, `dist/theme-packs/` once shipped) for per-slug pack directories,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ export type { ThemePackMeta, ThemePackSwatches } from "../route-context-payload/types.js";
2
3
  /** `[a-z0-9][a-z0-9-]*` — a pack's slug MUST equal its directory name
3
4
  * (Decision 6.7 "slug regex + dir-name parity"). */
4
5
  export declare const THEME_PACK_SLUG_RE: RegExp;
@@ -6,20 +7,6 @@ export declare const THEME_PACK_SLUG_RE: RegExp;
6
7
  * ONLY (no `pack.css`); the runtime never inserts a stylesheet link for it
7
8
  * (ADR Decision 1, "The reserved `default` pack"). */
8
9
  export declare const DEFAULT_THEME_PACK_SLUG = "default";
9
- declare const themePackSwatchesSchema: z.ZodObject<{
10
- bg: z.ZodString;
11
- fg: z.ZodString;
12
- accent: z.ZodString;
13
- syntax: z.ZodObject<{
14
- keyword: z.ZodString;
15
- string: z.ZodString;
16
- comment: z.ZodString;
17
- callable: z.ZodString;
18
- }, z.core.$strip>;
19
- }, z.core.$strip>;
20
- /** Preview swatches for the dialog grid — RESOLVED plain CSS colors, one set
21
- * per mode. See {@link themePackMetaSchema}'s `preview` field. */
22
- export type ThemePackSwatches = z.infer<typeof themePackSwatchesSchema>;
23
10
  export declare const themePackMetaSchema: z.ZodObject<{
24
11
  schemaVersion: z.ZodLiteral<1>;
25
12
  slug: z.ZodString;
@@ -61,10 +48,3 @@ export declare const themePackMetaSchema: z.ZodObject<{
61
48
  }, z.core.$strip>;
62
49
  }, z.core.$strip>;
63
50
  }, z.core.$strip>;
64
- /**
65
- * A pack's `meta.json`, schema-validated (ADR Decision 1). `mode` is a
66
- * designed-primary badge only — every pack still defines BOTH modes via
67
- * `light-dark()` in `pack.css`.
68
- */
69
- export type ThemePackMeta = z.infer<typeof themePackMetaSchema>;
70
- export {};
@@ -1,4 +1,4 @@
1
- import type { ThemePackRegistry } from "./load-registry.js";
1
+ import type { ThemePackRegistry } from "./types.js";
2
2
  /** The subset of settings this resolver reads. */
3
3
  export interface ResolveEnabledPacksSettings {
4
4
  /** Active pack slug — must end up a member of the resolved list. */
@@ -0,0 +1 @@
1
+ export type { ThemePackRegistry, ThemePackRegistryEntry, } from "../route-context-payload/types.js";
File without changes
@@ -1,8 +1,10 @@
1
1
  import { type ColorSchemeMode } from "./color-scheme-sync.js";
2
2
  export interface ThemeToggleProps {
3
3
  defaultMode?: ColorSchemeMode;
4
+ /** Keep activation pending until the first successful mount. @default true */
5
+ pendingUntilHydrated?: boolean;
4
6
  }
5
- export declare function ThemeToggle({ defaultMode, }: ThemeToggleProps): import("preact").JSX.Element;
7
+ export declare function ThemeToggle({ defaultMode, pendingUntilHydrated, }: ThemeToggleProps): import("preact").JSX.Element;
6
8
  export declare namespace ThemeToggle {
7
9
  var displayName: string;
8
10
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "preact/jsx-runtime";
3
3
  import { useState, useEffect } from "preact/hooks";
4
+ import { useHydrationPending } from "../hydration-pending.js";
4
5
  import {
5
6
  applyColorScheme,
6
7
  readColorSchemeFromDom,
@@ -53,8 +54,10 @@ function MoonIcon() {
53
54
  );
54
55
  }
55
56
  function ThemeToggle({
56
- defaultMode = "dark"
57
+ defaultMode = "dark",
58
+ pendingUntilHydrated = true
57
59
  }) {
60
+ const pending = useHydrationPending(pendingUntilHydrated);
58
61
  const [mode, setMode] = useState(defaultMode);
59
62
  useEffect(() => {
60
63
  const sync = () => setMode(readColorSchemeFromDom(defaultMode));
@@ -62,6 +65,7 @@ function ThemeToggle({
62
65
  return subscribeColorSchemeChanged(sync);
63
66
  }, []);
64
67
  function toggle() {
68
+ if (pending) return;
65
69
  const next = mode === "dark" ? "light" : "dark";
66
70
  setMode(next);
67
71
  applyColorScheme(next);
@@ -72,6 +76,8 @@ function ThemeToggle({
72
76
  {
73
77
  onClick: toggle,
74
78
  "aria-label": `Switch to ${nextMode} mode`,
79
+ "aria-disabled": pending ? "true" : void 0,
80
+ "data-zd-pending": pending ? "" : void 0,
75
81
  className: "text-muted hover:text-fg transition-colors p-hsp-sm focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2",
76
82
  children: mode === "dark" ? /* @__PURE__ */ jsx(SunIcon, {}) : /* @__PURE__ */ jsx(MoonIcon, {})
77
83
  }
@@ -1,5 +1,7 @@
1
1
  interface NestedIslandPropsRefreshOptions {
2
2
  document: Document;
3
+ /** Explicit error seam for hosts/tests; omitted errors surface in a microtask. */
4
+ reportError?: (error: unknown) => void;
3
5
  }
4
6
  /**
5
7
  * Install the before-swap props-refresh listener on a document.
@@ -8,7 +10,7 @@ interface NestedIslandPropsRefreshOptions {
8
10
  * non-singleton hosts. Browser modules use the durable, duplicate-safe
9
11
  * `ensureNestedIslandPropsRefresh` wrapper below instead.
10
12
  */
11
- export declare function installNestedIslandPropsRefresh({ document, }: NestedIslandPropsRefreshOptions): () => void;
13
+ export declare function installNestedIslandPropsRefresh({ document, reportError, }: NestedIslandPropsRefreshOptions): () => void;
12
14
  /**
13
15
  * Install exactly one document-lifetime controller for a browser document.
14
16
  * Repeated calls from component renders or duplicate boot paths are no-ops.
@@ -7,12 +7,47 @@ const ISLAND_REMOUNT_ATTR = "data-zfb-island-remount";
7
7
  const DOCUMENT_NODE = 9;
8
8
  const installedControllers = /* @__PURE__ */ new WeakMap();
9
9
  function installNestedIslandPropsRefresh({
10
- document: document2
10
+ document: document2,
11
+ reportError
11
12
  }) {
12
13
  const onBeforeSwap = (event) => {
13
- const incoming = event.newDocument;
14
- if (!isDocument(incoming) || incoming === document2) return;
15
- refreshNestedIslandProps(document2, incoming);
14
+ const swapEvent = event;
15
+ const incoming = swapEvent.newDocument;
16
+ if (!isDocument(incoming) || incoming === document2 || typeof swapEvent.swap !== "function") {
17
+ return;
18
+ }
19
+ const capturedSwap = swapEvent.swap;
20
+ const plan = buildNestedIslandPropsMutationPlan(document2, incoming);
21
+ let commitStarted = false;
22
+ let commitOutcome;
23
+ const composedSwap = function(...args) {
24
+ if (commitStarted) {
25
+ if (!commitOutcome) return void 0;
26
+ if (!commitOutcome.ok) throw commitOutcome.error;
27
+ return commitOutcome.value;
28
+ }
29
+ commitStarted = true;
30
+ try {
31
+ const value = commitMutationPlanAndDelegate(
32
+ plan,
33
+ capturedSwap,
34
+ this,
35
+ args,
36
+ reportError
37
+ );
38
+ commitOutcome = { ok: true, value };
39
+ return value;
40
+ } catch (error) {
41
+ commitOutcome = { ok: false, error };
42
+ throw error;
43
+ }
44
+ };
45
+ try {
46
+ swapEvent.swap = composedSwap;
47
+ } catch {
48
+ return;
49
+ }
50
+ if (swapEvent.swap !== composedSwap) return;
16
51
  };
17
52
  document2.addEventListener(BEFORE_SWAP_EVENT, onBeforeSwap);
18
53
  return () => document2.removeEventListener(BEFORE_SWAP_EVENT, onBeforeSwap);
@@ -29,9 +64,10 @@ function disposeNestedIslandPropsRefresh(document2) {
29
64
  installedControllers.get(document2)?.();
30
65
  installedControllers.delete(document2);
31
66
  }
32
- function refreshNestedIslandProps(liveDocument, incomingDocument) {
67
+ function buildNestedIslandPropsMutationPlan(liveDocument, incomingDocument) {
68
+ const plan = [];
33
69
  const liveRoots = collectRefreshableRoots(liveDocument);
34
- if (liveRoots.size === 0) return;
70
+ if (liveRoots.size === 0) return Object.freeze(plan);
35
71
  const incomingRoots = collectRefreshableRoots(incomingDocument);
36
72
  for (const [persistKey, liveRoot] of liveRoots) {
37
73
  const incomingRoot = incomingRoots.get(persistKey);
@@ -41,9 +77,31 @@ function refreshNestedIslandProps(liveDocument, incomingDocument) {
41
77
  for (const [name, liveIsland] of collectUniqueOwnedIslands(liveRoot, true)) {
42
78
  const incomingIsland = incomingIslands.get(name);
43
79
  if (!incomingIsland) continue;
44
- applyProps(liveIsland, incomingIsland);
80
+ const incomingProps = incomingIsland.getAttribute(PROPS_ATTR);
81
+ if (incomingProps === liveIsland.getAttribute(PROPS_ATTR)) continue;
82
+ plan.push(Object.freeze({ liveIsland, incomingProps }));
83
+ }
84
+ }
85
+ return Object.freeze(plan);
86
+ }
87
+ function commitMutationPlanAndDelegate(plan, delegate, receiver, args, reportError) {
88
+ const helperErrors = [];
89
+ for (const mutation of plan) {
90
+ try {
91
+ applyPropsMutation(mutation);
92
+ } catch (error) {
93
+ helperErrors.push(error);
45
94
  }
46
95
  }
96
+ let delegateResult;
97
+ try {
98
+ delegateResult = delegate.apply(receiver, args);
99
+ } catch (delegateError) {
100
+ reportRefreshErrors(helperErrors, reportError);
101
+ throw delegateError;
102
+ }
103
+ reportRefreshErrors(helperErrors, reportError);
104
+ return delegateResult;
47
105
  }
48
106
  function collectRefreshableRoots(doc) {
49
107
  const roots = indexUniquely(
@@ -83,17 +141,35 @@ function indexUniquely(elements, attribute) {
83
141
  for (const value of duplicated) byValue.delete(value);
84
142
  return byValue;
85
143
  }
86
- function applyProps(liveIsland, incomingIsland) {
87
- const incoming = incomingIsland.getAttribute(PROPS_ATTR);
88
- const current = liveIsland.getAttribute(PROPS_ATTR);
89
- if (incoming === current) return;
90
- if (incoming === null) {
144
+ function applyPropsMutation({
145
+ liveIsland,
146
+ incomingProps
147
+ }) {
148
+ if (incomingProps === null) {
91
149
  liveIsland.removeAttribute(PROPS_ATTR);
92
150
  } else {
93
- liveIsland.setAttribute(PROPS_ATTR, incoming);
151
+ liveIsland.setAttribute(PROPS_ATTR, incomingProps);
94
152
  }
95
153
  liveIsland.setAttribute(ISLAND_REMOUNT_ATTR, "");
96
154
  }
155
+ function reportRefreshErrors(errors, reportError) {
156
+ if (errors.length === 0) return;
157
+ const error = errors.length === 1 ? errors[0] : new AggregateError(errors, "Nested-island props refresh failed");
158
+ if (reportError) {
159
+ try {
160
+ reportError(error);
161
+ } catch (reporterError) {
162
+ surfaceAsynchronously(reporterError);
163
+ }
164
+ return;
165
+ }
166
+ surfaceAsynchronously(error);
167
+ }
168
+ function surfaceAsynchronously(error) {
169
+ queueMicrotask(() => {
170
+ throw error;
171
+ });
172
+ }
97
173
  function isDocument(value) {
98
174
  if (typeof value !== "object" || value === null) return false;
99
175
  const candidate = value;
@@ -187,7 +187,9 @@ function SidebarFooter({ links, themeDefaultMode }: { links?: SidebarLocaleLink[
187
187
  return (
188
188
  // pb-[50vh] provides scroll room so the footer doesn't sit at the very bottom of the viewport
189
189
  <div className="lg:hidden flex items-center gap-hsp-md border-t border-muted px-hsp-sm py-vsp-xs pb-[50vh] text-small">
190
- {themeDefaultMode && <ThemeToggle defaultMode={themeDefaultMode} />}
190
+ {themeDefaultMode && (
191
+ <ThemeToggle defaultMode={themeDefaultMode} pendingUntilHydrated={true} />
192
+ )}
191
193
  {links && links.map((link, i) => (
192
194
  <span key={link.href} className="flex items-center gap-hsp-xs">
193
195
  {i > 0 && <span className="text-muted">/</span>}
@@ -0,0 +1,13 @@
1
+ import { useEffect, useState } from "preact/hooks";
2
+
3
+ /**
4
+ * Keep an interactive control pending until its first successful client mount.
5
+ * Initializing from `enabled` preserves server/client-first-render parity.
6
+ */
7
+ export function useHydrationPending(enabled: boolean): boolean {
8
+ const [pending, setPending] = useState(enabled);
9
+
10
+ useEffect(() => setPending(false), []);
11
+
12
+ return pending;
13
+ }
@@ -14,6 +14,7 @@
14
14
  // not alias "react" to "preact/compat", so importing from "react" here
15
15
  // would fail to resolve.
16
16
  import { useState, useEffect } from "preact/hooks";
17
+ import { useHydrationPending } from "./hydration-pending.js";
17
18
  import {
18
19
  applyColorScheme,
19
20
  readColorSchemeFromDom,
@@ -69,6 +70,8 @@ function MoonIcon() {
69
70
 
70
71
  export interface ThemeToggleProps {
71
72
  defaultMode?: ColorSchemeMode;
73
+ /** Keep activation pending until the first successful mount. @default true */
74
+ pendingUntilHydrated?: boolean;
72
75
  }
73
76
 
74
77
  // NAMED export (not default) on purpose: tsup compiles a default export
@@ -79,7 +82,9 @@ export interface ThemeToggleProps {
79
82
  // the package's MobileToc island).
80
83
  export function ThemeToggle({
81
84
  defaultMode = "dark",
85
+ pendingUntilHydrated = true,
82
86
  }: ThemeToggleProps) {
87
+ const pending = useHydrationPending(pendingUntilHydrated);
83
88
  // Initial state must match server render to avoid hydration mismatch.
84
89
  // Actual theme is synced from DOM in useEffect below.
85
90
  const [mode, setMode] = useState<ColorSchemeMode>(defaultMode);
@@ -94,6 +99,7 @@ export function ThemeToggle({
94
99
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
95
100
 
96
101
  function toggle() {
102
+ if (pending) return;
97
103
  const next = mode === "dark" ? "light" : "dark";
98
104
  setMode(next);
99
105
  applyColorScheme(next);
@@ -105,6 +111,8 @@ export function ThemeToggle({
105
111
  <button
106
112
  onClick={toggle}
107
113
  aria-label={`Switch to ${nextMode} mode`}
114
+ aria-disabled={pending ? "true" : undefined}
115
+ data-zd-pending={pending ? "" : undefined}
108
116
  className="text-muted hover:text-fg transition-colors p-hsp-sm focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
109
117
  >
110
118
  {mode === "dark" ? <SunIcon /> : <MoonIcon />}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "5.12.0",
3
+ "version": "5.13.0",
4
4
  "type": "module",
5
5
  "description": "zudo-doc framework primitives layer that sits on top of zfb's engine — sidebar, theme, TOC, breadcrumb, layouts, head injection, View Transitions, SSR-skip wrappers (per ADR-003).",
6
6
  "license": "MIT",
@@ -247,11 +247,16 @@
247
247
  "types": "./dist/site-schema/index.d.ts",
248
248
  "default": "./dist/site-schema/index.js"
249
249
  },
250
+ "./theme-packs-registry": {
251
+ "types": "./dist/theme-packs-registry/index.d.ts",
252
+ "default": "./dist/theme-packs-registry/index.js"
253
+ },
250
254
  "./theme.css": "./dist/theme.css",
251
255
  "./safelist.css": "./dist/safelist.css",
252
256
  "./content.css": "./dist/content.css",
253
257
  "./page-loading.css": "./dist/page-loading.css",
254
258
  "./features.css": "./dist/features.css",
259
+ "./compiled.css": "./dist/compiled.css",
255
260
  "./theme-packs/*": "./dist/theme-packs/*",
256
261
  "./routes-src/*": "./routes-src/*",
257
262
  "./tsconfig.base.json": "./tsconfig.base.json",
@@ -612,6 +617,10 @@
612
617
  "./note-tray-model": {
613
618
  "types": "./dist/note-tray-model/index.d.ts",
614
619
  "default": "./dist/note-tray-model/index.js"
620
+ },
621
+ "./route-context-payload": {
622
+ "types": "./dist/route-context-payload/index.d.ts",
623
+ "default": "./dist/route-context-payload/index.js"
615
624
  }
616
625
  },
617
626
  "bin": {
@@ -634,10 +643,10 @@
634
643
  ],
635
644
  "peerDependencies": {
636
645
  "@takazudo/zdtp": "^0.4.12",
637
- "@takazudo/zfb": "^2.10.0",
638
- "@takazudo/zfb-md-wasm": "^2.10.0",
639
- "@takazudo/zfb-runtime": "^2.10.0",
640
- "@takazudo/zudo-doc-history-server": "^5.5.3",
646
+ "@takazudo/zfb": "^2.12.0",
647
+ "@takazudo/zfb-md-wasm": "^2.12.0",
648
+ "@takazudo/zfb-runtime": "^2.12.0",
649
+ "@takazudo/zudo-doc-history-server": "^5.12.1",
641
650
  "diff": "^8.0.0",
642
651
  "katex": "^0.16.0",
643
652
  "preact": "^10.29.1",
@@ -669,18 +678,16 @@
669
678
  "picocolors": "^1.1.1",
670
679
  "pluralize": "^8.0.0",
671
680
  "smol-toml": "^1.8.0",
672
- "string-similarity": "^4.0.4",
673
681
  "tsx": "^4.21.0"
674
682
  },
675
683
  "devDependencies": {
676
- "@takazudo/zfb": "2.10.0",
677
- "@takazudo/zfb-md-wasm": "2.10.0",
678
- "@takazudo/zfb-runtime": "2.10.0",
684
+ "@takazudo/zfb": "2.12.0",
685
+ "@takazudo/zfb-md-wasm": "2.12.0",
686
+ "@takazudo/zfb-runtime": "2.12.0",
679
687
  "@types/fs-extra": "^11.0.4",
680
688
  "@types/minimist": "^1.2.5",
681
689
  "@types/node": "^25.3.5",
682
690
  "@types/pluralize": "^0.0.33",
683
- "@types/string-similarity": "^4.0.2",
684
691
  "happy-dom": "^20.10.6",
685
692
  "npm-run-all2": "^7.0.2",
686
693
  "preact": "^10.29.1",
@@ -689,7 +696,7 @@
689
696
  "typescript": "^5.0.0",
690
697
  "vitest": "^4.1.0",
691
698
  "zod": "^4.3.6",
692
- "@takazudo/zudo-doc-history-server": "5.12.0"
699
+ "@takazudo/zudo-doc-history-server": "5.13.0"
693
700
  },
694
701
  "scripts": {
695
702
  "gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",
@@ -701,7 +708,7 @@
701
708
  "dev:js": "tsup --watch",
702
709
  "dev:dts": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
703
710
  "// check:prepack-contract": "check:search-widget-drift and check:nav-overflow-drift run here (not just b4push/CI) because `prepare` regenerates BOTH frozen literals unconditionally — without a prepack gate, packing from a tree where a source changed but a literal was never re-committed would silently publish new script bytes and break every consumer's pinned CSP hash (#3534/#3535 for nav-overflow; #3540 extended the same gate to search-widget, the exact failure class the freezes exist to prevent).",
704
- "check:prepack-contract": "pnpm --dir ../.. check:search-widget-drift && pnpm --dir ../.. check:nav-overflow-drift && pnpm --dir ../.. gen:changelog && node scripts/check-theme-css.mjs && node scripts/check-safelist.mjs && node scripts/check-content-css.mjs && node scripts/check-page-loading-css.mjs && node scripts/check-features-css.mjs && node scripts/check-theme-packs.mjs && node scripts/check-catalog.mjs && node scripts/check-site-schema.mjs && node scripts/check-plugins.mjs && node scripts/check-plugin-resolution.mjs && node scripts/check-eject-sources.mjs && node scripts/check-routes-src.mjs && node scripts/check-shim-artifacts.mjs && node scripts/check-virtual-modules.mjs && node bin/gen-component-tokens.mjs --check",
711
+ "check:prepack-contract": "pnpm --dir ../.. check:search-widget-drift && pnpm --dir ../.. check:nav-overflow-drift && pnpm --dir ../.. gen:changelog && node scripts/check-theme-css.mjs && node scripts/check-safelist.mjs && node scripts/check-content-css.mjs && node scripts/check-page-loading-css.mjs && node scripts/check-features-css.mjs && node scripts/check-compiled-css.mjs && node scripts/check-theme-packs.mjs && node scripts/check-catalog.mjs && node scripts/check-site-schema.mjs && node scripts/check-theme-packs-registry.mjs && node scripts/check-plugins.mjs && node scripts/check-plugin-resolution.mjs && node scripts/check-eject-sources.mjs && node scripts/check-routes-src.mjs && node scripts/check-shim-artifacts.mjs && node scripts/check-virtual-modules.mjs && node bin/gen-component-tokens.mjs --check && node scripts/check-route-context-payload.mjs",
705
712
  "test:plugin-resolution": "node scripts/check-plugin-resolution.mjs",
706
713
  "test": "vitest run --config vitest.config.ts",
707
714
  "test:slow": "vitest run --config vitest.slow.config.ts",