idcmd 0.0.8 → 0.0.10

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 (44) hide show
  1. package/README.md +13 -13
  2. package/package.json +3 -3
  3. package/src/build.ts +2 -2
  4. package/src/cli/commands/build.ts +2 -2
  5. package/src/cli/commands/client.ts +4 -6
  6. package/src/cli/commands/deploy.ts +3 -3
  7. package/src/cli/commands/dev.ts +2 -2
  8. package/src/cli/commands/init.ts +1 -1
  9. package/src/cli/runtime-assets.ts +1 -1
  10. package/src/content/paths.ts +1 -1
  11. package/src/project/paths.ts +10 -19
  12. package/src/render/layout-loader.ts +1 -1
  13. package/src/render/layout.tsx +1 -1
  14. package/src/render/right-rail-loader.ts +1 -1
  15. package/src/search/search-page-loader.ts +1 -1
  16. package/src/server/live-reload.ts +1 -1
  17. package/src/site/config.ts +1 -1
  18. package/templates/default/README.md +11 -11
  19. package/templates/default/{site/content → content}/index.md +1 -1
  20. package/templates/default/scripts/check-internal.ts +6 -6
  21. package/templates/default/scripts/check.ts +6 -6
  22. package/templates/default/src/runtime/live-reload.ts +18 -0
  23. package/templates/default/src/runtime/llm-menu.ts +162 -0
  24. package/templates/default/src/runtime/nav-prefetch.ts +30 -0
  25. package/templates/default/src/runtime/right-rail-scrollspy.ts +303 -0
  26. package/templates/default/{site/src → src}/server.ts +1 -1
  27. package/templates/default/{site/src → src}/ui/layout.tsx +1 -1
  28. package/templates/default/{site/styles → styles}/tailwind.css +2 -2
  29. /package/{templates/default/site/src → src}/runtime/live-reload.ts +0 -0
  30. /package/{templates/default/site/src → src}/runtime/llm-menu.ts +0 -0
  31. /package/{templates/default/site/src → src}/runtime/nav-prefetch.ts +0 -0
  32. /package/{templates/default/site/src → src}/runtime/right-rail-scrollspy.ts +0 -0
  33. /package/templates/default/{site/assets → assets}/anthropic-white.svg +0 -0
  34. /package/templates/default/{site/assets → assets}/favicon.svg +0 -0
  35. /package/templates/default/{site/assets → assets}/icons/file.svg +0 -0
  36. /package/templates/default/{site/assets → assets}/icons/home.svg +0 -0
  37. /package/templates/default/{site/assets → assets}/icons/info.svg +0 -0
  38. /package/templates/default/{site/assets → assets}/openai-white.svg +0 -0
  39. /package/templates/default/{site/content → content}/404.md +0 -0
  40. /package/templates/default/{site/content → content}/about.md +0 -0
  41. /package/templates/default/{site/site.jsonc → site.jsonc} +0 -0
  42. /package/templates/default/{site/src → src}/routes/api/hello.ts +0 -0
  43. /package/templates/default/{site/src → src}/ui/right-rail.tsx +0 -0
  44. /package/templates/default/{site/src → src}/ui/search-page.tsx +0 -0
package/README.md CHANGED
@@ -9,7 +9,7 @@ bun install
9
9
  bun run dev
10
10
  ```
11
11
 
12
- Everything you edit lives in `site/`.
12
+ Everything you edit lives at the project root-level source folders (`content/`, `src/`, `styles/`, `assets/`, `site.jsonc`).
13
13
 
14
14
  ## CLI
15
15
 
@@ -19,18 +19,18 @@ idcmd dev # tailwind watch + SSR dev server
19
19
  idcmd build # static public/
20
20
  idcmd preview # serve public/ locally
21
21
  idcmd deploy # build + validate Vercel static deploy config
22
- idcmd client ... # add/update local site/src implementations
22
+ idcmd client ... # add/update local src implementations
23
23
  ```
24
24
 
25
25
  ## Layout (V1)
26
26
 
