idcmd 0.0.7 → 0.0.9

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 (54) hide show
  1. package/README.md +16 -16
  2. package/package.json +4 -4
  3. package/src/build.ts +38 -36
  4. package/src/cli/commands/build.ts +3 -3
  5. package/src/cli/commands/client.ts +6 -8
  6. package/src/cli/commands/deploy.ts +7 -7
  7. package/src/cli/commands/dev.ts +3 -3
  8. package/src/cli/commands/init.ts +1 -1
  9. package/src/cli/commands/preview.ts +3 -3
  10. package/src/cli/runtime-assets.ts +2 -2
  11. package/src/content/paths.ts +1 -1
  12. package/src/project/paths.ts +18 -27
  13. package/src/render/layout-loader.ts +1 -1
  14. package/src/render/right-rail-loader.ts +1 -1
  15. package/src/search/index.ts +1 -1
  16. package/src/search/search-page-loader.ts +1 -1
  17. package/src/seo/server.ts +6 -6
  18. package/src/server/live-reload.ts +1 -1
  19. package/src/server/static.ts +3 -3
  20. package/src/server.ts +5 -5
  21. package/src/site/config.ts +1 -1
  22. package/templates/default/README.md +13 -13
  23. package/templates/default/{site/content → content}/index.md +1 -1
  24. package/templates/default/scripts/check-internal.ts +6 -6
  25. package/templates/default/scripts/check.ts +6 -6
  26. package/templates/default/src/runtime/live-reload.ts +18 -0
  27. package/templates/default/src/runtime/llm-menu.ts +162 -0
  28. package/templates/default/src/runtime/nav-prefetch.ts +30 -0
  29. package/templates/default/src/runtime/right-rail-scrollspy.ts +303 -0
  30. package/templates/default/{site/code → src}/server.ts +1 -1
  31. package/templates/default/vercel.json +1 -1
  32. package/public/anthropic-black.svg +0 -16
  33. package/public/openai-black.svg +0 -15
  34. package/templates/default/site/assets/anthropic-white.svg +0 -16
  35. package/templates/default/site/assets/favicon.svg +0 -13
  36. package/templates/default/site/assets/openai-white.svg +0 -15
  37. /package/{templates/default/site/code → src}/runtime/live-reload.ts +0 -0
  38. /package/{templates/default/site/code → src}/runtime/llm-menu.ts +0 -0
  39. /package/{templates/default/site/code → src}/runtime/nav-prefetch.ts +0 -0
  40. /package/{templates/default/site/code → src}/runtime/right-rail-scrollspy.ts +0 -0
  41. /package/{public → templates/default/assets}/anthropic-white.svg +0 -0
  42. /package/{public → templates/default/assets}/favicon.svg +0 -0
  43. /package/templates/default/{site/assets → assets}/icons/file.svg +0 -0
  44. /package/templates/default/{site/assets → assets}/icons/home.svg +0 -0
  45. /package/templates/default/{site/assets → assets}/icons/info.svg +0 -0
  46. /package/{public → templates/default/assets}/openai-white.svg +0 -0
  47. /package/templates/default/{site/content → content}/404.md +0 -0
  48. /package/templates/default/{site/content → content}/about.md +0 -0
  49. /package/templates/default/{site/site.jsonc → site.jsonc} +0 -0
  50. /package/templates/default/{site/code → src}/routes/api/hello.ts +0 -0
  51. /package/templates/default/{site/code → src}/ui/layout.tsx +0 -0
  52. /package/templates/default/{site/code → src}/ui/right-rail.tsx +0 -0
  53. /package/templates/default/{site/code → src}/ui/search-page.tsx +0 -0
  54. /package/templates/default/{site/styles → styles}/tailwind.css +0 -0
