idcmd 0.0.1 → 0.0.3

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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/package.json +53 -6
  4. package/public/_idcmd/live-reload.js +18 -0
  5. package/public/_idcmd/llm-menu.js +153 -0
  6. package/public/_idcmd/nav-prefetch.js +30 -0
  7. package/public/_idcmd/right-rail-scrollspy.js +262 -0
  8. package/public/anthropic-black.svg +16 -0
  9. package/public/anthropic-white.svg +16 -0
  10. package/public/favicon.svg +13 -0
  11. package/public/live-reload.js +18 -0
  12. package/public/llm-menu.js +153 -0
  13. package/public/openai-black.svg +15 -0
  14. package/public/openai-white.svg +15 -0
  15. package/public/right-rail-scrollspy.js +262 -0
  16. package/src/build.ts +230 -0
  17. package/src/cli/args.ts +101 -0
  18. package/src/cli/commands/build.ts +43 -0
  19. package/src/cli/commands/deploy.ts +82 -0
  20. package/src/cli/commands/dev.ts +79 -0
  21. package/src/cli/commands/init.ts +211 -0
  22. package/src/cli/commands/preview.ts +60 -0
  23. package/src/cli/fs.ts +47 -0
  24. package/src/cli/main.ts +120 -0
  25. package/src/cli/normalize.ts +26 -0
  26. package/src/cli/path.ts +30 -0
  27. package/src/cli/prompt.ts +74 -0
  28. package/src/cli/run.ts +17 -0
  29. package/src/cli/version.ts +12 -0
  30. package/src/cli.ts +6 -0
  31. package/src/client/index.ts +7 -0
  32. package/src/content/components/expand.ts +351 -0
  33. package/src/content/components/install-tabs.ts +120 -0
  34. package/src/content/components/registry.ts +12 -0
  35. package/src/content/components/types.ts +21 -0
  36. package/src/content/frontmatter.ts +89 -0
  37. package/src/content/icons.ts +78 -0
  38. package/src/content/llms.ts +93 -0
  39. package/src/content/meta.ts +92 -0
  40. package/src/content/navigation.ts +154 -0
  41. package/src/content/paths.ts +34 -0
  42. package/src/content/store.ts +10 -0
  43. package/src/project/paths.ts +86 -0
  44. package/src/render/layout-loader.ts +46 -0
  45. package/src/render/layout.tsx +339 -0
  46. package/src/render/markdown.ts +14 -0
  47. package/src/render/page-renderer.ts +320 -0
  48. package/src/render/right-rail.tsx +249 -0
  49. package/src/render/toc.ts +66 -0
  50. package/src/search/api.ts +75 -0
  51. package/src/search/contract.ts +44 -0
  52. package/src/search/index.ts +264 -0
  53. package/src/search/page.tsx +96 -0
  54. package/src/search/server-page.ts +97 -0
  55. package/src/seo/files.ts +124 -0
  56. package/src/seo/server.ts +102 -0
  57. package/src/server/headers.ts +10 -0
  58. package/src/server/live-reload.ts +121 -0
  59. package/src/server/static.ts +59 -0
  60. package/src/server/user-routes.ts +212 -0
  61. package/src/server.ts +234 -0
  62. package/src/site/config.ts +244 -0
  63. package/src/site/url-policy.ts +60 -0
  64. package/src/site/urls.ts +46 -0
  65. package/templates/default/README.md +26 -0
  66. package/templates/default/package.json +29 -0
  67. package/templates/default/site/client/layout.tsx +2 -0
  68. package/templates/default/site/client/right-rail.tsx +1 -0
  69. package/templates/default/site/client/search-page.tsx +1 -0
  70. package/templates/default/site/content/404.md +8 -0
  71. package/templates/default/site/content/about.md +10 -0
  72. package/templates/default/site/content/index.md +10 -0
  73. package/templates/default/site/icons/file.svg +1 -0
  74. package/templates/default/site/icons/home.svg +1 -0
  75. package/templates/default/site/icons/info.svg +1 -0
  76. package/templates/default/site/public/_idcmd/live-reload.js +18 -0
  77. package/templates/default/site/public/_idcmd/llm-menu.js +153 -0
  78. package/templates/default/site/public/_idcmd/nav-prefetch.js +30 -0
  79. package/templates/default/site/public/_idcmd/right-rail-scrollspy.js +262 -0
  80. package/templates/default/site/public/anthropic-white.svg +16 -0
  81. package/templates/default/site/public/favicon.svg +13 -0
  82. package/templates/default/site/public/openai-white.svg +15 -0
  83. package/templates/default/site/server/routes/api/hello.ts +2 -0
  84. package/templates/default/site/server/server.ts +4 -0
  85. package/templates/default/site/site.jsonc +21 -0
  86. package/templates/default/site/styles/tailwind.css +452 -0
  87. package/templates/default/tsconfig.json +23 -0
  88. package/templates/default/vercel.json +7 -0
  89. package/index.js +0 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rusty.wtf
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,97 @@
1
- # idcmd
1
+ # idcmd (Markdown Site CLI, Bun + SSR)
2
2
 
