idcmd 0.0.5 → 0.0.7

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 (51) hide show
  1. package/README.md +29 -9
  2. package/package.json +2 -2
  3. package/src/build.ts +6 -5
  4. package/src/cli/commands/build.ts +11 -7
  5. package/src/cli/commands/client.ts +328 -0
  6. package/src/cli/commands/dev.ts +92 -34
  7. package/src/cli/commands/init.ts +93 -2
  8. package/src/cli/main.ts +12 -0
  9. package/src/cli/runtime-assets.ts +89 -0
  10. package/src/cli.ts +0 -0
  11. package/src/client/index.ts +7 -1
  12. package/src/content/icons.ts +1 -1
  13. package/src/content/paths.ts +1 -1
  14. package/src/project/paths.ts +26 -30
  15. package/src/render/layout-loader.ts +7 -4
  16. package/src/render/layout.tsx +10 -2
  17. package/src/render/page-renderer.ts +12 -2
  18. package/src/render/right-rail-loader.ts +49 -0
  19. package/src/render/right-rail.tsx +10 -6
  20. package/src/search/page.tsx +4 -2
  21. package/src/search/search-page-loader.ts +51 -0
  22. package/src/search/server-page.ts +52 -18
  23. package/src/server/live-reload.ts +2 -6
  24. package/src/server/static.ts +1 -1
  25. package/src/server.ts +0 -1
  26. package/src/site/config.ts +2 -10
  27. package/templates/default/.github/workflows/ci.yml +24 -0
  28. package/templates/default/README.md +31 -5
  29. package/templates/default/package.json +2 -1
  30. package/templates/default/scripts/check-internal.ts +56 -0
  31. package/templates/default/scripts/check.ts +332 -0
  32. package/templates/default/scripts/smoke.ts +223 -0
  33. package/templates/default/site/{public/_idcmd/llm-menu.js → code/runtime/llm-menu.ts} +27 -18
  34. package/templates/default/site/{public/_idcmd/nav-prefetch.js → code/runtime/nav-prefetch.ts} +3 -3
  35. package/templates/default/site/{public/_idcmd/right-rail-scrollspy.js → code/runtime/right-rail-scrollspy.ts} +73 -32
  36. package/templates/default/site/{server → code}/server.ts +1 -1
  37. package/templates/default/site/code/ui/layout.tsx +237 -0
  38. package/templates/default/site/code/ui/right-rail.tsx +246 -0
  39. package/templates/default/site/code/ui/search-page.tsx +87 -0
  40. package/templates/default/tsconfig.json +1 -1
  41. package/templates/default/site/client/layout.tsx +0 -2
  42. package/templates/default/site/client/right-rail.tsx +0 -1
  43. package/templates/default/site/client/search-page.tsx +0 -1
  44. /package/templates/default/site/{public → assets}/anthropic-white.svg +0 -0
  45. /package/templates/default/site/{public → assets}/favicon.svg +0 -0
  46. /package/templates/default/site/{icons → assets/icons}/file.svg +0 -0
  47. /package/templates/default/site/{icons → assets/icons}/home.svg +0 -0
  48. /package/templates/default/site/{icons → assets/icons}/info.svg +0 -0
  49. /package/templates/default/site/{public → assets}/openai-white.svg +0 -0
  50. /package/templates/default/site/{server → code}/routes/api/hello.ts +0 -0
  51. /package/templates/default/site/{public/_idcmd/live-reload.js → code/runtime/live-reload.ts} +0 -0
