@takazudo/zudo-doc 5.5.1 → 5.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ 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.5.2] - 2026-08-16
8
+
9
+ ### Bug Fixes
10
+
11
+ - Preserve the sidebar scroll position across SPA navigation and island remounts. (7e8130f4b, 63629e73e, 7fadc3308)
12
+ - Suppress GFM task-list markers in rendered documentation. (4d9dbf941)
13
+
14
+ ### Other Changes
15
+
16
+ - Added regression coverage for stale sidebar snapshots and sidebar timing. (5632de06f, 360857d79)
17
+
7
18
  ## [5.5.1] - 2026-08-15
8
19
 
9
20
  ### Bug Fixes
package/dist/content.css CHANGED
@@ -164,6 +164,19 @@
164
164
  color: var(--color-muted);
165
165
  }
166
166
 
167
+ /* GFM task-list inputs are emitted as disabled direct children of a tight
168
+ * item, or as children of the item's paragraph in a loose list. The
169
+ * component-rendered ancestor `ul` carries an inline disc marker, so reset
170
+ * only those generated task rows on the `li` itself. Keep the direct-child
171
+ * shape and disabled constraint narrow so authored/nested checkboxes remain
172
+ * ordinary list content. */
173
+ .zd-content :where(
174
+ li:has(> input[type="checkbox"][disabled]),
175
+ li:has(> p > input[type="checkbox"][disabled])
176
+ ) {
177
+ list-style-type: none;
178
+ }
179
+
167
180
  .zd-content :where(li > ul, li > ol) {
168
181
  margin-top: var(--spacing-vsp-xs);
169
182
  margin-bottom: 0;
@@ -9,6 +9,8 @@ import { smartBreakToHtml } from "../smart-break/index.js";
9
9
  import { AFTER_NAVIGATE_EVENT, BEFORE_NAVIGATE_EVENT } from "../transitions/index.js";
10
10
  import { filterTree } from "../sidebar-filter/index.js";
11
11
  import { findActiveSlug, normalizePath } from "../sidebar-active-slug/index.js";
12
+ import { ensureSidebarScrollPreserve } from "./sidebar-scroll-preserve.js";
13
+ ensureSidebarScrollPreserve();
12
14
  function ToggleChevron({ isExpanded, className }) {
13
15
  return /* @__PURE__ */ jsx(
14
16
  ChevronRight,
@@ -58,35 +60,6 @@ function useActiveSlug(nodes, initial) {
58
60
  }, [nodes]);
59
61
  return slug;
60
62
  }
61
- function useSidebarScrollPreserve() {
62
- useEffect(() => {
63
- let savedScrollTop = 0;
64
- let restoreTimer;
65
- const onBefore = () => {
66
- if (restoreTimer !== void 0) {
67
- clearTimeout(restoreTimer);
68
- restoreTimer = void 0;
69
- }
70
- const aside = document.querySelector("#desktop-sidebar");
71
- if (aside) savedScrollTop = aside.scrollTop;
72
- };
73
- const onAfter = () => {
74
- const aside = document.querySelector("#desktop-sidebar");
75
- if (!aside) return;
76
- restoreTimer = setTimeout(() => {
77
- restoreTimer = void 0;
78
- aside.scrollTop = savedScrollTop;
79
- }, 50);
80
- };
81
- document.addEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
82
- document.addEventListener(AFTER_NAVIGATE_EVENT, onAfter);
83
- return () => {
84
- document.removeEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
85
- document.removeEventListener(AFTER_NAVIGATE_EVENT, onAfter);
86
- if (restoreTimer !== void 0) clearTimeout(restoreTimer);
87
- };
88
- }, []);
89
- }
90
63
  function RootMenuItemEntry({ item }) {
91
64
  const [expanded, setExpanded] = useState(false);
92
65
  const hasChildren = item.children && item.children.length > 0;
@@ -141,7 +114,6 @@ function SidebarFooter({ links, themeDefaultMode }) {
141
114
  }
142
115
  function SidebarTree({ nodes, currentSlug, rootMenuItems, backToMenuLabel, localeLinks, themeDefaultMode }) {
143
116
  const activeSlug = useActiveSlug(nodes, currentSlug);
144
- useSidebarScrollPreserve();
145
117
  const [query, setQuery] = useState("");
146
118
  const [showingRootMenu, setShowingRootMenu] = useState(false);
147
119
  const filterRef = useRef(null);
@@ -0,0 +1,21 @@
1
+ interface SidebarScrollPreserveOptions {
2
+ document: Document;
3
+ requestAnimationFrame: (callback: FrameRequestCallback) => number;
4
+ cancelAnimationFrame: (handle: number) => void;
5
+ }
6
+ /**
7
+ * Install the scroll-preservation state machine on a document.
8
+ *
9
+ * This low-level entrypoint owns an explicit cleanup for focused tests and
10
+ * non-singleton hosts. The sidebar's browser module uses the durable,
11
+ * duplicate-safe `ensureSidebarScrollPreserve` wrapper below instead.
12
+ */
13
+ export declare function installSidebarScrollPreserve({ document, requestAnimationFrame, cancelAnimationFrame, }: SidebarScrollPreserveOptions): () => void;
14
+ /**
15
+ * Install exactly one module-lifetime controller for a browser document.
16
+ * Repeated calls from component renders or duplicate boot paths are no-ops.
17
+ */
18
+ export declare function ensureSidebarScrollPreserve(options?: SidebarScrollPreserveOptions): void;
19
+ /** Test/HMR teardown for a controller installed through the singleton wrapper. */
20
+ export declare function disposeSidebarScrollPreserve(document: Document): void;
21
+ export {};
@@ -0,0 +1,63 @@
1
+ import { AFTER_NAVIGATE_EVENT, BEFORE_NAVIGATE_EVENT } from "../transitions/index.js";
2
+ const installedControllers = /* @__PURE__ */ new WeakMap();
3
+ function installSidebarScrollPreserve({
4
+ document: document2,
5
+ requestAnimationFrame,
6
+ cancelAnimationFrame
7
+ }) {
8
+ let snapshot;
9
+ let restoreFrame;
10
+ const cancelPendingRestore = () => {
11
+ if (restoreFrame === void 0) return;
12
+ cancelAnimationFrame(restoreFrame);
13
+ restoreFrame = void 0;
14
+ };
15
+ const onBefore = () => {
16
+ cancelPendingRestore();
17
+ const element = document2.querySelector("#desktop-sidebar");
18
+ snapshot = element ? { element, scrollTop: element.scrollTop } : void 0;
19
+ };
20
+ const onAfter = () => {
21
+ if (!snapshot) return;
22
+ const saved = snapshot;
23
+ snapshot = void 0;
24
+ restoreFrame = requestAnimationFrame(() => {
25
+ restoreFrame = void 0;
26
+ const current = document2.querySelector("#desktop-sidebar");
27
+ if (current === saved.element) current.scrollTop = saved.scrollTop;
28
+ });
29
+ };
30
+ document2.addEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
31
+ document2.addEventListener(AFTER_NAVIGATE_EVENT, onAfter);
32
+ return () => {
33
+ document2.removeEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
34
+ document2.removeEventListener(AFTER_NAVIGATE_EVENT, onAfter);
35
+ cancelPendingRestore();
36
+ snapshot = void 0;
37
+ };
38
+ }
39
+ function ensureSidebarScrollPreserve(options) {
40
+ const resolved = options ?? resolveBrowserOptions();
41
+ if (!resolved || installedControllers.has(resolved.document)) return;
42
+ installedControllers.set(
43
+ resolved.document,
44
+ installSidebarScrollPreserve(resolved)
45
+ );
46
+ }
47
+ function resolveBrowserOptions() {
48
+ if (typeof document === "undefined" || typeof window === "undefined") return void 0;
49
+ return {
50
+ document,
51
+ requestAnimationFrame: window.requestAnimationFrame.bind(window),
52
+ cancelAnimationFrame: window.cancelAnimationFrame.bind(window)
53
+ };
54
+ }
55
+ function disposeSidebarScrollPreserve(document2) {
56
+ installedControllers.get(document2)?.();
57
+ installedControllers.delete(document2);
58
+ }
59
+ export {
60
+ disposeSidebarScrollPreserve,
61
+ ensureSidebarScrollPreserve,
62
+ installSidebarScrollPreserve
63
+ };
@@ -18,6 +18,13 @@ import { smartBreakToHtml } from "../smart-break/index.js";
18
18
  import { AFTER_NAVIGATE_EVENT, BEFORE_NAVIGATE_EVENT } from "../transitions/index.js";
19
19
  import { filterTree } from "../sidebar-filter/index.js";
20
20
  import { findActiveSlug, normalizePath } from "../sidebar-active-slug/index.js";
21
+ import { ensureSidebarScrollPreserve } from "./sidebar-scroll-preserve.js";
22
+
23
+ // The persisted aside can transiently tear down and re-mount its SidebarTree
24
+ // effect during a body swap. Keep navigation snapshot ownership at browser
25
+ // module/document lifetime so that island lifecycle cannot discard it between
26
+ // before-preparation and after-swap. SSR evaluation is a safe no-op.
27
+ ensureSidebarScrollPreserve();
21
28
 
22
29
  function ToggleChevron({ isExpanded, className }: { isExpanded: boolean; className?: string }) {
23
30
  return (
@@ -90,42 +97,6 @@ function useActiveSlug(nodes: SidebarNavNode[], initial?: string): string | unde
90
97
  return slug;
91
98
  }
92
99
 
93
- /**
94
- * Preserve `#desktop-sidebar` scrollTop across SPA navigations.
95
- */
96
- function useSidebarScrollPreserve() {
97
- useEffect(() => {
98
- let savedScrollTop = 0;
99
- let restoreTimer: ReturnType<typeof setTimeout> | undefined;
100
-
101
- const onBefore = () => {
102
- if (restoreTimer !== undefined) {
103
- clearTimeout(restoreTimer);
104
- restoreTimer = undefined;
105
- }
106
- const aside = document.querySelector<HTMLElement>("#desktop-sidebar");
107
- if (aside) savedScrollTop = aside.scrollTop;
108
- };
109
-
110
- const onAfter = () => {
111
- const aside = document.querySelector<HTMLElement>("#desktop-sidebar");
112
- if (!aside) return;
113
- restoreTimer = setTimeout(() => {
114
- restoreTimer = undefined;
115
- aside.scrollTop = savedScrollTop;
116
- }, 50);
117
- };
118
-
119
- document.addEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
120
- document.addEventListener(AFTER_NAVIGATE_EVENT, onAfter);
121
- return () => {
122
- document.removeEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
123
- document.removeEventListener(AFTER_NAVIGATE_EVENT, onAfter);
124
- if (restoreTimer !== undefined) clearTimeout(restoreTimer);
125
- };
126
- }, []);
127
- }
128
-
129
100
  function RootMenuItemEntry({ item }: { item: SidebarRootMenuItem }) {
130
101
  const [expanded, setExpanded] = useState(false);
131
102
  const hasChildren = item.children && item.children.length > 0;
@@ -202,7 +173,6 @@ function SidebarFooter({ links, themeDefaultMode }: { links?: SidebarLocaleLink[
202
173
 
203
174
  export function SidebarTree({ nodes, currentSlug, rootMenuItems, backToMenuLabel, localeLinks, themeDefaultMode }: SidebarTreeProps) {
204
175
  const activeSlug = useActiveSlug(nodes, currentSlug);
205
- useSidebarScrollPreserve();
206
176
  const [query, setQuery] = useState("");
207
177
  const [showingRootMenu, setShowingRootMenu] = useState(false);
208
178
  const filterRef = useRef<HTMLInputElement>(null);
@@ -0,0 +1,88 @@
1
+ import { AFTER_NAVIGATE_EVENT, BEFORE_NAVIGATE_EVENT } from "../transitions/index.js";
2
+
3
+ interface SidebarScrollPreserveOptions {
4
+ document: Document;
5
+ requestAnimationFrame: (callback: FrameRequestCallback) => number;
6
+ cancelAnimationFrame: (handle: number) => void;
7
+ }
8
+
9
+ const installedControllers = new WeakMap<Document, () => void>();
10
+
11
+ /**
12
+ * Install the scroll-preservation state machine on a document.
13
+ *
14
+ * This low-level entrypoint owns an explicit cleanup for focused tests and
15
+ * non-singleton hosts. The sidebar's browser module uses the durable,
16
+ * duplicate-safe `ensureSidebarScrollPreserve` wrapper below instead.
17
+ */
18
+ export function installSidebarScrollPreserve({
19
+ document,
20
+ requestAnimationFrame,
21
+ cancelAnimationFrame,
22
+ }: SidebarScrollPreserveOptions): () => void {
23
+ let snapshot: { element: HTMLElement; scrollTop: number } | undefined;
24
+ let restoreFrame: number | undefined;
25
+
26
+ const cancelPendingRestore = () => {
27
+ if (restoreFrame === undefined) return;
28
+ cancelAnimationFrame(restoreFrame);
29
+ restoreFrame = undefined;
30
+ };
31
+
32
+ const onBefore = () => {
33
+ cancelPendingRestore();
34
+ const element = document.querySelector<HTMLElement>("#desktop-sidebar");
35
+ snapshot = element ? { element, scrollTop: element.scrollTop } : undefined;
36
+ };
37
+
38
+ const onAfter = () => {
39
+ if (!snapshot) return;
40
+ const saved = snapshot;
41
+ snapshot = undefined;
42
+ restoreFrame = requestAnimationFrame(() => {
43
+ restoreFrame = undefined;
44
+ const current = document.querySelector<HTMLElement>("#desktop-sidebar");
45
+ if (current === saved.element) current.scrollTop = saved.scrollTop;
46
+ });
47
+ };
48
+
49
+ document.addEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
50
+ document.addEventListener(AFTER_NAVIGATE_EVENT, onAfter);
51
+
52
+ return () => {
53
+ document.removeEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
54
+ document.removeEventListener(AFTER_NAVIGATE_EVENT, onAfter);
55
+ cancelPendingRestore();
56
+ snapshot = undefined;
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Install exactly one module-lifetime controller for a browser document.
62
+ * Repeated calls from component renders or duplicate boot paths are no-ops.
63
+ */
64
+ export function ensureSidebarScrollPreserve(
65
+ options?: SidebarScrollPreserveOptions,
66
+ ): void {
67
+ const resolved = options ?? resolveBrowserOptions();
68
+ if (!resolved || installedControllers.has(resolved.document)) return;
69
+ installedControllers.set(
70
+ resolved.document,
71
+ installSidebarScrollPreserve(resolved),
72
+ );
73
+ }
74
+
75
+ function resolveBrowserOptions(): SidebarScrollPreserveOptions | undefined {
76
+ if (typeof document === "undefined" || typeof window === "undefined") return undefined;
77
+ return {
78
+ document,
79
+ requestAnimationFrame: window.requestAnimationFrame.bind(window),
80
+ cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
81
+ };
82
+ }
83
+
84
+ /** Test/HMR teardown for a controller installed through the singleton wrapper. */
85
+ export function disposeSidebarScrollPreserve(document: Document): void {
86
+ installedControllers.get(document)?.();
87
+ installedControllers.delete(document);
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "5.5.1",
3
+ "version": "5.5.2",
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",
@@ -663,7 +663,7 @@
663
663
  "typescript": "^5.0.0",
664
664
  "vitest": "^4.1.0",
665
665
  "zod": "^4.3.6",
666
- "@takazudo/zudo-doc-history-server": "5.5.1"
666
+ "@takazudo/zudo-doc-history-server": "5.5.2"
667
667
  },
668
668
  "scripts": {
669
669
  "build": "tsup && tsc -p tsconfig.build.json",