idcmd 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +29 -9
  2. package/package.json +2 -2
  3. package/src/build.ts +6 -5
  4. package/src/cli/commands/build.ts +11 -7
  5. package/src/cli/commands/client.ts +328 -0
  6. package/src/cli/commands/dev.ts +92 -34
  7. package/src/cli/commands/init.ts +93 -2
  8. package/src/cli/main.ts +12 -0
  9. package/src/cli/runtime-assets.ts +89 -0
  10. package/src/cli.ts +0 -0
  11. package/src/client/index.ts +7 -1
  12. package/src/content/icons.ts +1 -1
  13. package/src/content/paths.ts +1 -1
  14. package/src/project/paths.ts +26 -30
  15. package/src/render/layout-loader.ts +7 -4
  16. package/src/render/layout.tsx +10 -2
  17. package/src/render/page-renderer.ts +12 -2
  18. package/src/render/right-rail-loader.ts +49 -0
  19. package/src/render/right-rail.tsx +10 -6
  20. package/src/search/page.tsx +4 -2
  21. package/src/search/search-page-loader.ts +51 -0
  22. package/src/search/server-page.ts +52 -18
  23. package/src/server/live-reload.ts +2 -6
  24. package/src/server/static.ts +1 -1
  25. package/src/server.ts +0 -1
  26. package/src/site/config.ts +2 -10
  27. package/templates/default/.github/workflows/ci.yml +24 -0
  28. package/templates/default/README.md +31 -5
  29. package/templates/default/package.json +2 -1
  30. package/templates/default/scripts/check-internal.ts +56 -0
  31. package/templates/default/scripts/check.ts +332 -0
  32. package/templates/default/scripts/smoke.ts +223 -0
  33. package/templates/default/site/{public/_idcmd/llm-menu.js → code/runtime/llm-menu.ts} +27 -18
  34. package/templates/default/site/{public/_idcmd/nav-prefetch.js → code/runtime/nav-prefetch.ts} +3 -3
  35. package/templates/default/site/{public/_idcmd/right-rail-scrollspy.js → code/runtime/right-rail-scrollspy.ts} +73 -32
  36. package/templates/default/site/{server → code}/server.ts +1 -1
  37. package/templates/default/site/code/ui/layout.tsx +237 -0
  38. package/templates/default/site/code/ui/right-rail.tsx +246 -0
  39. package/templates/default/site/code/ui/search-page.tsx +87 -0
  40. package/templates/default/tsconfig.json +1 -1
  41. package/templates/default/site/client/layout.tsx +0 -2
  42. package/templates/default/site/client/right-rail.tsx +0 -1
  43. package/templates/default/site/client/search-page.tsx +0 -1
  44. /package/templates/default/site/{public → assets}/anthropic-white.svg +0 -0
  45. /package/templates/default/site/{public → assets}/favicon.svg +0 -0
  46. /package/templates/default/site/{icons → assets/icons}/file.svg +0 -0
  47. /package/templates/default/site/{icons → assets/icons}/home.svg +0 -0
  48. /package/templates/default/site/{icons → assets/icons}/info.svg +0 -0
  49. /package/templates/default/site/{public → assets}/openai-white.svg +0 -0
  50. /package/templates/default/site/{server → code}/routes/api/hello.ts +0 -0
  51. /package/templates/default/site/{public/_idcmd/live-reload.js → code/runtime/live-reload.ts} +0 -0
package/README.md CHANGED
@@ -19,15 +19,33 @@ idcmd dev # tailwind watch + SSR dev server
19
19
  idcmd build # static dist/
20
20
  idcmd preview # serve dist/ locally
21
21
  idcmd deploy # build + validate Vercel static deploy config
22
+ idcmd client ... # add/update local site/code implementations
22
23
  ```
23
24
 
24
25
  ## Layout (V1)
25
26
 
26
27
  - `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)
28
+ - `site/code/ui/*` is local UI source code (you own and edit these files)
29
+ - `site/code/runtime/*.ts` is local browser runtime code (compiled to `dist/_idcmd/*.js`)
30
+ - `site/code/routes/**` file-based server routes (dev/server-host only)
31
+ - `site/styles/tailwind.css` -> `dist/styles.css`
32
+ - `site/assets/` static assets
30
33
  - `site/site.jsonc` site config