@@ -0,0 +1,223 @@
1
+ interface CommandResult {
2
+ code: number;
3
+ stderr: string;
4
+ stdout: string;
5
+ }
6
+
7
+ const BASE_URL = process.env.IDCMD_SMOKE_BASE_URL ?? "http://127.0.0.1:4000";
8
+ const CURL_MAX_TIME_SECONDS = "5";
9
+ const READY_TIMEOUT_MS = 60_000;
10
+ const READY_INTERVAL_MS = 500;
11
+ const SHUTDOWN_TIMEOUT_MS = 5000;
12
+
13
+ const delay = (ms: number): Promise<void> => Bun.sleep(ms);
14
+
15
+ const runCommand = async (command: string[]): Promise<CommandResult> => {
16
+ const proc = Bun.spawn(command, {
17
+ cwd: process.cwd(),
18
+ stderr: "pipe",
19
+ stdout: "pipe",
20
+ });
21
+ const [stdout, stderr, code] = await Promise.all([
22
+ new Response(proc.stdout).text(),
23
+ new Response(proc.stderr).text(),
24
+ proc.exited,
25
+ ]);
26
+ return { code, stderr, stdout };
27
+ };
28
+
29
+ const runCurl = (path: string): Promise<CommandResult> =>
30
+ runCommand([
31
+ "curl",
32
+ "-fsS",
33
+ "--max-time",
34
+ CURL_MAX_TIME_SECONDS,
35
+ `${BASE_URL}${path}`,
36
+ ]);
37
+
38
+ const assertCommandOk = (label: string, result: CommandResult): void => {
39
+ if (result.code === 0) {
40
+ return;
41
+ }
42
+ throw new Error(
43
+ [
44
+ `${label} failed with exit code ${String(result.code)}.`,
45
+ "stdout:",
46
+ result.stdout.trim() || "(empty)",
47
+ "stderr:",
48
+ result.stderr.trim() || "(empty)",
49
+ ].join("\n")
50
+ );
51
+ };
52
+
53
+ const expectIncludes = (args: {
54
+ haystack: string;
55
+ label: string;
56
+ needle: string;
57
+ }): void => {
58
+ if (args.haystack.includes(args.needle)) {
59
+ return;
60
+ }
61
+ throw new Error(`Expected ${args.label} to include ${args.needle}.`);
62
+ };
63
+
64
+ const waitForReady = async (): Promise<void> => {
65
+ const startedAt = Date.now();
66
+ let lastFailure = "(no attempts yet)";
67
+
68
+ while (Date.now() - startedAt < READY_TIMEOUT_MS) {
69
+ const ready = await runCurl("/");
70
+ if (ready.code === 0) {
71
+ return;
72
+ }
73
+ lastFailure = ready.stderr.trim() || ready.stdout.trim() || "curl failed";
74
+ await delay(READY_INTERVAL_MS);
75
+ }
76
+
77
+ throw new Error(
78
+ `dev server did not become ready within ${String(
79
+ READY_TIMEOUT_MS
80
+ )}ms. Last curl failure: ${lastFailure}`
81
+ );
82
+ };
83
+
84
+ const shutdownDev = async (
85
+ proc: ReturnType<typeof Bun.spawn>
86
+ ): Promise<void> => {
87
+ try {
88
+ proc.kill("SIGTERM");
89
+ } catch {
90
+ return;
91
+ }
92
+
93
+ const didExit = await Promise.race([
94
+ proc.exited.then(() => true),
95
+ delay(SHUTDOWN_TIMEOUT_MS).then(() => false),
96
+ ]);
97
+ if (!didExit) {
98
+ try {
99
+ proc.kill("SIGKILL");
100
+ } catch {
101
+ // ignore
102
+ }
103
+ await proc.exited;
104
+ }
105
+ };
106
+
107
+ const assertHomeResponse = async (): Promise<void> => {
108
+ const home = await runCurl("/");
109
+ assertCommandOk("curl /", home);
110
+ expectIncludes({ haystack: home.stdout, label: "/", needle: "<html" });
111
+ };
112
+
113
+ const assertAboutResponse = async (): Promise<void> => {
114
+ const about = await runCurl("/about/");
115
+ assertCommandOk("curl /about/", about);
116
+ if (!about.stdout.includes("# About") && !about.stdout.includes(">About<")) {
117
+ throw new Error("Expected /about/ response to include About heading.");
118
+ }
119
+ };
120
+
121
+ const assertLlmsResponse = async (): Promise<void> => {
122
+ const llms = await runCurl("/llms.txt");
123
+ assertCommandOk("curl /llms.txt", llms);
124
+ if (llms.stdout.trim().length === 0 || !llms.stdout.includes("about.md")) {
125
+ throw new Error("Expected /llms.txt to be non-empty and include about.md.");
126
+ }
127
+ };
128
+
129
+ const assertApiResponse = async (): Promise<void> => {
130
+ const api = await runCurl("/api/hello");
131
+ assertCommandOk("curl /api/hello", api);
132
+ const payload = JSON.parse(api.stdout) as { message?: string; ok?: boolean };
133
+ if (payload.ok !== true || payload.message !== "Hello from idcmd route!") {
134
+ throw new Error("Expected /api/hello payload to match template route.");
135
+ }
136
+ };
137
+
138
+ const runSmokeChecks = async (): Promise<void> => {
139
+ await assertHomeResponse();
140
+ await assertAboutResponse();
141
+ await assertLlmsResponse();
142
+ await assertApiResponse();
143
+ };
144
+
145
+ const runProjectCheck = async (): Promise<void> => {
146
+ const check = await runCommand([process.execPath, "run", "check"]);
147
+ assertCommandOk("bun run check", check);
148
+ };
149
+
150
+ const startDev = (): {
151
+ devProc: ReturnType<typeof Bun.spawn>;
152
+ devStderr: Promise<string>;
153
+ devStdout: Promise<string>;
154
+ } => {
155
+ const devProc = Bun.spawn([process.execPath, "run", "dev"], {
156
+ cwd: process.cwd(),
157
+ stderr: "pipe",
158
+ stdout: "pipe",
159
+ });
160
+ return {
161
+ devProc,
162
+ devStderr: new Response(devProc.stderr).text(),
163
+ devStdout: new Response(devProc.stdout).text(),
164
+ };
165
+ };
166
+
167
+ const logDevFailure = async (args: {
168
+ error: unknown;
169
+ stderr: Promise<string>;
170
+ stdout: Promise<string>;
171
+ }): Promise<void> => {
172
+ const [stdout, stderr] = await Promise.all([args.stdout, args.stderr]);
173
+ const message =
174
+ args.error instanceof Error ? args.error.message : String(args.error);
175
+ console.error(message);
176
+ console.error("dev stdout:");
177
+ console.error(stdout.trim() || "(empty)");
178
+ console.error("dev stderr:");
179
+ console.error(stderr.trim() || "(empty)");
180
+ };
181
+
182
+ const toErrorMessage = (error: unknown): string => {
183
+ if (error instanceof Error) {
184
+ return error.message;
185
+ }
186
+ return String(error);
187
+ };
188
+
189
+ const runDevSmokeFlow = async (): Promise<number> => {
190
+ const { devProc, devStderr, devStdout } = startDev();
191
+
192
+ try {
193
+ await waitForReady();
194
+ await runSmokeChecks();
195
+ } catch (error) {
196
+ await logDevFailure({ error, stderr: devStderr, stdout: devStdout });
197
+ return 1;
198
+ } finally {
199
+ await shutdownDev(devProc);
200
+ }
201
+ return 0;
202
+ };
203
+
204
+ const runPostDevCheck = async (): Promise<number> => {
205
+ try {
206
+ await runProjectCheck();
207
+ return 0;
208
+ } catch (error) {
209
+ console.error(toErrorMessage(error));
210
+ return 1;
211
+ }
212
+ };
213
+
214
+ const main = async (): Promise<number> => {
215
+ const smokeCode = await runDevSmokeFlow();
216
+ if (smokeCode !== 0) {
217
+ return smokeCode;
218
+ }
219
+ return runPostDevCheck();
220
+ };
221
+
222
+ const code = await main();
223
+ process.exit(code);
@@ -2,7 +2,7 @@ const COPY_SELECTOR = 'a[data-copy-markdown="1"]';
2
2
  const LABEL_SELECTOR = '[data-copy-markdown-label="1"]';