3
- Placeholder package to reserve the name on npm. API and usage will be added later.
3
+ ## Create A Site
4
+
5
+ ```bash
6
+ bunx idcmd@latest init my-docs
7
+ cd my-docs
8
+ bun install
9
+ bun run dev
10
+ ```
11
+
12
+ Everything you edit lives in `site/`.
13
+
14
+ ## CLI
15
+
16
+ ```bash
17
+ idcmd init [dir] # scaffold a new site
18
+ idcmd dev # tailwind watch + SSR dev server
19
+ idcmd build # static dist/
20
+ idcmd preview # serve dist/ locally
21
+ idcmd deploy # build + validate Vercel static deploy config
22
+ ```
23
+
24
+ ## Layout (V1)
25
+
26
+ - `site/content/<slug>.md` -> `/<slug>/` (`index.md` -> `/`)
27
+ - `site/styles/tailwind.css` -> `site/public/styles.css` (dev) / `dist/styles.css` (build)
28
+ - `site/public/` static assets
29
+ - `site/server/routes/**` file-based server routes (dev/server-host only)
30
+ - `site/site.jsonc` site config
31
+
32
+ ## Example: Add A Page
33
+
34
+ Create `site/content/hello.md`:
35
+
36
+ ```md
37
+ ---
38
+ title: Hello
39
+ group: main
40
+ order: 99
41
+ icon: file
42
+ ---
43
+
44
+ # Hello
45
+
46
+ This is a new page.
47
+ ```
48
+
49
+ It renders at `/hello/`.
50
+
51
+ ## Custom Server Routes (V1)
52
+
53
+ Add `site/server/routes/api/hello.ts`:
54
+
55
+ ```ts
56
+ export const GET = (): Response => Response.json({ ok: true });
57
+ ```
58
+
59
+ It responds at `/api/hello`.
60
+
61
+ ## V1 Definition Of Done
62
+
63
+ `tickets/ROADMAP.md` is the source of truth. For V1, we explicitly target:
64
+
65
+ - Content routes ship `0` bytes of JavaScript by default (both SSR output and built HTML).
66
+ - Search index size `<= 5 MB` for `<= 2,000` pages.
67
+ - Build completes in `<= 60s` for `<= 2,000` pages on a typical laptop.
68
+
69
+ ## URL Policy
70
+
71
+ - HTML pages are canonicalized to trailing-slash paths (example: `/about/`), except `/`.
72
+ - File-like paths (example: `/styles.css`, `/robots.txt`, `/index.md`) are not forced to trailing slash.
73
+
74
+ ## Invariants
75
+
76
+ ### Slug and path rules
77
+
78
+ - Content lives at `site/content/<slug>.md` (or legacy `content/<slug>.md`).
79
+ - `slug="index"` is the home page.
80
+ - Canonical HTML paths are `/` for index and `/<slug>/` otherwise.
81
+ - Markdown download paths exist in two forms:
82
+ - Flat: `/index.md` and `/<slug>.md`
83
+ - Flat: `/index.md` and `/<slug>.md`
84
+
85
+ ### baseUrl vs origin
86
+
87
+ - `site.baseUrl` is normalized to an origin (protocol + host + optional port). Any path/query/hash in config is stripped.
88
+ - Dev server canonicals always use the request `origin` (localhost should not emit production canonicals).
89
+ - Non-dev/server canonicals prefer `site.baseUrl` and fall back to request `origin`.
90
+
91
+ ### JS policy
92
+
93
+ - Content routes ship `0` bytes of JavaScript by default.
94
+ - Allowed scripts:
95
+ - Dev only: `/_idcmd/live-reload.js`
96
+ - Optional: `/_idcmd/right-rail-scrollspy.js` only when scrollspy is enabled and the computed TOC is non-empty
97
+ - Search page is SSR-only by default (no client JS).
package/package.json CHANGED
@@ -1,11 +1,58 @@
1
1
  {
2
2
  "name": "idcmd",
3
- "version": "0.0.1",
4
- "description": "",
5
- "license": "MIT",
6
- "main": "index.js",
3
+ "version": "0.0.3",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/rustydotwtf/idcmd"
7
+ },
8
+ "bin": {
9
+ "idcmd": "src/cli.ts"
10
+ },
7
11
  "files": [
8
- "index.js",
12
+ "src",
13
+ "templates",
14
+ "public",
15
+ "LICENSE",
9
16
  "README.md"
10
- ]
17
+ ],
18
+ "type": "module",
19
+ "module": "src/server.ts",
20
+ "exports": {
21
+ "./client": "./src/client/index.ts"
22
+ },
23
+ "scripts": {
24
+ "dev": "concurrently -k -n css,server -c blue,green \"bun run dev:css\" \"bun run dev:server\"",
25
+ "dev:css": "bunx @tailwindcss/cli -i content/styles.css -o public/styles.css --watch",
26
+ "dev:server": "bun --hot src/server.ts",
27
+ "build:css": "bunx @tailwindcss/cli -i content/styles.css -o dist/styles.css --minify",
28
+ "build": "bun run build:css && bun src/build.ts",
29
+ "preview": "bunx serve dist",
30
+ "start": "bun src/server.ts",
31
+ "check": "ultracite check && bun run typecheck && bun run test",
32
+ "test": "bun test",
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "fix": "ultracite fix",
35
+ "prepare": "lefthook install"
36
+ },
37
+ "dependencies": {
38
+ "preact": "^10.28.3",
39
+ "preact-render-to-string": "^6.6.5",
40
+ "shiki": "^3.22.0",
41
+ "zod": "^3.24.0"
42
+ },
43
+ "devDependencies": {
44
+ "@tailwindcss/cli": "^4.1.18",
45
+ "@types/bun": "latest",
46
+ "@typescript/native-preview": "latest",
47
+ "concurrently": "^9.2.1",
48
+ "lefthook": "^2.1.0",
49
+ "oxfmt": "^0.28.0",
50
+ "oxlint": "^1.43.0",
51
+ "tailwindcss": "^4.1.18",
52
+ "typescript": "^5",
53
+ "ultracite": "7.1.4"
54
+ },
55
+ "peerDependencies": {
56
+ "typescript": "^5"
57
+ }
11
58
  }