34
+ - `dist/` generated output (gitignored)
35
+
36
+ ## Syncing Local Client Code
37
+
38
+ Use these commands to pull baseline UI implementations into your project:
39
+
40
+ ```bash
41
+ idcmd client add all
42
+ idcmd client update all --dry-run
43
+ idcmd client update layout --yes
44
+ idcmd client update runtime --yes
45
+ ```
46
+
47
+ `add` creates missing files. `update` overwrites changed files and requires `--yes` unless `--dry-run` is used.
48
+ Runtime files in `site/code/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
31
49
 
32
50
  ## Example: Add A Page
33
51
 
@@ -50,7 +68,7 @@ It renders at `/hello/`.
50
68
 
51
69
  ## Custom Server Routes (V1)
52
70
 
53
- Add `site/server/routes/api/hello.ts`:
71
+ Add `site/code/routes/api/hello.ts`:
54
72
 
55
73
  ```ts
56
74
  export const GET = (): Response => Response.json({ ok: true });
@@ -63,6 +81,7 @@ It responds at `/api/hello`.
63
81
  `tickets/ROADMAP.md` is the source of truth. For V1, we explicitly target:
64
82
 
65
83
  - Content routes ship `0` bytes of JavaScript by default (both SSR output and built HTML).
84
+ - Content routes ship a small, opinionated JavaScript runtime by default (prefetch + optional right-rail behavior).
66
85
  - Search index size `<= 5 MB` for `<= 2,000` pages.
67
86
  - Build completes in `<= 60s` for `<= 2,000` pages on a typical laptop.
68
87
 
@@ -75,7 +94,7 @@ It responds at `/api/hello`.
75
94
 
76
95
  ### Slug and path rules
77
96
 
78
- - Content lives at `site/content/<slug>.md` (or legacy `content/<slug>.md`).
97
+ - Content lives at `site/content/<slug>.md`.
79
98
  - `slug="index"` is the home page.
80
99
  - Canonical HTML paths are `/` for index and `/<slug>/` otherwise.
81
100
  - Markdown download paths exist in two forms:
@@ -90,8 +109,9 @@ It responds at `/api/hello`.
90
109
 
91
110
  ### JS policy
92
111
 
93
- - Content routes ship `0` bytes of JavaScript by default.
94
- - Allowed scripts:
112
+ - Content routes ship lightweight runtime scripts by default.
113
+ - Script behavior:
114
+ - Always: `/_idcmd/nav-prefetch.js`
95
115
  - 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).
116
+ - Right rail enabled: `/_idcmd/llm-menu.js`
117
+ - Right rail scrollspy enabled with non-empty TOC: `/_idcmd/right-rail-scrollspy.js`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idcmd",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/rustydotwtf/idcmd"
@@ -28,7 +28,7 @@
28
28
  "build": "bun run build:css && bun src/build.ts",
29
29
  "preview": "bunx serve dist",
30
30
  "start": "bun src/server.ts",
31
- "check": "ultracite check && bun run typecheck && bun run test",
31
+ "check": "bun run scripts/check.ts",
32
32
  "test": "bun test",
33
33
  "typecheck": "tsc --noEmit -p tsconfig.json",
34
34
  "fix": "ultracite fix",
package/src/build.ts CHANGED
@@ -5,7 +5,7 @@ import { scanContentFiles, slugFromContentFile } from "./content/paths";
5
5
  import { getProjectPaths } from "./project/paths";
6
6
  import { renderDocument, renderMarkdownPage } from "./render/page-renderer";
7
7
  import { generateSearchIndexFromContent } from "./search/index";