3
3
  const RESET_DELAY_MS = 2000;
4
4
 
5
- const setLinkDisabled = (link, disabled) => {
5
+ const setLinkDisabled = (link: HTMLAnchorElement, disabled: boolean): void => {
6
6
  if (disabled) {
7
7
  link.setAttribute("aria-disabled", "true");
8
8
  link.style.pointerEvents = "none";
@@ -15,14 +15,14 @@ const setLinkDisabled = (link, disabled) => {
15
15
  link.style.opacity = "";
16
16
  };
17
17
 
18
- const setLinkLabel = (link, next) => {
18
+ const setLinkLabel = (link: HTMLAnchorElement, next: string): void => {
19
19
  const label = link.querySelector(LABEL_SELECTOR);
20
20
  if (label) {
21
21
  label.textContent = next;
22
22
  }
23
23
  };
24
24
 
25
- const toAbsoluteUrl = (href) => {
25
+ const toAbsoluteUrl = (href: string): string => {
26
26
  try {
27
27
  return new URL(href, window.location.href).toString();
28
28
  } catch {
@@ -30,7 +30,7 @@ const toAbsoluteUrl = (href) => {
30
30
  }
31
31
  };
32
32
 
33
- const createHiddenTextarea = (text) => {
33
+ const createHiddenTextarea = (text: string): HTMLTextAreaElement => {
34
34
  const textarea = document.createElement("textarea");
35
35
  textarea.value = text;
36
36
  textarea.setAttribute("readonly", "true");
@@ -40,7 +40,7 @@ const createHiddenTextarea = (text) => {
40
40
  return textarea;
41
41
  };
42
42
 
43
- const safeExecCommandCopy = () => {
43
+ const safeExecCommandCopy = (): boolean => {
44
44
  try {
45
45
  return document.execCommand("copy");
46
46
  } catch {
@@ -48,7 +48,7 @@ const safeExecCommandCopy = () => {
48
48
  }
49
49
  };
50
50
 
51
- const copyViaExecCommand = (text) => {
51
+ const copyViaExecCommand = (text: string): boolean => {
52
52
  const textarea = createHiddenTextarea(text);
53
53
  document.body.append(textarea);
54
54
  textarea.focus();
@@ -58,7 +58,7 @@ const copyViaExecCommand = (text) => {
58
58
  return ok;
59
59
  };
60
60
 
61
- const copyText = async (text) => {
61
+ const copyText = async (text: string): Promise<boolean> => {
62
62
  const { clipboard } = navigator;
63
63
  if (clipboard?.writeText) {
64
64
  try {
@@ -72,18 +72,18 @@ const copyText = async (text) => {
72
72
  return copyViaExecCommand(text);
73
73
  };
74
74
 
75
- const closeMenuIfPresent = (link) => {
75
+ const closeMenuIfPresent = (link: HTMLAnchorElement): void => {
76
76
  const details = link.closest("details");
77
77
  if (details instanceof HTMLDetailsElement) {
78
78
  details.open = false;
79
79
  }
80
80
  };
81
81
 
82
- const getOriginalLabel = (link) =>
82
+ const getOriginalLabel = (link: HTMLAnchorElement): string =>
83
83
  link.querySelector(LABEL_SELECTOR)?.textContent ??
84
84
  "Copy Markdown to Clipboard";
85
85
 
86
- const fetchMarkdownText = async (href) => {
86
+ const fetchMarkdownText = async (href: string): Promise<string | null> => {
87
87
  const res = await fetch(toAbsoluteUrl(href), { credentials: "same-origin" });
88
88
  if (!res.ok) {
89
89
  return null;
@@ -91,7 +91,7 @@ const fetchMarkdownText = async (href) => {
91
91
  return res.text();
92
92
  };
93
93
 
94
- const copyMarkdownFromHref = async (href) => {
94
+ const copyMarkdownFromHref = async (href: string): Promise<boolean> => {
95
95
  try {
96
96
  const text = await fetchMarkdownText(href);
97
97
  if (!text) {
@@ -103,24 +103,30 @@ const copyMarkdownFromHref = async (href) => {
103
103
  }
104
104
  };
105
105
 
106
- const startCopyOperation = (link) => {
106
+ const startCopyOperation = (link: HTMLAnchorElement): void => {
107
107
  setLinkDisabled(link, true);
108
108
  setLinkLabel(link, "Copying...");
109
109
  };
110
110
 
111
- const finishCopyOperation = (link, ok) => {
111
+ const finishCopyOperation = (link: HTMLAnchorElement, ok: boolean): void => {
112
112
  setLinkLabel(link, ok ? "Copied" : "Copy failed");
113
113
  closeMenuIfPresent(link);
114
114
  };
115
115
 
116
- const scheduleResetOperation = (link, originalLabel) => {
116
+ const scheduleResetOperation = (
117
+ link: HTMLAnchorElement,
118
+ originalLabel: string
119
+ ): void => {
117
120
  window.setTimeout(() => {
118
121
  setLinkLabel(link, originalLabel);
119
122
  setLinkDisabled(link, false);
120
123
  }, RESET_DELAY_MS);
121
124
  };
122
125
 
123
- const handleCopyClick = async (link, originalLabel) => {
126
+ const handleCopyClick = async (
127
+ link: HTMLAnchorElement,
128
+ originalLabel: string
129
+ ): Promise<void> => {
124
130
  const href = link.getAttribute("href");
125
131
  if (!href) {
126
132
  return;
@@ -132,7 +138,7 @@ const handleCopyClick = async (link, originalLabel) => {
132
138
  scheduleResetOperation(link, originalLabel);
133
139
  };
134
140
 
135
- const attachCopyHandler = (link) => {
141
+ const attachCopyHandler = (link: HTMLAnchorElement): void => {
136
142
  const originalLabel = getOriginalLabel(link);
137
143
  link.addEventListener("click", async (event) => {
138
144
  event.preventDefault();
@@ -143,8 +149,11 @@ const attachCopyHandler = (link) => {
143
149
  });
144
150
  };
145
151
 
146
- const initCopyMarkdownButtons = () => {
147
- const links = [...document.querySelectorAll(COPY_SELECTOR)];
152
+ const initCopyMarkdownButtons = (): void => {
153
+ const links = [...document.querySelectorAll(COPY_SELECTOR)].filter(
154
+ (link): link is HTMLAnchorElement => link instanceof HTMLAnchorElement
155
+ );
156
+
148
157
  for (const link of links) {
149
158
  attachCopyHandler(link);
150
159
  }
@@ -1,8 +1,8 @@
1
1
  (() => {
2
2
  const selector = 'a[data-prefetch="hover"][href]';
3
- const prefetched = new Set();
3
+ const prefetched = new Set<string>();
4
4
 
5
- const prefetch = (href) => {
5
+ const prefetch = (href: string | null | undefined): void => {
6
6
  if (!href || prefetched.has(href)) {
7
7
  return;
8
8
  }
@@ -14,7 +14,7 @@
14
14
  document.head.append(link);
15
15
  };
16
16
 
17
- const onOver = (event) => {
17
+ const onOver = (event: Event): void => {
18
18
  const { target } = event;
19
19
  if (!(target instanceof Element)) {
20
20
  return;
@@ -3,15 +3,16 @@ const TOC_LINK_SELECTOR = 'a[data-toc-link="1"][href^="#"]';
3
3
  const TOC_SCROLL_CONTAINER_SELECTOR = '[data-toc-scroll-container="1"]';
4
4
 
5
5
  const NAVBAR_GAP_PX = 16;
6
- const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
6
+ const clamp = (value: number, min: number, max: number): number =>
7
+ Math.min(Math.max(value, min), max);
7
8
 
8
- const getTopOffset = () => {
9
+ const getTopOffset = (): number => {
9
10
  const header = document.querySelector("header");
10
11
  const headerHeight = header?.getBoundingClientRect().height ?? 0;
11
12
  return Math.ceil(headerHeight + NAVBAR_GAP_PX);
12
13
  };
13
14
 
14
- const decodeAnchorId = (href) => {
15
+ const decodeAnchorId = (href: string | null): string | null => {
15
16
  if (!href?.startsWith("#")) {
16
17
  return null;
17
18
  }
@@ -28,7 +29,13 @@ const decodeAnchorId = (href) => {
28
29
  }
29
30
  };
30
31
 
31
- const toEntry = (link) => {
32
+ interface TocEntry {
33
+ heading: HTMLElement;
34
+ link: HTMLAnchorElement;
35
+ y: number;
36
+ }
37
+
38
+ const toEntry = (link: HTMLAnchorElement): TocEntry | null => {
32
39
  const id = decodeAnchorId(link.getAttribute("href"));
33
40
  if (!id) {
34
41
  return null;
@@ -44,32 +51,39 @@ const toEntry = (link) => {
44
51
  return { heading, link, y: 0 };
45
52
  };
46
53
 
47
- const buildEntries = (tocRoot) =>
54
+ const buildEntries = (tocRoot: Element): TocEntry[] =>
48
55
  [...tocRoot.querySelectorAll(TOC_LINK_SELECTOR)]
56
+ .filter(
57
+ (link): link is HTMLAnchorElement => link instanceof HTMLAnchorElement
58
+ )
49
59
  .map(toEntry)
50
- .filter((entry) => entry !== null);
60
+ .filter((entry): entry is TocEntry => entry !== null);
51
61
 
52
- const measureEntries = (entries) => {
62
+ const measureEntries = (entries: TocEntry[]): void => {
53
63
  for (const entry of entries) {
54
64
  entry.y = entry.heading.getBoundingClientRect().top + window.scrollY;
55
65
  }
56
66
  };
57
67
 
58
- const setScrollMarginTop = (topOffset) => {
68
+ const setScrollMarginTop = (topOffset: number): void => {
59
69
  document.documentElement.style.setProperty(
60
70
  "--scroll-margin-top",
61
71
  `${topOffset}px`
62
72
  );
63
73
  };
64
74
 
65
- const binarySearchLastAtOrAbove = (entries, anchorLine) => {
75
+ const binarySearchLastAtOrAbove = (
76
+ entries: TocEntry[],
77
+ anchorLine: number
78
+ ): number => {
66
79
  let lo = 0;
67
80
  let hi = entries.length;
68
81
 
69
82
  // Find the first entry with y > anchorLine, then step back one.
70
83
  while (lo < hi) {
71
84
  const mid = Math.floor((lo + hi) / 2);
72
- if (entries[mid].y <= anchorLine) {
85
+ const midY = entries[mid]?.y ?? Number.POSITIVE_INFINITY;
86
+ if (midY <= anchorLine) {
73
87
  lo = mid + 1;
74
88
  } else {
75
89
  hi = mid;
@@ -79,28 +93,30 @@ const binarySearchLastAtOrAbove = (entries, anchorLine) => {
79
93
  return lo - 1;
80
94
  };
81
95
 
82
- const findActiveIndex = (entries, topOffset) => {
96
+ const findActiveIndex = (entries: TocEntry[], topOffset: number): number => {
83
97
  const anchorLine = window.scrollY + topOffset + 1;
84
98
  const best = binarySearchLastAtOrAbove(entries, anchorLine);
85
99
  return Math.max(0, best);
86
100
  };
87
101
 
88
- const parseTransformValues = (transform, prefix) =>
102
+ const parseTransformValues = (transform: string, prefix: string): number[] =>
89
103
  transform
90
104
  .slice(prefix.length, -1)
91
105
  .split(",")
92
106
  .map((value) => Number.parseFloat(value.trim()));
93
107
 
94
- const getNumberAtIndex = (values, index) => {
95
- const [value] = values.slice(index, index + 1);
108
+ const getNumberAtIndex = (values: number[], index: number): number => {
109
+ const value = values[index] ?? Number.NaN;
96
110
  return Number.isFinite(value) ? value : 0;
97
111
  };
98
112
 
99
- const getMatrix3dTranslateY = (values) => getNumberAtIndex(values, 13);
113
+ const getMatrix3dTranslateY = (values: number[]): number =>
114
+ getNumberAtIndex(values, 13);
100
115
 
101
- const getMatrixTranslateY = (values) => getNumberAtIndex(values, 5);
116
+ const getMatrixTranslateY = (values: number[]): number =>
117
+ getNumberAtIndex(values, 5);
102
118
 
103
- const getComputedTranslateY = (element) => {
119
+ const getComputedTranslateY = (element: Element): number => {
104
120
  const { transform } = window.getComputedStyle(element);
105
121
  if (!transform || transform === "none") {
106
122
  return 0;
@@ -119,16 +135,20 @@ const getComputedTranslateY = (element) => {
119
135
  return 0;
120
136
  };
121
137
 
122
- const setTranslateY = (element, next) => {
138
+ const setTranslateY = (element: HTMLElement, next: number): void => {
123
139
  element.style.transform = `translate3d(0, ${next}px, 0)`;
124
140
  };
125
141
 
126
- const getElementCenterY = (element) => {
142
+ const getElementCenterY = (element: Element): number => {
127
143
  const rect = element.getBoundingClientRect();
128
144
  return rect.top + rect.height / 2;
129
145
  };
130
146
 
131
- const getCenteredTranslateY = (scrollContainer, list, link) => {
147
+ const getCenteredTranslateY = (
148
+ scrollContainer: HTMLElement,
149
+ list: HTMLElement,
150
+ link: HTMLAnchorElement
151
+ ): number => {
132
152
  const containerHeight = scrollContainer.clientHeight;
133
153
  const listHeight = list.scrollHeight;
134
154
 
@@ -142,7 +162,20 @@ const getCenteredTranslateY = (scrollContainer, list, link) => {
142
162
  return clamp(currentTranslate + delta, minTranslate, 0);
143
163
  };
144
164
 
145
- const centerLinkIfNeeded = (state, link) => {
165
+ interface ScrollSpyState {
166
+ activeIndex: number;
167
+ centerActiveItem: boolean;
168
+ entries: TocEntry[];
169
+ isTicking: boolean;
170
+ scrollContainer: HTMLElement | null;
171
+ tocList: HTMLElement | null;
172
+ topOffset: number;
173
+ }
174
+
175
+ const centerLinkIfNeeded = (
176
+ state: ScrollSpyState,
177
+ link: HTMLAnchorElement
178
+ ): void => {
146
179
  const { scrollContainer, tocList } = state;
147
180
  if (!scrollContainer || !tocList) {
148
181
  return;
@@ -157,7 +190,14 @@ const centerLinkIfNeeded = (state, link) => {
157
190
  setTranslateY(tocList, next);
158
191
  };
159
192
 
160
- const setActiveLink = (state, index) => {
193
+ const setCurrentLocationAttr = (entry: TocEntry | undefined): void => {
194
+ if (!entry) {
195
+ return;
196
+ }
197
+ entry.link.setAttribute("aria-current", "location");
198
+ };
199
+
200
+ const setActiveLink = (state: ScrollSpyState, index: number): void => {
161
201
  if (index === state.activeIndex) {
162
202
  return;
163
203
  }
@@ -167,18 +207,18 @@ const setActiveLink = (state, index) => {
167
207
 
168
208
  state.activeIndex = index;
169
209
  const current = state.entries[state.activeIndex];
170
- current.link.setAttribute("aria-current", "location");
210
+ setCurrentLocationAttr(current);
171
211
 
172
- if (state.centerActiveItem) {
212
+ if (state.centerActiveItem && current) {
173
213
  centerLinkIfNeeded(state, current.link);
174
214
  }
175
215
  };
176
216
 
177
- const updateActive = (state) => {
217
+ const updateActive = (state: ScrollSpyState): void => {
178
218
  setActiveLink(state, findActiveIndex(state.entries, state.topOffset));
179
219
  };
180
220
 
181
- const scheduleUpdate = (state) => {
221
+ const scheduleUpdate = (state: ScrollSpyState): void => {
182
222
  if (state.isTicking) {
183
223
  return;
184
224
  }
@@ -190,7 +230,7 @@ const scheduleUpdate = (state) => {
190
230
  });
191
231
  };
192
232
 
193
- const refreshLayout = (state) => {
233
+ const refreshLayout = (state: ScrollSpyState): void => {
194
234
  state.topOffset = getTopOffset();
195
235
  setScrollMarginTop(state.topOffset);
196
236
  measureEntries(state.entries);
@@ -204,7 +244,7 @@ const refreshLayout = (state) => {
204
244
  }
205
245
  };
206
246
 
207
- const createState = () => {
247
+ const createState = (): ScrollSpyState | null => {
208
248
  const { body } = document;
209
249
  const tocRoot = document.querySelector(TOC_ROOT_SELECTOR);
210
250
  const entries = tocRoot ? buildEntries(tocRoot) : [];
@@ -226,13 +266,14 @@ const createState = () => {
226
266
  centerActiveItem,
227
267
  entries,
228
268
  isTicking: false,
229
- scrollContainer,
230
- tocList,
269
+ scrollContainer:
270
+ scrollContainer instanceof HTMLElement ? scrollContainer : null,
271
+ tocList: tocList instanceof HTMLElement ? tocList : null,
231
272
  topOffset: getTopOffset(),
232
273
  };
233
274
  };
234
275
 
235
- const start = (state) => {
276
+ const start = (state: ScrollSpyState): void => {
236
277
  // Disable independent TOC scrolling whenever scrollspy is active.
237
278
  // The TOC list position is controlled by JS (either centered or left as-is).
238
279
  document.body.dataset.tocFollow = "1";
@@ -250,7 +291,7 @@ const start = (state) => {
250
291
  refreshLayout(state);
251
292
  };
252
293
 
253
- const init = () => {
294
+ const init = (): void => {
254
295
  const state = createState();
255
296
  if (!state) {
256
297
  return;
@@ -1,4 +1,4 @@
1
1
  // Optional: this file is here as the obvious place to put server-side code.
2
- // V1 runs the built-in idcmd server; add custom endpoints via `site/server/routes/**`.
2
+ // V1 runs the built-in idcmd server; add custom endpoints via `site/code/routes/**`.
3
3
 
4
4
  export const serverPlaceholder = true;