package/src/server.ts CHANGED
@@ -24,8 +24,8 @@ import {
24
24
  type ServerInstance = Server<undefined>;
25
25
 
26
26
  const project = await getProjectPaths();
27
- const PUBLIC_DIR = project.publicDir;
28
- const DIST_DIR = project.distDir;
27
+ const ASSETS_DIR = project.assetsDir;
28
+ const OUTPUT_DIR = project.outputDir;
29
29
  const isDev = process.env.NODE_ENV !== "production";
30
30
  const LIVE_RELOAD_POLL_MS = 250;
31
31
  const MIN_SEARCH_QUERY_LENGTH = 2;
@@ -185,7 +185,7 @@ const handleRequest = async (
185
185
  return undefined;
186
186
  }
187
187
 
188
- const seoEnv = { distDir: DIST_DIR, isDev, staticCacheHeaders };
188
+ const seoEnv = { isDev, outputDir: OUTPUT_DIR, staticCacheHeaders };
189
189
  const searchPageEnv = {
190
190
  cacheHeaders,
191
191
  isDev,
@@ -193,9 +193,9 @@ const handleRequest = async (
193
193
  minQueryLength: MIN_SEARCH_QUERY_LENGTH,
194
194
  };
195
195
  const staticEnv = {
196
- distDir: DIST_DIR,
196
+ assetsDir: ASSETS_DIR,
197
197
  isDev,
198
- publicDir: PUBLIC_DIR,
198
+ outputDir: OUTPUT_DIR,
199
199
  staticCacheHeaders,
200
200
  };
201
201
  const userRoutesEnv = { isDev, routesDir: project.routesDir };
@@ -91,7 +91,7 @@ export interface ResolvedRightRailConfig {
91
91
  };
92
92
  }
93
93
 
94
- const SITE_CONFIG_PATH = "site/site.jsonc";
94
+ const SITE_CONFIG_PATH = "site.jsonc";
95
95
  const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
96
96
 
97
97
  const DEFAULT_RIGHT_RAIL_CONFIG: ResolvedRightRailConfig = {
@@ -1,6 +1,6 @@
1
1
  # **IDCMD_SITE_NAME**
2
2
 
3
- Everything you edit lives in `site/`.
3
+ Everything you edit lives in root-level source folders.
4
4
 
5
5
  This starter is intentionally opinionated for AI-friendly markdown sites.
6
6
 
@@ -20,16 +20,16 @@ bun run smoke
20
20
 
21
21
  ## Layout
22
22
 
23
- - Content: `site/content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
24
- - Code: `site/code/ui/` (`layout.tsx`, `right-rail.tsx`, `search-page.tsx`)
25
- - Code: `site/code/runtime/` browser runtime TS (`*_idcmd` scripts compile from here)
26
- - Code: `site/code/routes/` file-based server routes (dev/server-host only)
27
- - Assets: `site/assets/` static files you own (icons, images, favicon, etc.)
28
- - Styles source: `site/styles/tailwind.css`
29
- - Config: `site/site.jsonc`
30
- - Generated output: `dist/` (`dist/styles.css`, `dist/_idcmd/*.js`, built pages)
23
+ - Content: `content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
24
+ - Code: `src/ui/` (`layout.tsx`, `right-rail.tsx`, `search-page.tsx`)
25
+ - Code: `src/runtime/` browser runtime TS (`*_idcmd` scripts compile from here)
26
+ - Code: `src/routes/` file-based server routes (dev/server-host only)
27
+ - Assets: `assets/` static files you own (icons, images, favicon, etc.)
28
+ - Styles source: `styles/tailwind.css`
29
+ - Config: `site.jsonc`
30
+ - Generated output: `public/` (`public/styles.css`, `public/_idcmd/*.js`, built pages)
31
31
 
32
- The mental model is simple: edit `site/content` and `site/code`, treat `dist/` as generated output.
32
+ The mental model is simple: edit `content` and `src`, treat `public/` as generated output.
33
33
 
34
34
  ## Sync Local Client Files
35
35
 
@@ -40,8 +40,8 @@ idcmd client update layout --yes
40
40
  idcmd client update runtime --yes
41
41
  ```
42
42
 
43
- These commands copy the latest baseline implementations from `idcmd` into `site/code/ui/` and `site/code/runtime/`.
44
- Runtime files in `site/code/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
43
+ These commands copy the latest baseline implementations from `idcmd` into `src/ui/` and `src/runtime/`.
44
+ Runtime files in `src/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
45
45
 
46
46
  ## Deploy (Vercel static)
47
47
 
@@ -49,4 +49,4 @@ Runtime files in `site/code/runtime/` are compiled automatically by `idcmd dev`
49
49
  bun run build
50
50
  ```
51
51
 
52
- This produces a static `dist/` directory for Vercel.
52
+ This produces a static `public/` directory for Vercel.
@@ -7,4 +7,4 @@ icon: home
7
7
 
8
8
  # **IDCMD_SITE_NAME**
9
9
 
10
- Edit markdown in `site/content/`.
10
+ Edit markdown in `content/`.
@@ -11,16 +11,16 @@ const checks: InternalCheck[] = [
11
11
  run: () => fileExists("package.json"),
12
12
  },
13
13
  {
14
- description: "site config must exist (site/site.jsonc)",
15
- run: () => fileExists("site/site.jsonc"),
14
+ description: "site config must exist (site.jsonc)",
15
+ run: () => fileExists("site.jsonc"),
16
16
  },
17
17
  {
18
- description: "tailwind input must exist (site/styles/tailwind.css)",
19
- run: () => fileExists("site/styles/tailwind.css"),
18
+ description: "tailwind input must exist (styles/tailwind.css)",
19
+ run: () => fileExists("styles/tailwind.css"),
20
20
  },
21
21
  {
22
- description: "site code UI entry must exist (site/code/ui/layout.tsx)",
23
- run: () => fileExists("site/code/ui/layout.tsx"),
22
+ description: "source UI entry must exist (src/ui/layout.tsx)",
23
+ run: () => fileExists("src/ui/layout.tsx"),
24
24
  },
25
25
  ];
26
26
 
@@ -36,11 +36,11 @@ const LINT_TARGETS = [
36
36
  ".oxlintrc.json",
37
37
  ".oxfmtrc.jsonc",
38
38
  "scripts",
39
- "site/code",
40
- "site/content",
41
- "site/assets",
42
- "site/styles",
43
- "site/site.jsonc",
39
+ "src",
40
+ "content",
41
+ "assets",
42
+ "styles",
43
+ "site.jsonc",
44
44
  ];
45
45
  const ROOT_TEST_FILE_PATTERNS = [
46
46
  "*.test.ts",
@@ -78,7 +78,7 @@ const NESTED_TEST_FILE_PATTERNS = [
78
78
  "**/*_spec_*.js",
79
79
  "**/*_spec_*.jsx",
80
80
  ];
81
- const TEST_SOURCE_DIRS = ["site", "scripts", "src", "tests"];
81
+ const TEST_SOURCE_DIRS = ["content", "scripts", "src", "tests"];
82
82
 
83
83
  const buildScopedTestFilePatterns = (): string[] => {
84
84
  const patterns = [...ROOT_TEST_FILE_PATTERNS];
@@ -0,0 +1,18 @@
1
+ (() => {
2
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
3
+ const socket = new WebSocket(
4
+ `${protocol}//${location.host}/_idcmd/live-reload`
5
+ );
6
+
7
+ socket.addEventListener("message", (event) => {
8
+ if (event.data === "reload") {
9
+ location.reload();
10
+ }
11
+ });
12
+
13
+ socket.addEventListener("close", () => {
14
+ setTimeout(() => {
15
+ location.reload();
16
+ }, 1000);
17
+ });
18
+ })();
@@ -0,0 +1,162 @@
1
+ const COPY_SELECTOR = 'a[data-copy-markdown="1"]';
2
+ const LABEL_SELECTOR = '[data-copy-markdown-label="1"]';
3
+ const RESET_DELAY_MS = 2000;
4
+
5
+ const setLinkDisabled = (link: HTMLAnchorElement, disabled: boolean): void => {
6
+ if (disabled) {
7
+ link.setAttribute("aria-disabled", "true");
8
+ link.style.pointerEvents = "none";
9
+ link.style.opacity = "0.8";
10
+ return;
11
+ }
12
+
13
+ link.removeAttribute("aria-disabled");
14
+ link.style.pointerEvents = "";
15
+ link.style.opacity = "";
16
+ };
17
+
18
+ const setLinkLabel = (link: HTMLAnchorElement, next: string): void => {
19
+ const label = link.querySelector(LABEL_SELECTOR);
20
+ if (label) {
21
+ label.textContent = next;
22
+ }
23
+ };
24
+
25
+ const toAbsoluteUrl = (href: string): string => {
26
+ try {
27
+ return new URL(href, window.location.href).toString();
28
+ } catch {
29
+ return href;
30
+ }
31
+ };
32
+
33
+ const createHiddenTextarea = (text: string): HTMLTextAreaElement => {
34
+ const textarea = document.createElement("textarea");
35
+ textarea.value = text;
36
+ textarea.setAttribute("readonly", "true");
37
+ textarea.style.position = "fixed";
38
+ textarea.style.left = "-9999px";
39
+ textarea.style.top = "0";
40
+ return textarea;
41
+ };
42
+
43
+ const safeExecCommandCopy = (): boolean => {
44
+ try {
45
+ return document.execCommand("copy");
46
+ } catch {
47
+ return false;
48
+ }
49
+ };
50
+
51
+ const copyViaExecCommand = (text: string): boolean => {
52
+ const textarea = createHiddenTextarea(text);
53
+ document.body.append(textarea);
54
+ textarea.focus();
55
+ textarea.select();
56
+ const ok = safeExecCommandCopy();
57
+ textarea.remove();
58
+ return ok;
59
+ };
60
+
61
+ const copyText = async (text: string): Promise<boolean> => {
62
+ const { clipboard } = navigator;
63
+ if (clipboard?.writeText) {
64
+ try {
65
+ await clipboard.writeText(text);
66
+ return true;
67
+ } catch {
68
+ // Fall back below.
69
+ }
70
+ }
71
+
72
+ return copyViaExecCommand(text);
73
+ };
74
+
75
+ const closeMenuIfPresent = (link: HTMLAnchorElement): void => {
76
+ const details = link.closest("details");
77
+ if (details instanceof HTMLDetailsElement) {
78
+ details.open = false;
79
+ }
80
+ };
81
+
82
+ const getOriginalLabel = (link: HTMLAnchorElement): string =>
83
+ link.querySelector(LABEL_SELECTOR)?.textContent ??
84
+ "Copy Markdown to Clipboard";
85
+
86
+ const fetchMarkdownText = async (href: string): Promise<string | null> => {
87
+ const res = await fetch(toAbsoluteUrl(href), { credentials: "same-origin" });
88
+ if (!res.ok) {
89
+ return null;
90
+ }
91
+ return res.text();
92
+ };
93
+
94
+ const copyMarkdownFromHref = async (href: string): Promise<boolean> => {
95
+ try {
96
+ const text = await fetchMarkdownText(href);
97
+ if (!text) {
98
+ return false;
99
+ }
100
+ return copyText(text);
101
+ } catch {
102
+ return false;
103
+ }
104
+ };
105
+
106
+ const startCopyOperation = (link: HTMLAnchorElement): void => {
107
+ setLinkDisabled(link, true);
108
+ setLinkLabel(link, "Copying...");
109
+ };
110
+
111
+ const finishCopyOperation = (link: HTMLAnchorElement, ok: boolean): void => {
112
+ setLinkLabel(link, ok ? "Copied" : "Copy failed");
113
+ closeMenuIfPresent(link);
114
+ };
115
+
116
+ const scheduleResetOperation = (
117
+ link: HTMLAnchorElement,
118
+ originalLabel: string
119
+ ): void => {
120
+ window.setTimeout(() => {
121
+ setLinkLabel(link, originalLabel);
122
+ setLinkDisabled(link, false);
123
+ }, RESET_DELAY_MS);
124
+ };
125
+
126
+ const handleCopyClick = async (
127
+ link: HTMLAnchorElement,
128
+ originalLabel: string
129
+ ): Promise<void> => {
130
+ const href = link.getAttribute("href");
131
+ if (!href) {
132
+ return;
133
+ }
134
+
135
+ startCopyOperation(link);
136
+ const ok = await copyMarkdownFromHref(href);
137
+ finishCopyOperation(link, ok);
138
+ scheduleResetOperation(link, originalLabel);
139
+ };
140
+
141
+ const attachCopyHandler = (link: HTMLAnchorElement): void => {
142
+ const originalLabel = getOriginalLabel(link);
143
+ link.addEventListener("click", async (event) => {
144
+ event.preventDefault();
145
+ if (link.getAttribute("aria-disabled") === "true") {
146
+ return;
147
+ }
148
+ await handleCopyClick(link, originalLabel);
149
+ });
150
+ };
151
+
152
+ const initCopyMarkdownButtons = (): void => {
153
+ const links = [...document.querySelectorAll(COPY_SELECTOR)].filter(
154
+ (link): link is HTMLAnchorElement => link instanceof HTMLAnchorElement
155
+ );
156
+
157
+ for (const link of links) {
158
+ attachCopyHandler(link);
159
+ }
160
+ };
161
+
162
+ initCopyMarkdownButtons();
@@ -0,0 +1,30 @@
1
+ (() => {
2
+ const selector = 'a[data-prefetch="hover"][href]';
3
+ const prefetched = new Set<string>();
4
+
5
+ const prefetch = (href: string | null | undefined): void => {
6
+ if (!href || prefetched.has(href)) {
7
+ return;
8
+ }
9
+ prefetched.add(href);
10
+
11
+ const link = document.createElement("link");
12
+ link.rel = "prefetch";
13
+ link.href = href;
14
+ document.head.append(link);
15
+ };
16
+
17
+ const onOver = (event: Event): void => {
18
+ const { target } = event;
19
+ if (!(target instanceof Element)) {
20
+ return;
21
+ }
22
+ const link = target.closest(selector);
23
+ if (!(link instanceof HTMLAnchorElement)) {
24
+ return;
25
+ }
26
+ prefetch(link.href);
27
+ };
28
+
29
+ document.addEventListener("mouseover", onOver, { passive: true });
30
+ })();
@@ -0,0 +1,303 @@
1
+ const TOC_ROOT_SELECTOR = '[data-toc-root="1"]';
2
+ const TOC_LINK_SELECTOR = 'a[data-toc-link="1"][href^="#"]';
3
+ const TOC_SCROLL_CONTAINER_SELECTOR = '[data-toc-scroll-container="1"]';
4
+
5
+ const NAVBAR_GAP_PX = 16;
6
+ const clamp = (value: number, min: number, max: number): number =>
7
+ Math.min(Math.max(value, min), max);
8
+
9
+ const getTopOffset = (): number => {
10
+ const header = document.querySelector("header");
11
+ const headerHeight = header?.getBoundingClientRect().height ?? 0;
12
+ return Math.ceil(headerHeight + NAVBAR_GAP_PX);
13
+ };
14
+
15
+ const decodeAnchorId = (href: string | null): string | null => {
16
+ if (!href?.startsWith("#")) {
17
+ return null;
18
+ }
19
+
20
+ const raw = href.slice(1);
21
+ if (!raw) {
22
+ return null;
23
+ }
24
+
25
+ try {
26
+ return decodeURIComponent(raw);
27
+ } catch {
28
+ return raw;
29
+ }
30
+ };
31
+
32
+ interface TocEntry {
33
+ heading: HTMLElement;
34
+ link: HTMLAnchorElement;
35
+ y: number;
36
+ }
37
+
38
+ const toEntry = (link: HTMLAnchorElement): TocEntry | null => {
39
+ const id = decodeAnchorId(link.getAttribute("href"));
40
+ if (!id) {
41
+ return null;
42
+ }
43
+
44
+ // ids like "11-overview" are valid HTML ids but invalid CSS selectors unless escaped.
45
+ // eslint-disable-next-line unicorn/prefer-query-selector
46
+ const heading = document.getElementById(id);
47
+ if (!heading) {
48
+ return null;
49
+ }
50
+
51
+ return { heading, link, y: 0 };
52
+ };
53
+
54
+ const buildEntries = (tocRoot: Element): TocEntry[] =>
55
+ [...tocRoot.querySelectorAll(TOC_LINK_SELECTOR)]
56
+ .filter(
57
+ (link): link is HTMLAnchorElement => link instanceof HTMLAnchorElement
58
+ )
59
+ .map(toEntry)
60
+ .filter((entry): entry is TocEntry => entry !== null);
61
+
62
+ const measureEntries = (entries: TocEntry[]): void => {
63
+ for (const entry of entries) {
64
+ entry.y = entry.heading.getBoundingClientRect().top + window.scrollY;
65
+ }
66
+ };
67
+
68
+ const setScrollMarginTop = (topOffset: number): void => {
69
+ document.documentElement.style.setProperty(
70
+ "--scroll-margin-top",
71
+ `${topOffset}px`
72
+ );
73
+ };
74
+
75
+ const binarySearchLastAtOrAbove = (
76
+ entries: TocEntry[],
77
+ anchorLine: number
78
+ ): number => {
79
+ let lo = 0;
80
+ let hi = entries.length;
81
+
82
+ // Find the first entry with y > anchorLine, then step back one.
83
+ while (lo < hi) {
84
+ const mid = Math.floor((lo + hi) / 2);
85
+ const midY = entries[mid]?.y ?? Number.POSITIVE_INFINITY;
86
+ if (midY <= anchorLine) {
87
+ lo = mid + 1;
88
+ } else {
89
+ hi = mid;
90
+ }
91
+ }
92
+
93
+ return lo - 1;
94
+ };
95
+
96
+ const findActiveIndex = (entries: TocEntry[], topOffset: number): number => {
97
+ const anchorLine = window.scrollY + topOffset + 1;
98
+ const best = binarySearchLastAtOrAbove(entries, anchorLine);
99
+ return Math.max(0, best);
100
+ };
101
+
102
+ const parseTransformValues = (transform: string, prefix: string): number[] =>
103
+ transform
104
+ .slice(prefix.length, -1)
105
+ .split(",")
106
+ .map((value) => Number.parseFloat(value.trim()));
107
+
108
+ const getNumberAtIndex = (values: number[], index: number): number => {
109
+ const value = values[index] ?? Number.NaN;
110
+ return Number.isFinite(value) ? value : 0;
111
+ };
112
+
113
+ const getMatrix3dTranslateY = (values: number[]): number =>
114
+ getNumberAtIndex(values, 13);
115
+
116
+ const getMatrixTranslateY = (values: number[]): number =>
117
+ getNumberAtIndex(values, 5);
118
+
119
+ const getComputedTranslateY = (element: Element): number => {
120
+ const { transform } = window.getComputedStyle(element);
121
+ if (!transform || transform === "none") {
122
+ return 0;
123
+ }
124
+
125
+ if (transform.startsWith("matrix3d(")) {
126
+ const values = parseTransformValues(transform, "matrix3d(");
127
+ return getMatrix3dTranslateY(values);
128
+ }
129
+
130
+ if (transform.startsWith("matrix(")) {
131
+ const values = parseTransformValues(transform, "matrix(");
132
+ return getMatrixTranslateY(values);
133
+ }
134
+
135
+ return 0;
136
+ };
137
+
138
+ const setTranslateY = (element: HTMLElement, next: number): void => {
139
+ element.style.transform = `translate3d(0, ${next}px, 0)`;
140
+ };
141
+
142
+ const getElementCenterY = (element: Element): number => {
143
+ const rect = element.getBoundingClientRect();
144
+ return rect.top + rect.height / 2;
145
+ };
146
+
147
+ const getCenteredTranslateY = (
148
+ scrollContainer: HTMLElement,
149
+ list: HTMLElement,
150
+ link: HTMLAnchorElement
151
+ ): number => {
152
+ const containerHeight = scrollContainer.clientHeight;
153
+ const listHeight = list.scrollHeight;
154
+
155
+ if (listHeight <= containerHeight + 1) {
156
+ return 0;
157
+ }
158
+
159
+ const delta = getElementCenterY(scrollContainer) - getElementCenterY(link);
160
+ const currentTranslate = getComputedTranslateY(list);
161
+ const minTranslate = containerHeight - listHeight;
162
+ return clamp(currentTranslate + delta, minTranslate, 0);
163
+ };
164
+
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 => {
179
+ const { scrollContainer, tocList } = state;
180
+ if (!scrollContainer || !tocList) {
181
+ return;
182
+ }
183
+
184
+ const next = getCenteredTranslateY(scrollContainer, tocList, link);
185
+ const current = getComputedTranslateY(tocList);
186
+ if (Math.abs(current - next) < 0.5) {
187
+ return;
188
+ }
189
+
190
+ setTranslateY(tocList, next);
191
+ };
192
+
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 => {
201
+ if (index === state.activeIndex) {
202
+ return;
203
+ }
204
+
205
+ const previous = state.entries[state.activeIndex];
206
+ previous?.link.removeAttribute("aria-current");
207
+
208
+ state.activeIndex = index;
209
+ const current = state.entries[state.activeIndex];
210
+ setCurrentLocationAttr(current);
211
+
212
+ if (state.centerActiveItem && current) {
213
+ centerLinkIfNeeded(state, current.link);
214
+ }
215
+ };
216
+
217
+ const updateActive = (state: ScrollSpyState): void => {
218
+ setActiveLink(state, findActiveIndex(state.entries, state.topOffset));
219
+ };
220
+
221
+ const scheduleUpdate = (state: ScrollSpyState): void => {
222
+ if (state.isTicking) {
223
+ return;
224
+ }
225
+
226
+ state.isTicking = true;
227
+ requestAnimationFrame(() => {
228
+ state.isTicking = false;
229
+ updateActive(state);
230
+ });
231
+ };
232
+
233
+ const refreshLayout = (state: ScrollSpyState): void => {
234
+ state.topOffset = getTopOffset();
235
+ setScrollMarginTop(state.topOffset);
236
+ measureEntries(state.entries);
237
+ updateActive(state);
238
+
239
+ if (state.centerActiveItem) {
240
+ const current = state.entries[state.activeIndex];
241
+ if (current) {
242
+ centerLinkIfNeeded(state, current.link);
243
+ }
244
+ }
245
+ };
246
+
247
+ const createState = (): ScrollSpyState | null => {
248
+ const { body } = document;
249
+ const tocRoot = document.querySelector(TOC_ROOT_SELECTOR);
250
+ const entries = tocRoot ? buildEntries(tocRoot) : [];
251
+ if (
252
+ !body ||
253
+ body.dataset.scrollspy !== "1" ||
254
+ !tocRoot ||
255
+ entries.length === 0
256
+ ) {
257
+ return null;
258
+ }
259
+
260
+ const centerActiveItem = body.dataset.scrollspyCenter === "1";
261
+ const scrollContainer = tocRoot.querySelector(TOC_SCROLL_CONTAINER_SELECTOR);
262
+ const tocList = scrollContainer?.querySelector("ul") ?? null;
263
+
264
+ return {
265
+ activeIndex: -1,
266
+ centerActiveItem,
267
+ entries,
268
+ isTicking: false,
269
+ scrollContainer:
270
+ scrollContainer instanceof HTMLElement ? scrollContainer : null,
271
+ tocList: tocList instanceof HTMLElement ? tocList : null,
272
+ topOffset: getTopOffset(),
273
+ };
274
+ };
275
+
276
+ const start = (state: ScrollSpyState): void => {
277
+ // Disable independent TOC scrolling whenever scrollspy is active.
278
+ // The TOC list position is controlled by JS (either centered or left as-is).
279
+ document.body.dataset.tocFollow = "1";
280
+
281
+ window.addEventListener("scroll", () => scheduleUpdate(state), {
282
+ passive: true,
283
+ });
284
+ window.addEventListener("resize", () => refreshLayout(state));
285
+ window.addEventListener("load", () => {
286
+ refreshLayout(state);
287
+ setTimeout(() => refreshLayout(state), 250);
288
+ setTimeout(() => refreshLayout(state), 1000);
289
+ });
290
+
291
+ refreshLayout(state);
292
+ };
293
+
294
+ const init = (): void => {
295
+ const state = createState();
296
+ if (!state) {
297
+ return;
298
+ }
299
+
300
+ start(state);
301
+ };
302
+
303
+ init();
@@ -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/code/routes/**`.
2
+ // V1 runs the built-in idcmd server; add custom endpoints via `src/routes/**`.
3
3
 
4
4
  export const serverPlaceholder = true;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://openapi.vercel.sh/vercel.json",
3
3
  "buildCommand": "bun run build",
4
- "outputDirectory": "dist",
4
+ "outputDirectory": "public",
5
5
  "installCommand": "bun install",
6
6
  "bunVersion": "1.x"
7
7
  }