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