27
- - `site/content/<slug>.md` -> `/<slug>/` (`index.md` -> `/`)
28
- - `site/src/ui/*` is local UI source code (you own and edit these files)
29
- - `site/src/runtime/*.ts` is local browser runtime code (compiled to `public/_idcmd/*.js`)
30
- - `site/src/routes/**` file-based server routes (dev/server-host only)
31
- - `site/styles/tailwind.css` -> `public/styles.css`
32
- - `site/assets/` static assets
33
- - `site/site.jsonc` site config
27
+ - `content/<slug>.md` -> `/<slug>/` (`index.md` -> `/`)
28
+ - `src/ui/*` is local UI source code (you own and edit these files)
29
+ - `src/runtime/*.ts` is local browser runtime code (compiled to `public/_idcmd/*.js`)
30
+ - `src/routes/**` file-based server routes (dev/server-host only)
31
+ - `styles/tailwind.css` -> `public/styles.css`
32
+ - `assets/` static assets
33
+ - `site.jsonc` site config
34
34
  - `public/` generated output (gitignored)
35
35
 
36
36
  ## Syncing Local Client Code
@@ -45,11 +45,11 @@ idcmd client update runtime --yes
45
45
  ```
46
46
 
47
47
  `add` creates missing files. `update` overwrites changed files and requires `--yes` unless `--dry-run` is used.
48
- Runtime files in `site/src/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
48
+ Runtime files in `src/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
49
49
 
50
50
  ## Example: Add A Page
51
51
 
52
- Create `site/content/hello.md`:
52
+ Create `content/hello.md`:
53
53
 
54
54
  ```md
55
55
  ---
@@ -68,7 +68,7 @@ It renders at `/hello/`.
68
68
 
69
69
  ## Custom Server Routes (V1)
70
70
 
71
- Add `site/src/routes/api/hello.ts`:
71
+ Add `src/routes/api/hello.ts`:
72
72
 