@@ -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,153 @@
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, disabled) => {
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, next) => {
19
+ const label = link.querySelector(LABEL_SELECTOR);
20
+ if (label) {
21
+ label.textContent = next;
22
+ }
23
+ };
24
+
25
+ const toAbsoluteUrl = (href) => {
26
+ try {
27
+ return new URL(href, window.location.href).toString();
28
+ } catch {
29
+ return href;
30
+ }
31
+ };
32
+
33
+ const createHiddenTextarea = (text) => {
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 = () => {
44
+ try {
45
+ return document.execCommand("copy");
46
+ } catch {
47
+ return false;
48
+ }
49
+ };
50
+
51
+ const copyViaExecCommand = (text) => {
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) => {
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) => {
76
+ const details = link.closest("details");
77
+ if (details instanceof HTMLDetailsElement) {
78
+ details.open = false;
79
+ }
80
+ };
81
+
82
+ const getOriginalLabel = (link) =>
83
+ link.querySelector(LABEL_SELECTOR)?.textContent ??
84
+ "Copy Markdown to Clipboard";
85
+
86
+ const fetchMarkdownText = async (href) => {
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) => {
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) => {
107
+ setLinkDisabled(link, true);
108
+ setLinkLabel(link, "Copying...");
109
+ };
110
+
111
+ const finishCopyOperation = (link, ok) => {
112
+ setLinkLabel(link, ok ? "Copied" : "Copy failed");
113
+ closeMenuIfPresent(link);
114
+ };
115
+
116
+ const scheduleResetOperation = (link, originalLabel) => {
117
+ window.setTimeout(() => {
118
+ setLinkLabel(link, originalLabel);
119
+ setLinkDisabled(link, false);
120
+ }, RESET_DELAY_MS);
121
+ };
122
+
123
+ const handleCopyClick = async (link, originalLabel) => {
124
+ const href = link.getAttribute("href");
125
+ if (!href) {
126
+ return;
127
+ }
128
+
129
+ startCopyOperation(link);
130
+ const ok = await copyMarkdownFromHref(href);
131
+ finishCopyOperation(link, ok);
132
+ scheduleResetOperation(link, originalLabel);
133
+ };
134
+
135
+ const attachCopyHandler = (link) => {
136
+ const originalLabel = getOriginalLabel(link);
137
+ link.addEventListener("click", async (event) => {
138
+ event.preventDefault();
139
+ if (link.getAttribute("aria-disabled") === "true") {
140
+ return;
141
+ }
142
+ await handleCopyClick(link, originalLabel);
143
+ });
144
+ };
145
+
146
+ const initCopyMarkdownButtons = () => {
147
+ const links = [...document.querySelectorAll(COPY_SELECTOR)];
148
+ for (const link of links) {
149
+ attachCopyHandler(link);
150
+ }
151
+ };
152
+
153
+ initCopyMarkdownButtons();
@@ -0,0 +1,30 @@
1
+ (() => {
2
+ const selector = 'a[data-prefetch="hover"][href]';
3
+ const prefetched = new Set();
4
+
5
+ const prefetch = (href) => {
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) => {
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,262 @@
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, min, max) => Math.min(Math.max(value, min), max);
7
+
8
+ const getTopOffset = () => {
9
+ const header = document.querySelector("header");
10
+ const headerHeight = header?.getBoundingClientRect().height ?? 0;
11
+ return Math.ceil(headerHeight + NAVBAR_GAP_PX);
12
+ };
13
+
14
+ const decodeAnchorId = (href) => {
15
+ if (!href?.startsWith("#")) {
16
+ return null;
17
+ }
18
+
19
+ const raw = href.slice(1);
20
+ if (!raw) {
21
+ return null;
22
+ }
23
+
24
+ try {
25
+ return decodeURIComponent(raw);
26
+ } catch {
27
+ return raw;
28
+ }
29
+ };
30
+
31
+ const toEntry = (link) => {
32
+ const id = decodeAnchorId(link.getAttribute("href"));
33
+ if (!id) {
34
+ return null;
35
+ }
36
+
37
+ // ids like "11-overview" are valid HTML ids but invalid CSS selectors unless escaped.
38
+ // eslint-disable-next-line unicorn/prefer-query-selector
39
+ const heading = document.getElementById(id);
40
+ if (!heading) {
41
+ return null;
42
+ }
43
+
44
+ return { heading, link, y: 0 };
45
+ };
46
+
47
+ const buildEntries = (tocRoot) =>
48
+ [...tocRoot.querySelectorAll(TOC_LINK_SELECTOR)]
49
+ .map(toEntry)
50
+ .filter((entry) => entry !== null);
51
+
52
+ const measureEntries = (entries) => {
53
+ for (const entry of entries) {
54
+ entry.y = entry.heading.getBoundingClientRect().top + window.scrollY;
55
+ }
56
+ };
57
+
58
+ const setScrollMarginTop = (topOffset) => {
59
+ document.documentElement.style.setProperty(
60
+ "--scroll-margin-top",
61
+ `${topOffset}px`
62
+ );
63
+ };
64
+
65
+ const binarySearchLastAtOrAbove = (entries, anchorLine) => {
66
+ let lo = 0;
67
+ let hi = entries.length;
68
+
69
+ // Find the first entry with y > anchorLine, then step back one.
70
+ while (lo < hi) {
71
+ const mid = Math.floor((lo + hi) / 2);
72
+ if (entries[mid].y <= anchorLine) {
73
+ lo = mid + 1;
74
+ } else {
75
+ hi = mid;
76
+ }
77
+ }
78
+
79
+ return lo - 1;
80
+ };
81
+
82
+ const findActiveIndex = (entries, topOffset) => {
83
+ const anchorLine = window.scrollY + topOffset + 1;
84
+ const best = binarySearchLastAtOrAbove(entries, anchorLine);
85
+ return Math.max(0, best);
86
+ };
87
+
88
+ const parseTransformValues = (transform, prefix) =>
89
+ transform
90
+ .slice(prefix.length, -1)
91
+ .split(",")
92
+ .map((value) => Number.parseFloat(value.trim()));
93
+
94
+ const getNumberAtIndex = (values, index) => {
95
+ const [value] = values.slice(index, index + 1);
96
+ return Number.isFinite(value) ? value : 0;
97
+ };
98
+
99
+ const getMatrix3dTranslateY = (values) => getNumberAtIndex(values, 13);
100
+
101
+ const getMatrixTranslateY = (values) => getNumberAtIndex(values, 5);
102
+
103
+ const getComputedTranslateY = (element) => {
104
+ const { transform } = window.getComputedStyle(element);
105
+ if (!transform || transform === "none") {
106
+ return 0;
107
+ }
108
+
109
+ if (transform.startsWith("matrix3d(")) {
110
+ const values = parseTransformValues(transform, "matrix3d(");
111
+ return getMatrix3dTranslateY(values);
112
+ }
113
+
114
+ if (transform.startsWith("matrix(")) {
115
+ const values = parseTransformValues(transform, "matrix(");
116
+ return getMatrixTranslateY(values);
117
+ }
118
+
119
+ return 0;
120
+ };
121
+
122
+ const setTranslateY = (element, next) => {
123
+ element.style.transform = `translate3d(0, ${next}px, 0)`;
124
+ };
125
+
126
+ const getElementCenterY = (element) => {
127
+ const rect = element.getBoundingClientRect();
128
+ return rect.top + rect.height / 2;
129
+ };
130
+
131
+ const getCenteredTranslateY = (scrollContainer, list, link) => {
132
+ const containerHeight = scrollContainer.clientHeight;
133
+ const listHeight = list.scrollHeight;
134
+
135
+ if (listHeight <= containerHeight + 1) {
136
+ return 0;
137
+ }
138
+
139
+ const delta = getElementCenterY(scrollContainer) - getElementCenterY(link);
140
+ const currentTranslate = getComputedTranslateY(list);
141
+ const minTranslate = containerHeight - listHeight;
142
+ return clamp(currentTranslate + delta, minTranslate, 0);
143
+ };
144
+
145
+ const centerLinkIfNeeded = (state, link) => {
146
+ const { scrollContainer, tocList } = state;
147
+ if (!scrollContainer || !tocList) {
148
+ return;
149
+ }
150
+
151
+ const next = getCenteredTranslateY(scrollContainer, tocList, link);
152
+ const current = getComputedTranslateY(tocList);
153
+ if (Math.abs(current - next) < 0.5) {
154
+ return;
155
+ }
156
+
157
+ setTranslateY(tocList, next);
158
+ };
159
+
160
+ const setActiveLink = (state, index) => {
161
+ if (index === state.activeIndex) {
162
+ return;
163
+ }
164
+
165
+ const previous = state.entries[state.activeIndex];
166
+ previous?.link.removeAttribute("aria-current");
167
+
168
+ state.activeIndex = index;
169
+ const current = state.entries[state.activeIndex];
170
+ current.link.setAttribute("aria-current", "location");
171
+
172
+ if (state.centerActiveItem) {
173
+ centerLinkIfNeeded(state, current.link);
174
+ }
175
+ };
176
+
177
+ const updateActive = (state) => {
178
+ setActiveLink(state, findActiveIndex(state.entries, state.topOffset));
179
+ };
180
+
181
+ const scheduleUpdate = (state) => {
182
+ if (state.isTicking) {
183
+ return;
184
+ }
185
+
186
+ state.isTicking = true;
187
+ requestAnimationFrame(() => {
188
+ state.isTicking = false;
189
+ updateActive(state);
190
+ });
191
+ };
192
+
193
+ const refreshLayout = (state) => {
194
+ state.topOffset = getTopOffset();
195
+ setScrollMarginTop(state.topOffset);
196
+ measureEntries(state.entries);
197
+ updateActive(state);
198
+
199
+ if (state.centerActiveItem) {
200
+ const current = state.entries[state.activeIndex];
201
+ if (current) {
202
+ centerLinkIfNeeded(state, current.link);
203
+ }
204
+ }
205
+ };
206
+
207
+ const createState = () => {
208
+ const { body } = document;
209
+ const tocRoot = document.querySelector(TOC_ROOT_SELECTOR);
210
+ const entries = tocRoot ? buildEntries(tocRoot) : [];
211
+ if (
212
+ !body ||
213
+ body.dataset.scrollspy !== "1" ||
214
+ !tocRoot ||
215
+ entries.length === 0
216
+ ) {
217
+ return null;
218
+ }
219
+
220
+ const centerActiveItem = body.dataset.scrollspyCenter === "1";
221
+ const scrollContainer = tocRoot.querySelector(TOC_SCROLL_CONTAINER_SELECTOR);
222
+ const tocList = scrollContainer?.querySelector("ul") ?? null;
223
+
224
+ return {
225
+ activeIndex: -1,
226
+ centerActiveItem,
227
+ entries,
228
+ isTicking: false,
229
+ scrollContainer,
230
+ tocList,
231
+ topOffset: getTopOffset(),
232
+ };
233
+ };
234
+
235
+ const start = (state) => {
236
+ // Disable independent TOC scrolling whenever scrollspy is active.
237
+ // The TOC list position is controlled by JS (either centered or left as-is).
238
+ document.body.dataset.tocFollow = "1";
239
+
240
+ window.addEventListener("scroll", () => scheduleUpdate(state), {
241
+ passive: true,
242
+ });
243
+ window.addEventListener("resize", () => refreshLayout(state));
244
+ window.addEventListener("load", () => {
245
+ refreshLayout(state);
246
+ setTimeout(() => refreshLayout(state), 250);
247
+ setTimeout(() => refreshLayout(state), 1000);
248
+ });
249
+
250
+ refreshLayout(state);
251
+ };
252
+
253
+ const init = () => {
254
+ const state = createState();
255
+ if (!state) {
256
+ return;
257
+ }
258
+
259
+ start(state);
260
+ };
261
+
262
+ init();
@@ -0,0 +1,16 @@
1
+ <svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
2
+ <style type="text/css">
3
+ .st0{fill:#181818;}
4
+ </style>
5
+ <metadata>
6
+ <sfw xmlns="ns_sfw;">
7
+ <slices>
8
+ </slices>
9
+ <sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
10
+ </sliceSourceBounds>
11
+ </sfw>
12
+ </metadata>
13
+ <path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
14
+ M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
15
+ </path>
16
+ </svg>
@@ -0,0 +1,16 @@
1
+ <svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
2
+ <style type="text/css">
3
+ .st0{fill:#FFFFFF;}
4
+ </style>
5
+ <metadata>
6
+ <sfw xmlns="ns_sfw;">
7
+ <slices>
8
+ </slices>
9
+ <sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
10
+ </sliceSourceBounds>
11
+ </sfw>
12
+ </metadata>
13
+ <path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
14
+ M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
15
+ </path>
16
+ </svg>
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="IDC favicon">
2
+ <rect width="64" height="64" rx="12" fill="#0B0B0B" />
3
+ <text
4
+ x="32"
5
+ y="40"
6
+ font-family="JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
7
+ font-size="22"
8
+ text-anchor="middle"
9
+ fill="#F9FAFB"
10
+ >
11
+ idc
12
+ </text>
13
+ </svg>