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.
- package/README.md +16 -16
- package/package.json +4 -4
- package/src/build.ts +38 -36
- package/src/cli/commands/build.ts +3 -3
- package/src/cli/commands/client.ts +6 -8
- package/src/cli/commands/deploy.ts +7 -7
- package/src/cli/commands/dev.ts +3 -3
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/commands/preview.ts +3 -3
- package/src/cli/runtime-assets.ts +2 -2
- package/src/content/paths.ts +1 -1
- package/src/project/paths.ts +18 -27
- package/src/render/layout-loader.ts +1 -1
- package/src/render/right-rail-loader.ts +1 -1
- package/src/search/index.ts +1 -1
- package/src/search/search-page-loader.ts +1 -1
- package/src/seo/server.ts +6 -6
- package/src/server/live-reload.ts +1 -1
- package/src/server/static.ts +3 -3
- package/src/server.ts +5 -5
- package/src/site/config.ts +1 -1
- package/templates/default/README.md +13 -13
- package/templates/default/{site/content → content}/index.md +1 -1
- package/templates/default/scripts/check-internal.ts +6 -6
- package/templates/default/scripts/check.ts +6 -6
- package/templates/default/src/runtime/live-reload.ts +18 -0
- package/templates/default/src/runtime/llm-menu.ts +162 -0
- package/templates/default/src/runtime/nav-prefetch.ts +30 -0
- package/templates/default/src/runtime/right-rail-scrollspy.ts +303 -0
- package/templates/default/{site/code → src}/server.ts +1 -1
- package/templates/default/vercel.json +1 -1
- package/public/anthropic-black.svg +0 -16
- package/public/openai-black.svg +0 -15
- package/templates/default/site/assets/anthropic-white.svg +0 -16
- package/templates/default/site/assets/favicon.svg +0 -13
- package/templates/default/site/assets/openai-white.svg +0 -15
- /package/{templates/default/site/code → src}/runtime/live-reload.ts +0 -0
- /package/{templates/default/site/code → src}/runtime/llm-menu.ts +0 -0
- /package/{templates/default/site/code → src}/runtime/nav-prefetch.ts +0 -0
- /package/{templates/default/site/code → src}/runtime/right-rail-scrollspy.ts +0 -0
- /package/{public → templates/default/assets}/anthropic-white.svg +0 -0
- /package/{public → templates/default/assets}/favicon.svg +0 -0
- /package/templates/default/{site/assets → assets}/icons/file.svg +0 -0
- /package/templates/default/{site/assets → assets}/icons/home.svg +0 -0
- /package/templates/default/{site/assets → assets}/icons/info.svg +0 -0
- /package/{public → templates/default/assets}/openai-white.svg +0 -0
- /package/templates/default/{site/content → content}/404.md +0 -0
- /package/templates/default/{site/content → content}/about.md +0 -0
- /package/templates/default/{site/site.jsonc → site.jsonc} +0 -0
- /package/templates/default/{site/code → src}/routes/api/hello.ts +0 -0
- /package/templates/default/{site/code → src}/ui/layout.tsx +0 -0
- /package/templates/default/{site/code → src}/ui/right-rail.tsx +0 -0
- /package/templates/default/{site/code → src}/ui/search-page.tsx +0 -0
- /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
|
|
28
|
-
const
|
|
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 = {
|
|
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
|
-
|
|
196
|
+
assetsDir: ASSETS_DIR,
|
|
197
197
|
isDev,
|
|
198
|
-
|
|
198
|
+
outputDir: OUTPUT_DIR,
|
|
199
199
|
staticCacheHeaders,
|
|
200
200
|
};
|
|
201
201
|
const userRoutesEnv = { isDev, routesDir: project.routesDir };
|
package/src/site/config.ts
CHANGED
|
@@ -91,7 +91,7 @@ export interface ResolvedRightRailConfig {
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
const SITE_CONFIG_PATH = "site
|
|
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
|
|
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: `
|
|
24
|
-
- Code: `
|
|
25
|
-
- Code: `
|
|
26
|
-
- Code: `
|
|
27
|
-
- Assets: `
|
|
28
|
-
- Styles source: `
|
|
29
|
-
- Config: `site
|
|
30
|
-
- Generated output: `
|
|
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 `
|
|
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 `
|
|
44
|
-
Runtime files in `
|
|
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 `
|
|
52
|
+
This produces a static `public/` directory for Vercel.
|
|
@@ -11,16 +11,16 @@ const checks: InternalCheck[] = [
|
|
|
11
11
|
run: () => fileExists("package.json"),
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
description: "site config must exist (site
|
|
15
|
-
run: () => fileExists("site
|
|
14
|
+
description: "site config must exist (site.jsonc)",
|
|
15
|
+
run: () => fileExists("site.jsonc"),
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
|
-
description: "tailwind input must exist (
|
|
19
|
-
run: () => fileExists("
|
|
18
|
+
description: "tailwind input must exist (styles/tailwind.css)",
|
|
19
|
+
run: () => fileExists("styles/tailwind.css"),
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
|
-
description: "
|
|
23
|
-
run: () => fileExists("
|
|
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
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"site
|
|
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 = ["
|
|
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 `
|
|
2
|
+
// V1 runs the built-in idcmd server; add custom endpoints via `src/routes/**`.
|
|
3
3
|
|
|
4
4
|
export const serverPlaceholder = true;
|