73
73
  ```ts
74
74
  export const GET = (): Response => Response.json({ ok: true });
@@ -94,7 +94,7 @@ It responds at `/api/hello`.
94
94
 
95
95
  ### Slug and path rules
96
96
 
97
- - Content lives at `site/content/<slug>.md`.
97
+ - Content lives at `content/<slug>.md`.
98
98
  - `slug="index"` is the home page.
99
99
  - Canonical HTML paths are `/` for index and `/<slug>/` otherwise.
100
100
  - Markdown download paths exist in two forms:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idcmd",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/rustydotwtf/idcmd"
@@ -22,9 +22,9 @@
22
22
  },
23
23
  "scripts": {
24
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 site/styles/tailwind.css -o public/styles.css --watch",
25
+ "dev:css": "bunx @tailwindcss/cli -i styles/tailwind.css -o public/styles.css --watch",
26
26
  "dev:server": "bun --hot src/server.ts",
27
- "build:css": "bunx @tailwindcss/cli -i site/styles/tailwind.css -o public/styles.css --minify",
27
+ "build:css": "bunx @tailwindcss/cli -i styles/tailwind.css -o public/styles.css --minify",
28
28
  "build": "bun run build:css && bun src/build.ts",
29
29
  "preview": "bunx serve public",
30
30
  "start": "bun src/server.ts",
package/src/build.ts CHANGED
@@ -20,7 +20,7 @@ const MIN_SEARCH_QUERY_LENGTH = 2;
20
20
 
21
21
  const project = await getProjectPaths();
22
22
 
23
- // Find all content files in `site/content/` (`site/content/<slug>.md`).
23
+ // Find all content files in `content/` (`content/<slug>.md`).
24
24
  const contentFiles: string[] = [];
25
25
 
26
26
  const buildStart = performance.now();
@@ -214,7 +214,7 @@ if (siteConfig.baseUrl) {
214
214
  console.log(` generated ${project.outputDir}/robots.txt`);
215
215
  } else {
216
216
  console.log(
217
- "Warning: site/site.jsonc missing baseUrl; skipping sitemap.xml and robots.txt."
217
+ "Warning: site.jsonc missing baseUrl; skipping sitemap.xml and robots.txt."
218
218
  );
219
219
  }
220
220
 
@@ -1,12 +1,12 @@
1
1
  import { compileRuntimeAssetsOnce } from "../runtime-assets";
2
2
 
3
3
  const findTailwindInput = async (): Promise<string> => {
4
- const path = "site/styles/tailwind.css";
4
+ const path = "styles/tailwind.css";
5
5
  if (await Bun.file(path).exists()) {
6
6
  return path;
7
7
  }
8
8
  throw new Error(
9
- "Could not find Tailwind input. Expected site/styles/tailwind.css."
9
+ "Could not find Tailwind input. Expected styles/tailwind.css."
10
10
  );
11
11
  };
12
12
 
@@ -8,7 +8,6 @@ const TEMPLATE_UI_DIR = joinPath(
8
8
  "..",
9
9
  "templates",
10
10
  "default",
11
- "site",
12
11
  "src",
13
12
  "ui"
14
13
  );
@@ -19,14 +18,13 @@ const TEMPLATE_RUNTIME_DIR = joinPath(
19
18
  "..",
20
19
  "templates",
21
20
  "default",
22
- "site",
23
21
  "src",
24
22
  "runtime"
25
23
  );
26
24
 
27
- const SITE_UI_DIR = joinPath("site", "src", "ui");
28
- const SITE_RUNTIME_DIR = joinPath("site", "src", "runtime");
29
- const SITE_CONFIG_PATH = joinPath("site", "site.jsonc");
25
+ const SITE_UI_DIR = joinPath("src", "ui");
26
+ const SITE_RUNTIME_DIR = joinPath("src", "runtime");
27
+ const SITE_CONFIG_PATH = "site.jsonc";
30
28
 
31
29
  const CLIENT_PARTS = [
32
30
  "layout",
@@ -144,7 +142,7 @@ const parseClientArgs = (positionals: string[]): ParsedClientArgs => {
144
142
  const ensureSiteLayout = async (): Promise<void> => {
145
143
  if (!(await Bun.file(SITE_CONFIG_PATH).exists())) {
146
144
  throw new Error(
147
- `Could not find ${SITE_CONFIG_PATH}. Run this command from an idcmd site project root.`
145
+ `Could not find ${SITE_CONFIG_PATH}. Run this command from an idcmd project root.`
148
146
  );
149
147
  }
150
148
  };
@@ -30,7 +30,7 @@ const warnIfVercelMisconfigured = async (): Promise<void> => {
30
30
  };
31
31
 
32
32
  const warnIfBaseUrlMissing = async (): Promise<void> => {
33
- const file = Bun.file("site/site.jsonc");
33
+ const file = Bun.file("site.jsonc");
34
34
  if (!(await file.exists())) {
35
35
  return;
36
36
  }
@@ -42,13 +42,13 @@ const warnIfBaseUrlMissing = async (): Promise<void> => {
42
42
  if (!cfg.baseUrl) {
43
43
  // eslint-disable-next-line no-console
44
44
  console.warn(
45
- "Warning: site/site.jsonc missing baseUrl; sitemap.xml and robots.txt will be skipped."
45
+ "Warning: site.jsonc missing baseUrl; sitemap.xml and robots.txt will be skipped."
46
46
  );
47
47
  }
48
48
  } catch (error) {
49
49
  const message = error instanceof Error ? error.message : String(error);
50
50
  // eslint-disable-next-line no-console
51
- console.warn(`Warning: Failed to parse site/site.jsonc: ${message}`);
51
+ console.warn(`Warning: Failed to parse site.jsonc: ${message}`);
52
52
  }
53
53
  };
54
54
 
@@ -11,12 +11,12 @@ export interface DevFlags {
11
11
  }
12
12
 
13
13
  const findTailwindInput = async (): Promise<string> => {
14
- const path = "site/styles/tailwind.css";
14
+ const path = "styles/tailwind.css";
15
15
  if (await Bun.file(path).exists()) {
16
16
  return path;
17
17
  }
18
18
  throw new Error(
19
- "Could not find Tailwind input. Expected site/styles/tailwind.css."
19
+ "Could not find Tailwind input. Expected styles/tailwind.css."
20
20
  );
21
21
  };
22
22
 
@@ -200,7 +200,7 @@ const applySubstitutions = async (args: {
200
200
  siteName: string;
201
201
  targetDir: string;
202
202
  }): Promise<void> => {
203
- await replaceInFile(joinPath(args.targetDir, "site", "site.jsonc"), (text) =>
203
+ await replaceInFile(joinPath(args.targetDir, "site.jsonc"), (text) =>
204
204
  fillSiteJsonc({
205
205
  baseUrl: args.baseUrl,
206
206
  description: args.description,
@@ -1,6 +1,6 @@
1
1
  import { joinPath } from "./path";
2
2
 
3
- const RUNTIME_SOURCE_DIR = joinPath("site", "src", "runtime");
3
+ const RUNTIME_SOURCE_DIR = joinPath("src", "runtime");
4
4
  const RUNTIME_OUTPUT_DIR = joinPath("public", "_idcmd");
5
5
 
6
6
  const RUNTIME_ENTRY_FILES = [
@@ -21,7 +21,7 @@ export const slugFromContentFile = (file: string): string => {
21
21
  export const scanContentFiles =
22
22
  async function* scanContentFiles(): AsyncGenerator<string> {
23
23
  const { contentDir } = await getProjectPaths();
24
- // `site/content/<slug>.md`
24
+ // `content/<slug>.md`
25
25
  for await (const file of flatContentGlob.scan(contentDir)) {
26
26
  yield file;
27
27
  }
@@ -1,4 +1,3 @@
1
- const DEFAULT_SITE_DIR = "site";
2
1
  const DEFAULT_OUTPUT_DIR = "public";
3
2
  const ASSET_PREFIX = "/_idcmd" as const;
4
3
  const CONTENT_DIR = "content";
@@ -15,13 +14,11 @@ export interface ProjectPaths {
15
14
  assetsDir: string;
16
15
  routesDir: string;
17
16
  siteConfigPath: string;
18
- siteDir: string | null;
19
17
  }
20
18
 
21
19
  export interface ResolveProjectPathsOptions {
22
20
  cwd?: string;
23
21
  outputDir?: string;
24
- siteDir?: string;
25
22
  }
26
23
 
27
24
  const trimTrailingSlash = (value: string): string =>
@@ -45,28 +42,22 @@ const joinPath = (...parts: string[]): string => {
45
42
  const buildPaths = (args: {
46
43
  cwd: string;
47
44
  outputDirName: string;
48
- siteDirName: string;
49
- }): ProjectPaths => {
50
- const siteRoot = joinPath(args.cwd, args.siteDirName);
51
- return {
52
- assetPrefix: ASSET_PREFIX,
53
- assetsDir: joinPath(siteRoot, ASSETS_DIR),
54
- contentDir: joinPath(siteRoot, CONTENT_DIR),
55
- iconsDir: joinPath(siteRoot, ICONS_DIR),
56
- outputDir: joinPath(args.cwd, args.outputDirName),
57
- routesDir: joinPath(siteRoot, ROUTES_DIR),
58
- siteConfigPath: joinPath(siteRoot, SITE_CONFIG_FILE),
59
- siteDir: siteRoot,
60
- };
61
- };
45
+ }): ProjectPaths => ({
46
+ assetPrefix: ASSET_PREFIX,
47
+ assetsDir: joinPath(args.cwd, ASSETS_DIR),
48
+ contentDir: joinPath(args.cwd, CONTENT_DIR),
49
+ iconsDir: joinPath(args.cwd, ICONS_DIR),
50
+ outputDir: joinPath(args.cwd, args.outputDirName),
51
+ routesDir: joinPath(args.cwd, ROUTES_DIR),
52
+ siteConfigPath: joinPath(args.cwd, SITE_CONFIG_FILE),
53
+ });
62
54
 
63
55
  export const resolveProjectPaths = (
64
56
  options: ResolveProjectPathsOptions = {}
65
57
  ): Promise<ProjectPaths> => {
66
58
  const cwd = trimTrailingSlash(options.cwd ?? process.cwd());
67
59
  const outputDirName = options.outputDir ?? DEFAULT_OUTPUT_DIR;
68
- const siteDirName = options.siteDir ?? DEFAULT_SITE_DIR;
69
- return Promise.resolve(buildPaths({ cwd, outputDirName, siteDirName }));
60
+ return Promise.resolve(buildPaths({ cwd, outputDirName }));
70
61
  };
71
62
 
72
63
  let cached: Promise<ProjectPaths> | null = null;
@@ -2,7 +2,7 @@ import type { RenderLayout } from "./layout";
2
2
 
3
3
  import { renderLayout as defaultRenderLayout } from "./layout";
4
4
 
5
- const USER_LAYOUT_PATH = "site/src/ui/layout.tsx";
5
+ const USER_LAYOUT_PATH = "src/ui/layout.tsx";
6
6
 
7
7
  const loadUserLayout = async (
8
8
  filePath: string
@@ -151,7 +151,7 @@ const TopNavbar = ({
151
151
  <div class="flex items-center gap-4">
152
152
  <a
153
153
  href="/"
154
- class="text-sm font-medium tracking-tight font-mono md:hidden"
154
+ class="text-sm font-medium tracking-tight font-mono lg:hidden"
155
155
  data-prefetch="hover"
156
156
  >
157
157
  <span class="text-muted-foreground">~/</span>
@@ -2,7 +2,7 @@ import type { RightRailComponent } from "./right-rail";
2
2
 
3
3
  import { RightRail as defaultRightRail } from "./right-rail";
4
4
 
5
- const USER_RIGHT_RAIL_PATH = "site/src/ui/right-rail.tsx";
5
+ const USER_RIGHT_RAIL_PATH = "src/ui/right-rail.tsx";
6
6
 
7
7
  const loadUserRightRail = async (
8
8
  filePath: string
@@ -2,7 +2,7 @@ import type { RenderSearchPageContent } from "./page";
2
2
 
3
3
  import { renderSearchPageContent as defaultRenderSearchPageContent } from "./page";
4
4
 
5
- const USER_SEARCH_PAGE_PATH = "site/src/ui/search-page.tsx";
5
+ const USER_SEARCH_PAGE_PATH = "src/ui/search-page.tsx";
6
6
 
7
7
  const loadUserSearchPage = async (
8
8
  filePath: string
@@ -59,7 +59,7 @@ export const createLiveReload = (env: LiveReloadEnv): LiveReloadController => {
59
59
  return;
60
60
  }
61
61
 
62
- console.log("Watching site/content/ for changes...");
62
+ console.log("Watching content/ for changes...");
63
63
  let snapshot = await getContentSnapshot();
64
64
 
65
65
  const poll = async (): Promise<void> => {
@@ -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/src/ui/` (`layout.tsx`, `right-rail.tsx`, `search-page.tsx`)
25
- - Code: `site/src/runtime/` browser runtime TS (`*_idcmd` scripts compile from here)
26
- - Code: `site/src/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`
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
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/src`, treat `public/` 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/src/ui/` and `site/src/runtime/`.
44
- Runtime files in `site/src/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
 
@@ -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 source UI entry must exist (site/src/ui/layout.tsx)",
23
- run: () => fileExists("site/src/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/src",
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/src/routes/**`.
2
+ // V1 runs the built-in idcmd server; add custom endpoints via `src/routes/**`.
3
3
 
4
4
  export const serverPlaceholder = true;
@@ -104,7 +104,7 @@ const TopNavbar = ({
104
104
  <div class="flex items-center gap-4">
105
105
  <a
106
106
  href="/"
107
- class="text-sm font-mono font-medium tracking-tight md:hidden"
107
+ class="text-sm font-mono font-medium tracking-tight lg:hidden"
108
108
  data-prefetch="hover"
109
109
  >
110
110
  <span class="text-muted-foreground">~/</span>
@@ -151,7 +151,7 @@ header {
151
151
  display: none;
152
152
  }
153
153
 
154
- @media (min-width: 768px) {
154
+ @media (min-width: 1024px) {
155
155
  .sidebar {
156
156
  position: fixed;
157
157
  top: 0;
@@ -185,7 +185,7 @@ header {
185
185
  flex-direction: column;
186
186
  }
187
187
 
188
- @media (min-width: 768px) {
188
+ @media (min-width: 1024px) {
189
189
  .main-wrapper {
190
190
  margin-left: var(--sidebar-width);
191
191
  }