8
- import { renderSearchPageContent } from "./search/page";
8
+ import { getRenderSearchPageContent } from "./search/search-page-loader";
9
9
  import {
10
10
  collectSitemapPagesFromContent,
11
11
  generateRobotsTxt,
@@ -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 `content/` (`content/<slug>.md`).
23
+ // Find all content files in `site/content/` (`site/content/<slug>.md`).
24
24
  const contentFiles: string[] = [];
25
25
 
26
26
  const buildStart = performance.now();
@@ -100,13 +100,14 @@ const cssSource = await resolveCssSource();
100
100
  const inlineCss = cssSource ? await Bun.file(cssSource).text() : undefined;
101
101
  const cssPath = inlineCss ? undefined : "/styles.css";
102
102
 
103
- const renderStaticSearchPage = (): Promise<string> => {
103
+ const renderStaticSearchPage = async (): Promise<string> => {
104
+ const renderSearchPage = await getRenderSearchPageContent();
104
105
  const topPages = navigation
105
106
  .flatMap((group) => group.items)
106
107
  .slice(0, 8)
107
108
  .map((item) => ({ href: item.href, title: item.title }));
108
109
 
109
- const contentHtml = renderSearchPageContent({
110
+ const contentHtml = renderSearchPage({
110
111
  minQueryLength: MIN_SEARCH_QUERY_LENGTH,
111
112
  query: "",
112
113
  results: [],
@@ -211,7 +212,7 @@ if (siteConfig.baseUrl) {
211
212
  console.log(` generated ${project.distDir}/robots.txt`);
212
213
  } else {
213
214
  console.log(
214
- "Warning: site.jsonc missing baseUrl; skipping sitemap.xml and robots.txt."
215
+ "Warning: site/site.jsonc missing baseUrl; skipping sitemap.xml and robots.txt."
215
216
  );
216
217
  }
217
218
 
@@ -1,13 +1,12 @@
1
+ import { compileRuntimeAssetsOnce } from "../runtime-assets";
2
+
1
3
  const findTailwindInput = async (): Promise<string> => {
2
- const candidates = ["site/styles/tailwind.css", "content/styles.css"];
3
- for (const path of candidates) {
4
- // eslint-disable-next-line no-await-in-loop
5
- if (await Bun.file(path).exists()) {
6
- return path;
7
- }
4
+ const path = "site/styles/tailwind.css";
5
+ if (await Bun.file(path).exists()) {
6
+ return path;
8
7
  }
9
8
  throw new Error(
10
- "Could not find Tailwind input. Expected site/styles/tailwind.css (new layout) or content/styles.css (legacy)."
9
+ "Could not find Tailwind input. Expected site/styles/tailwind.css."
11
10
  );
12
11
  };
13
12
 
@@ -16,6 +15,11 @@ const idcmdBuildEntry = (): string =>
16
15
  Bun.fileURLToPath(new URL("../../build.ts", import.meta.url));
17
16
 
18
17
  export const buildCommand = async (): Promise<number> => {
18
+ const runtimeCode = await compileRuntimeAssetsOnce();
19
+ if (runtimeCode !== 0) {
20
+ return runtimeCode;
21
+ }
22
+
19
23
  const tailwindInput = await findTailwindInput();
20
24
 
21
25
  const cssProc = Bun.spawn(
@@ -0,0 +1,328 @@
1
+ import { ensureDir } from "../fs";
2
+ import { dirname, joinPath } from "../path";
3
+
4
+ const TEMPLATE_UI_DIR = joinPath(
5
+ import.meta.dir,
6
+ "..",
7
+ "..",
8
+ "..",
9
+ "templates",
10
+ "default",
11
+ "site",
12
+ "code",
13
+ "ui"
14
+ );
15
+ const TEMPLATE_RUNTIME_DIR = joinPath(
16
+ import.meta.dir,
17
+ "..",
18
+ "..",
19
+ "..",
20
+ "templates",
21
+ "default",
22
+ "site",
23
+ "code",
24
+ "runtime"
25
+ );
26
+
27
+ const SITE_UI_DIR = joinPath("site", "code", "ui");
28
+ const SITE_RUNTIME_DIR = joinPath("site", "code", "runtime");
29
+ const SITE_CONFIG_PATH = joinPath("site", "site.jsonc");
30
+
31
+ const CLIENT_PARTS = [
32
+ "layout",
33
+ "right-rail",
34
+ "search-page",
35
+ "runtime",
36
+ ] as const;
37
+ type ClientPart = (typeof CLIENT_PARTS)[number];
38
+ type ClientAction = "add" | "update";
39
+
40
+ const RUNTIME_FILES = [
41
+ "live-reload.ts",
42
+ "llm-menu.ts",
43
+ "nav-prefetch.ts",
44
+ "right-rail-scrollspy.ts",
45
+ ] as const;
46
+
47
+ interface ClientFileSpec {
48
+ fileName: string;
49
+ targetPath: string;
50
+ templatePath: string;
51
+ }
52
+
53
+ export interface ClientFlags {
54
+ dryRun?: boolean;
55
+ yes?: boolean;
56
+ }
57
+
58
+ interface ParsedClientArgs {
59
+ action: ClientAction;
60
+ parts: ClientPart[];
61
+ }
62
+
63
+ interface FilePlan {
64
+ fileName: string;
65
+ nextText: string;
66
+ part: ClientPart;
67
+ reason: string;
68
+ shouldWrite: boolean;
69
+ targetPath: string;
70
+ }
71
+
72
+ const getFileSpecsForPart = (part: ClientPart): ClientFileSpec[] => {
73
+ if (part === "layout") {
74
+ return [
75
+ {
76
+ fileName: "layout.tsx",
77
+ targetPath: joinPath(SITE_UI_DIR, "layout.tsx"),
78
+ templatePath: joinPath(TEMPLATE_UI_DIR, "layout.tsx"),
79
+ },
80
+ ];
81
+ }
82
+
83
+ if (part === "right-rail") {
84
+ return [
85
+ {
86
+ fileName: "right-rail.tsx",
87
+ targetPath: joinPath(SITE_UI_DIR, "right-rail.tsx"),
88
+ templatePath: joinPath(TEMPLATE_UI_DIR, "right-rail.tsx"),
89
+ },
90
+ ];
91
+ }
92
+
93
+ if (part === "search-page") {
94
+ return [
95
+ {
96
+ fileName: "search-page.tsx",
97
+ targetPath: joinPath(SITE_UI_DIR, "search-page.tsx"),
98
+ templatePath: joinPath(TEMPLATE_UI_DIR, "search-page.tsx"),
99
+ },
100
+ ];
101
+ }
102
+
103
+ return RUNTIME_FILES.map((fileName) => ({
104
+ fileName,
105
+ targetPath: joinPath(SITE_RUNTIME_DIR, fileName),
106
+ templatePath: joinPath(TEMPLATE_RUNTIME_DIR, fileName),
107
+ }));
108
+ };
109
+
110
+ const isClientAction = (value: string): value is ClientAction =>
111
+ value === "add" || value === "update";
112
+
113
+ const isClientPart = (value: string): value is ClientPart =>
114
+ CLIENT_PARTS.includes(value as ClientPart);
115
+
116
+ const parseClientPart = (value: string | undefined): ClientPart[] => {
117
+ if (!value || value === "all") {
118
+ return [...CLIENT_PARTS];
119
+ }
120
+
121
+ if (!isClientPart(value)) {
122
+ throw new Error(
123
+ `Unknown client part: ${value}. Expected one of ${CLIENT_PARTS.join(", ")} or all.`
124
+ );
125
+ }
126
+
127
+ return [value];
128
+ };
129
+
130
+ const parseClientArgs = (positionals: string[]): ParsedClientArgs => {
131
+ const [actionRaw, partRaw] = positionals;
132
+ if (!actionRaw || !isClientAction(actionRaw)) {
133
+ throw new Error(
134
+ "Usage: idcmd client <add|update> <layout|right-rail|search-page|runtime|all> [--dry-run] [--yes]"
135
+ );
136
+ }
137
+
138
+ return {
139
+ action: actionRaw,
140
+ parts: parseClientPart(partRaw),
141
+ };
142
+ };
143
+
144
+ const ensureSiteLayout = async (): Promise<void> => {
145
+ if (!(await Bun.file(SITE_CONFIG_PATH).exists())) {
146
+ throw new Error(
147
+ `Could not find ${SITE_CONFIG_PATH}. Run this command from an idcmd site project root.`
148
+ );
149
+ }
150
+ };
151
+
152
+ const readTemplateFile = async (spec: ClientFileSpec): Promise<string> => {
153
+ const file = Bun.file(spec.templatePath);
154
+ if (!(await file.exists())) {
155
+ throw new Error(`Missing template file: ${spec.templatePath}`);
156
+ }
157
+ return file.text();
158
+ };
159
+
160
+ const classifyAddPlan = async (
161
+ part: ClientPart,
162
+ spec: ClientFileSpec,
163
+ nextText: string
164
+ ): Promise<FilePlan> => {
165
+ const exists = await Bun.file(spec.targetPath).exists();
166
+
167
+ return {
168
+ fileName: spec.fileName,
169
+ nextText,
170
+ part,
171
+ reason: exists ? "exists" : "missing",
172
+ shouldWrite: !exists,
173
+ targetPath: spec.targetPath,
174
+ };
175
+ };
176
+
177
+ const classifyUpdatePlan = async (
178
+ part: ClientPart,
179
+ spec: ClientFileSpec,
180
+ nextText: string
181
+ ): Promise<FilePlan> => {
182
+ const file = Bun.file(spec.targetPath);
183
+
184
+ if (!(await file.exists())) {
185
+ return {
186
+ fileName: spec.fileName,
187
+ nextText,
188
+ part,
189
+ reason: "missing",
190
+ shouldWrite: false,
191
+ targetPath: spec.targetPath,
192
+ };
193
+ }
194
+
195
+ const currentText = await file.text();
196
+ const isUpToDate = currentText === nextText;
197
+
198
+ return {
199
+ fileName: spec.fileName,
200
+ nextText,
201
+ part,
202
+ reason: isUpToDate ? "up-to-date" : "changed",
203
+ shouldWrite: !isUpToDate,
204
+ targetPath: spec.targetPath,
205
+ };
206
+ };
207
+
208
+ const buildPlan = async (args: ParsedClientArgs): Promise<FilePlan[]> => {
209
+ const entries: FilePlan[] = [];
210
+ for (const part of args.parts) {
211
+ const files = getFileSpecsForPart(part);
212
+ for (const file of files) {
213
+ // eslint-disable-next-line no-await-in-loop
214
+ const nextText = await readTemplateFile(file);
215
+ // eslint-disable-next-line no-await-in-loop
216
+ const item =
217
+ args.action === "add"
218
+ ? await classifyAddPlan(part, file, nextText)
219
+ : await classifyUpdatePlan(part, file, nextText);
220
+ entries.push(item);
221
+ }
222
+ }
223
+ return entries;
224
+ };
225
+
226
+ const getWriteLabel = (action: ClientAction): "create" | "update" =>
227
+ action === "add" ? "create" : "update";
228
+
229
+ const getSkipReason = (action: ClientAction, item: FilePlan): string => {
230
+ if (action === "add") {
231
+ return item.reason;
232
+ }
233
+
234
+ return item.reason === "missing"
235
+ ? `missing; run: idcmd client add ${item.part}`
236
+ : item.reason;
237
+ };
238
+
239
+ const formatPlanLine = (action: ClientAction, item: FilePlan): string => {
240
+ if (item.shouldWrite) {
241
+ return `[${getWriteLabel(action)}] ${item.targetPath}`;
242
+ }
243
+
244
+ return `[skip] ${item.targetPath} (${getSkipReason(action, item)})`;
245
+ };
246
+
247
+ const printPlan = (action: ClientAction, plan: FilePlan[]): void => {
248
+ for (const item of plan) {
249
+ console.log(formatPlanLine(action, item));
250
+ }
251
+ };
252
+
253
+ const ensureUpdateConfirmed = (
254
+ flags: ClientFlags,
255
+ plan: FilePlan[],
256
+ action: ClientAction
257
+ ): void => {
258
+ if (action !== "update") {
259
+ return;
260
+ }
261
+
262
+ if (flags.dryRun) {
263
+ return;
264
+ }
265
+
266
+ const pending = plan.some((item) => item.shouldWrite);
267
+ if (pending && !flags.yes) {
268
+ throw new Error(
269
+ "Refusing to overwrite client files without --yes. Re-run with: idcmd client update <part> --yes"
270
+ );
271
+ }
272
+ };
273
+
274
+ const applyPlan = async (plan: FilePlan[]): Promise<void> => {
275
+ for (const item of plan) {
276
+ if (!item.shouldWrite) {
277
+ continue;
278
+ }
279
+
280
+ // eslint-disable-next-line no-await-in-loop
281
+ await ensureDir(dirname(item.targetPath));
282
+ // eslint-disable-next-line no-await-in-loop
283
+ await Bun.write(item.targetPath, item.nextText);
284
+ }
285
+ };
286
+
287
+ const printDryRunFooter = (plan: FilePlan[]): void => {
288
+ const count = plan.filter((item) => item.shouldWrite).length;
289
+ console.log(`[dry-run] ${count} file(s) would change.`);
290
+ };
291
+
292
+ const printAppliedFooter = (plan: FilePlan[]): void => {
293
+ const count = plan.filter((item) => item.shouldWrite).length;
294
+ console.log(`Applied ${count} change(s).`);
295
+ };
296
+
297
+ const maybeHandleDryRun = (flags: ClientFlags, plan: FilePlan[]): boolean => {
298
+ if (!flags.dryRun) {
299
+ return false;
300
+ }
301
+
302
+ printDryRunFooter(plan);
303
+ return true;
304
+ };
305
+
306
+ const applyPlanWithSummary = async (plan: FilePlan[]): Promise<void> => {
307
+ await applyPlan(plan);
308
+ printAppliedFooter(plan);
309
+ };
310
+
311
+ export const clientCommand = async (
312
+ positionals: string[],
313
+ flags: ClientFlags
314
+ ): Promise<number> => {
315
+ await ensureSiteLayout();
316
+ const parsed = parseClientArgs(positionals);
317
+ const plan = await buildPlan(parsed);
318
+
319
+ printPlan(parsed.action, plan);
320
+ ensureUpdateConfirmed(flags, plan, parsed.action);
321
+
322
+ if (maybeHandleDryRun(flags, plan)) {
323
+ return 0;
324
+ }
325
+
326
+ await applyPlanWithSummary(plan);
327
+ return 0;
328
+ };
@@ -1,4 +1,8 @@
1
1
  import { parsePort } from "../normalize";
2
+ import {
3
+ compileRuntimeAssetsOnce,
4
+ watchRuntimeAssets,
5
+ } from "../runtime-assets";
2
6
 
3
7
  const DEFAULT_PORT = 4000;
4
8
 
@@ -7,22 +11,16 @@ export interface DevFlags {
7
11
  }
8
12
 
9
13
  const findTailwindInput = async (): Promise<string> => {
10
- const candidates = ["site/styles/tailwind.css", "content/styles.css"];
11
- for (const path of candidates) {
12
- // eslint-disable-next-line no-await-in-loop
13
- if (await Bun.file(path).exists()) {
14
- return path;
15
- }
14
+ const path = "site/styles/tailwind.css";
15
+ if (await Bun.file(path).exists()) {
16
+ return path;
16
17
  }
17
18
  throw new Error(
18
- "Could not find Tailwind input. Expected site/styles/tailwind.css (new layout) or content/styles.css (legacy)."
19
+ "Could not find Tailwind input. Expected site/styles/tailwind.css."
19
20
  );
20
21
  };
21
22
 
22
- const resolveTailwindOutput = async (): Promise<string> => {
23
- const hasSite = await Bun.file("site/site.jsonc").exists();
24
- return hasSite ? "site/public/styles.css" : "public/styles.css";
25
- };
23
+ const resolveTailwindOutput = (): string => "dist/styles.css";
26
24
 
27
25
  const idcmdServerEntry = (): string =>
28
26
  // `src/server.ts` lives two levels up from `src/cli/commands/*`.
@@ -33,12 +31,11 @@ const installSignalHandlers = (shutdown: () => void): void => {
33
31
  process.on("SIGTERM", shutdown);
34
32
  };
35
33
 
36
- export const devCommand = async (flags: DevFlags): Promise<number> => {
37
- const port = parsePort(flags.port, DEFAULT_PORT);
38
- const tailwindInput = await findTailwindInput();
39
- const tailwindOutput = await resolveTailwindOutput();
40
-
41
- const cssProc = Bun.spawn(
34
+ const spawnCssProcess = (
35
+ tailwindInput: string,
36
+ tailwindOutput: string
37
+ ): ReturnType<typeof Bun.spawn> =>
38
+ Bun.spawn(
42
39
  [
43
40
  "bunx",
44
41
  "@tailwindcss/cli",
@@ -52,30 +49,91 @@ export const devCommand = async (flags: DevFlags): Promise<number> => {
52
49
  { stderr: "inherit", stdout: "inherit" }
53
50
  );
54
51
 
55
- const serverProc = Bun.spawn(["bun", "--hot", idcmdServerEntry()], {
52
+ const spawnServerProcess = (port: number): ReturnType<typeof Bun.spawn> =>
53
+ Bun.spawn(["bun", "--hot", idcmdServerEntry()], {
56
54
  env: { ...process.env, NODE_ENV: "development", PORT: String(port) },
57
55
  stderr: "inherit",
58
56
  stdout: "inherit",
59
57
  });
60
58
 
61
- const shutdown = (): void => {
62
- try {
63
- cssProc.kill("SIGTERM");
64
- } catch {
65
- // ignore
66
- }
67
- try {
68
- serverProc.kill("SIGTERM");
69
- } catch {
70
- // ignore
71
- }
59
+ const ensureRuntimeAssetsReady = async (): Promise<{
60
+ runtimeProc: ReturnType<typeof Bun.spawn> | null;
61
+ runtimeSetupCode: number;
62
+ }> => {
63
+ const runtimeCode = await compileRuntimeAssetsOnce();
64
+ if (runtimeCode !== 0) {
65
+ return { runtimeProc: null, runtimeSetupCode: runtimeCode };
66
+ }
67
+
68
+ return {
69
+ runtimeProc: await watchRuntimeAssets(),
70
+ runtimeSetupCode: 0,
72
71
  };
72
+ };
73
+
74
+ const killProcess = (proc: ReturnType<typeof Bun.spawn> | null): void => {
75
+ if (!proc) {
76
+ return;
77
+ }
78
+ try {
79
+ proc.kill("SIGTERM");
80
+ } catch {
81
+ // ignore
82
+ }
83
+ };
84
+
85
+ const resolveDevExitCode = (args: {
86
+ cssExit: number;
87
+ runtimeExit: number;
88
+ serverExit: number;
89
+ }): number => {
90
+ if (args.serverExit !== 0) {
91
+ return args.serverExit;
92
+ }
93
+ if (args.cssExit !== 0) {
94
+ return args.cssExit;
95
+ }
96
+ return args.runtimeExit;
97
+ };
73
98
 
74
- installSignalHandlers(shutdown);
99
+ const installDevSignalHandlers = (args: {
100
+ cssProc: ReturnType<typeof Bun.spawn>;
101
+ runtimeProc: ReturnType<typeof Bun.spawn> | null;
102
+ serverProc: ReturnType<typeof Bun.spawn>;
103
+ }): void => {
104
+ installSignalHandlers(() => {
105
+ killProcess(args.cssProc);
106
+ killProcess(args.serverProc);
107
+ killProcess(args.runtimeProc);
108
+ });
109
+ };
75
110
 
76
- const [cssExit, serverExit] = await Promise.all([
77
- cssProc.exited,
78
- serverProc.exited,
111
+ const waitForDevExit = async (args: {
112
+ cssProc: ReturnType<typeof Bun.spawn>;
113
+ runtimeProc: ReturnType<typeof Bun.spawn> | null;
114
+ serverProc: ReturnType<typeof Bun.spawn>;
115
+ }): Promise<{ cssExit: number; runtimeExit: number; serverExit: number }> => {
116
+ const [cssExit, serverExit, runtimeExit] = await Promise.all([
117
+ args.cssProc.exited,
118
+ args.serverProc.exited,
119
+ args.runtimeProc?.exited ?? Promise.resolve(0),
79
120
  ]);
80
- return serverExit === 0 ? cssExit : serverExit;
121
+ return { cssExit, runtimeExit, serverExit };
122
+ };
123
+
124
+ export const devCommand = async (flags: DevFlags): Promise<number> => {
125
+ const port = parsePort(flags.port, DEFAULT_PORT);
126
+ const { runtimeProc, runtimeSetupCode } = await ensureRuntimeAssetsReady();
127
+ if (runtimeSetupCode !== 0) {
128
+ return runtimeSetupCode;
129
+ }
130
+
131
+ const tailwindInput = await findTailwindInput();
132
+ const tailwindOutput = resolveTailwindOutput();
133
+ const cssProc = spawnCssProcess(tailwindInput, tailwindOutput);
134
+ const serverProc = spawnServerProcess(port);
135
+ installDevSignalHandlers({ cssProc, runtimeProc, serverProc });
136
+ return resolveDevExitCode(
137
+ await waitForDevExit({ cssProc, runtimeProc, serverProc })
138
+ );
81
139
  };