@moku-labs/web 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/bin/moku.cjs +809 -0
- package/dist/bin/moku.d.cts +1 -0
- package/dist/bin/moku.d.mts +1 -0
- package/dist/bin/moku.mjs +809 -0
- package/dist/factory-BBVQO5ZG.d.mts +90 -0
- package/dist/factory-CixCpR9C.cjs +1710 -0
- package/dist/factory-D0m7Xil2.d.cts +90 -0
- package/dist/factory-DwpBwjDk.mjs +1602 -0
- package/dist/index-CWdZdegx.d.mts +349 -0
- package/dist/index.cjs +46 -0
- package/dist/index.d.cts +135 -0
- package/dist/index.d.mts +135 -0
- package/dist/index.mjs +36 -0
- package/dist/plugins/head/build.cjs +35 -0
- package/dist/plugins/head/build.d.cts +17 -0
- package/dist/plugins/head/build.d.mts +17 -0
- package/dist/plugins/head/build.mjs +27 -0
- package/dist/plugins/spa/index.cjs +26 -0
- package/dist/plugins/spa/index.d.cts +30 -0
- package/dist/plugins/spa/index.d.mts +30 -0
- package/dist/plugins/spa/index.mjs +24 -0
- package/dist/primitives-BBo4wxUL.d.cts +69 -0
- package/dist/primitives-BYUp6kae.cjs +100 -0
- package/dist/primitives-gO5i1tD8.mjs +58 -0
- package/dist/primitives-kuZFxqV7.d.mts +69 -0
- package/dist/project-BTNUWbGQ.mjs +1020 -0
- package/dist/project-C1vtMxE8.cjs +1081 -0
- package/dist/route-builder-Lv6HUVvP.d.cts +349 -0
- package/dist/test.cjs +82 -0
- package/dist/test.d.cts +61 -0
- package/dist/test.d.mts +61 -0
- package/dist/test.mjs +79 -0
- package/package.json +100 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
const require_project = require('../project-C1vtMxE8.cjs');
|
|
3
|
+
let node_fs = require("node:fs");
|
|
4
|
+
let node_path = require("node:path");
|
|
5
|
+
let node_util = require("node:util");
|
|
6
|
+
|
|
7
|
+
//#region src/bin/help.ts
|
|
8
|
+
/** @file Help text and banner formatters for the moku CLI. */
|
|
9
|
+
/**
|
|
10
|
+
* Format the banner line shown before any output.
|
|
11
|
+
*
|
|
12
|
+
* @param version - The package version.
|
|
13
|
+
* @returns The banner string.
|
|
14
|
+
*/
|
|
15
|
+
const formatBanner = (version) => `moku v${version}`;
|
|
16
|
+
/**
|
|
17
|
+
* Format the top-level help text listing all commands and global flags.
|
|
18
|
+
*
|
|
19
|
+
* @returns The help text.
|
|
20
|
+
*/
|
|
21
|
+
const formatHelp = () => `
|
|
22
|
+
Usage: moku <command> [options]
|
|
23
|
+
|
|
24
|
+
Commands:
|
|
25
|
+
build [folder] Build static site (default folder: src/)
|
|
26
|
+
dev [folder] Start dev server with watch + rebuild
|
|
27
|
+
preview [folder] Build and serve site for local preview
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--version Show version number
|
|
31
|
+
--help, -h Show help
|
|
32
|
+
|
|
33
|
+
Run moku <command> --help for command-specific options.`.trim();
|
|
34
|
+
/**
|
|
35
|
+
* Format the help text for `moku build`.
|
|
36
|
+
*
|
|
37
|
+
* @returns The help text.
|
|
38
|
+
*/
|
|
39
|
+
const formatBuildHelp = () => `
|
|
40
|
+
Usage: moku build [folder] [options]
|
|
41
|
+
|
|
42
|
+
Arguments:
|
|
43
|
+
folder Source folder containing main.ts (default: src/)
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--verbose, -v Show detailed build output
|
|
47
|
+
--mode <mode> Build mode: ssg, spa, hybrid
|
|
48
|
+
--help, -h Show help`.trim();
|
|
49
|
+
/**
|
|
50
|
+
* Format the help text for `moku dev`.
|
|
51
|
+
*
|
|
52
|
+
* @returns The help text.
|
|
53
|
+
*/
|
|
54
|
+
const formatDevHelp = () => `
|
|
55
|
+
Usage: moku dev [folder] [options]
|
|
56
|
+
|
|
57
|
+
Arguments:
|
|
58
|
+
folder Source folder containing main.ts (default: src/)
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
--verbose, -v Show detailed build output
|
|
62
|
+
--port, -p <n> Server port (default: 4173)
|
|
63
|
+
--help, -h Show help`.trim();
|
|
64
|
+
/**
|
|
65
|
+
* Format the help text for `moku preview`.
|
|
66
|
+
*
|
|
67
|
+
* @returns The help text.
|
|
68
|
+
*/
|
|
69
|
+
const formatPreviewHelp = () => `
|
|
70
|
+
Usage: moku preview [folder] [options]
|
|
71
|
+
|
|
72
|
+
Arguments:
|
|
73
|
+
folder Source folder containing main.ts (default: src/)
|
|
74
|
+
|
|
75
|
+
Options:
|
|
76
|
+
--port, -p <n> Server port (default: 3000)
|
|
77
|
+
--help, -h Show help`.trim();
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/bin/parse.ts
|
|
81
|
+
/** @file Argument parsers for the moku CLI built on node:util parseArgs. */
|
|
82
|
+
const VALID_MODES = new Set([
|
|
83
|
+
"ssg",
|
|
84
|
+
"spa",
|
|
85
|
+
"hybrid"
|
|
86
|
+
]);
|
|
87
|
+
const MIN_PORT = 1;
|
|
88
|
+
const MAX_PORT = 65535;
|
|
89
|
+
const DEFAULT_DEV_PORT = 4173;
|
|
90
|
+
const DEFAULT_PREVIEW_PORT = 3e3;
|
|
91
|
+
/**
|
|
92
|
+
* Classify the top-level invocation into version / help / unknown-flag / command.
|
|
93
|
+
*
|
|
94
|
+
* @param argv - The argv slice (already stripped of `process.argv[0..1]`).
|
|
95
|
+
* @returns A {@link TopLevel} discriminant.
|
|
96
|
+
*/
|
|
97
|
+
const parseTopLevel = (argv) => {
|
|
98
|
+
if (argv.length === 0) return { kind: "help" };
|
|
99
|
+
const [first, ...rest] = argv;
|
|
100
|
+
if (first === void 0) return { kind: "help" };
|
|
101
|
+
if (first === "--version") return { kind: "version" };
|
|
102
|
+
if (first === "--help" || first === "-h") return { kind: "help" };
|
|
103
|
+
if (first.startsWith("-")) return {
|
|
104
|
+
kind: "unknown-flag",
|
|
105
|
+
flag: first
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
kind: "command",
|
|
109
|
+
command: first,
|
|
110
|
+
rest
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const parsePort = (value, fallback) => {
|
|
114
|
+
if (value === void 0) return {
|
|
115
|
+
ok: true,
|
|
116
|
+
value: fallback
|
|
117
|
+
};
|
|
118
|
+
const port = Number.parseInt(value, 10);
|
|
119
|
+
if (!Number.isFinite(port) || `${port}` !== value) return {
|
|
120
|
+
ok: false,
|
|
121
|
+
message: `Invalid port: ${value}. Must be a number.`
|
|
122
|
+
};
|
|
123
|
+
if (port < MIN_PORT || port > MAX_PORT) return {
|
|
124
|
+
ok: false,
|
|
125
|
+
message: `Invalid port: ${port}. Must be ${MIN_PORT}-${MAX_PORT}.`
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
value: port
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Parse `moku build` arguments.
|
|
134
|
+
*
|
|
135
|
+
* @param argv - Argv after the `build` keyword.
|
|
136
|
+
* @returns A {@link ParseResult} carrying a {@link BuildArgs} or an error message.
|
|
137
|
+
*/
|
|
138
|
+
const parseBuild = (argv) => {
|
|
139
|
+
try {
|
|
140
|
+
const { values, positionals } = (0, node_util.parseArgs)({
|
|
141
|
+
args: argv,
|
|
142
|
+
options: {
|
|
143
|
+
verbose: {
|
|
144
|
+
type: "boolean",
|
|
145
|
+
short: "v",
|
|
146
|
+
default: false
|
|
147
|
+
},
|
|
148
|
+
mode: { type: "string" },
|
|
149
|
+
help: {
|
|
150
|
+
type: "boolean",
|
|
151
|
+
short: "h",
|
|
152
|
+
default: false
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
allowPositionals: true,
|
|
156
|
+
strict: true
|
|
157
|
+
});
|
|
158
|
+
if (values.mode !== void 0 && !VALID_MODES.has(values.mode)) return {
|
|
159
|
+
ok: false,
|
|
160
|
+
message: `Invalid mode: ${values.mode}. Must be one of: ssg, spa, hybrid.`
|
|
161
|
+
};
|
|
162
|
+
const mode = values.mode;
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
value: {
|
|
166
|
+
folder: positionals[0] ?? "src",
|
|
167
|
+
verbose: values.verbose,
|
|
168
|
+
help: values.help,
|
|
169
|
+
...mode === void 0 ? {} : { mode }
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
message: error.message
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Parse `moku dev` arguments.
|
|
181
|
+
*
|
|
182
|
+
* @param argv - Argv after the `dev` keyword.
|
|
183
|
+
* @returns A {@link ParseResult} carrying a {@link DevArgs} or an error message.
|
|
184
|
+
*/
|
|
185
|
+
const parseDev = (argv) => {
|
|
186
|
+
try {
|
|
187
|
+
const { values, positionals } = (0, node_util.parseArgs)({
|
|
188
|
+
args: argv,
|
|
189
|
+
options: {
|
|
190
|
+
verbose: {
|
|
191
|
+
type: "boolean",
|
|
192
|
+
short: "v",
|
|
193
|
+
default: false
|
|
194
|
+
},
|
|
195
|
+
port: {
|
|
196
|
+
type: "string",
|
|
197
|
+
short: "p"
|
|
198
|
+
},
|
|
199
|
+
help: {
|
|
200
|
+
type: "boolean",
|
|
201
|
+
short: "h",
|
|
202
|
+
default: false
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
allowPositionals: true,
|
|
206
|
+
strict: true
|
|
207
|
+
});
|
|
208
|
+
const port = parsePort(values.port, DEFAULT_DEV_PORT);
|
|
209
|
+
if (!port.ok) return port;
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
value: {
|
|
213
|
+
folder: positionals[0] ?? "src",
|
|
214
|
+
verbose: values.verbose,
|
|
215
|
+
help: values.help,
|
|
216
|
+
port: port.value
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return {
|
|
221
|
+
ok: false,
|
|
222
|
+
message: error.message
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Parse `moku preview` arguments.
|
|
228
|
+
*
|
|
229
|
+
* @param argv - Argv after the `preview` keyword.
|
|
230
|
+
* @returns A {@link ParseResult} carrying a {@link PreviewArgs} or an error message.
|
|
231
|
+
*/
|
|
232
|
+
const parsePreview = (argv) => {
|
|
233
|
+
try {
|
|
234
|
+
const { values, positionals } = (0, node_util.parseArgs)({
|
|
235
|
+
args: argv,
|
|
236
|
+
options: {
|
|
237
|
+
port: {
|
|
238
|
+
type: "string",
|
|
239
|
+
short: "p"
|
|
240
|
+
},
|
|
241
|
+
help: {
|
|
242
|
+
type: "boolean",
|
|
243
|
+
short: "h",
|
|
244
|
+
default: false
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
allowPositionals: true,
|
|
248
|
+
strict: true
|
|
249
|
+
});
|
|
250
|
+
const port = parsePort(values.port, DEFAULT_PREVIEW_PORT);
|
|
251
|
+
if (!port.ok) return port;
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
value: {
|
|
255
|
+
folder: positionals[0] ?? "src",
|
|
256
|
+
help: values.help,
|
|
257
|
+
port: port.value
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
message: error.message
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/bin/version.ts
|
|
270
|
+
const parseSemver = (raw) => {
|
|
271
|
+
const stripped = raw.split(/[+-]/, 1)[0] ?? raw;
|
|
272
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(stripped);
|
|
273
|
+
if (match === null) return null;
|
|
274
|
+
return {
|
|
275
|
+
major: Number(match[1]),
|
|
276
|
+
minor: Number(match[2]),
|
|
277
|
+
patch: Number(match[3])
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
const compareSemver = (a, b) => {
|
|
281
|
+
if (a.major !== b.major) return a.major - b.major;
|
|
282
|
+
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
283
|
+
return a.patch - b.patch;
|
|
284
|
+
};
|
|
285
|
+
const satisfiesRange = (actual, min, prefix) => {
|
|
286
|
+
if (prefix === "^") return actual.major === min.major && compareSemver(actual, min) >= 0;
|
|
287
|
+
if (prefix === "~") {
|
|
288
|
+
if (actual.major !== min.major || actual.minor !== min.minor) return false;
|
|
289
|
+
return actual.patch >= min.patch;
|
|
290
|
+
}
|
|
291
|
+
return compareSemver(actual, min) >= 0;
|
|
292
|
+
};
|
|
293
|
+
const splitRange = (engine) => {
|
|
294
|
+
const trimmed = engine.trim();
|
|
295
|
+
const match = /^(>=|\^|~)(.+)$/.exec(trimmed);
|
|
296
|
+
if (match === null) return {
|
|
297
|
+
prefix: ">=",
|
|
298
|
+
rawMin: trimmed
|
|
299
|
+
};
|
|
300
|
+
return {
|
|
301
|
+
prefix: match[1],
|
|
302
|
+
rawMin: match[2] ?? ""
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
/**
|
|
306
|
+
* Check whether a Bun runtime version satisfies a simple engines range.
|
|
307
|
+
*
|
|
308
|
+
* Supports `>=A.B.C`, `^A.B.C`, `~A.B.C`. Returns ok=true if `engine` is
|
|
309
|
+
* undefined (no constraint). Pre-release suffixes on `bunVersion` are stripped
|
|
310
|
+
* for the comparison.
|
|
311
|
+
*
|
|
312
|
+
* @param bunVersion - The current `Bun.version` string.
|
|
313
|
+
* @param engine - The `engines.bun` field from package.json (or undefined).
|
|
314
|
+
* @returns A {@link VersionCheck} with `ok: false` carrying a user-facing message.
|
|
315
|
+
*/
|
|
316
|
+
const checkBunVersion = (bunVersion, engine) => {
|
|
317
|
+
if (engine === void 0) return { ok: true };
|
|
318
|
+
const actual = parseSemver(bunVersion);
|
|
319
|
+
if (actual === null) return {
|
|
320
|
+
ok: false,
|
|
321
|
+
message: `Unable to parse Bun version: ${bunVersion}`
|
|
322
|
+
};
|
|
323
|
+
const { prefix, rawMin } = splitRange(engine);
|
|
324
|
+
const min = parseSemver(rawMin);
|
|
325
|
+
if (min === null) return {
|
|
326
|
+
ok: false,
|
|
327
|
+
message: `Unable to parse engine range: ${engine}`
|
|
328
|
+
};
|
|
329
|
+
if (satisfiesRange(actual, min, prefix)) return { ok: true };
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
message: `Bun ${bunVersion} does not satisfy engines.bun "${engine}". Install Bun ${rawMin} or newer.`
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/bin/cli.ts
|
|
338
|
+
/** @file runCli — pure dispatch over injected deps. Tested without spawning subprocesses. */
|
|
339
|
+
/**
|
|
340
|
+
* Top-level CLI dispatcher.
|
|
341
|
+
*
|
|
342
|
+
* Exit codes:
|
|
343
|
+
* 0 - success
|
|
344
|
+
* 1 - config / discovery error
|
|
345
|
+
* 2 - build / runtime error
|
|
346
|
+
* 3 - invalid arguments
|
|
347
|
+
* 4 - unsupported runtime
|
|
348
|
+
*
|
|
349
|
+
* @param argv - Argv slice (already stripped of `node`/`bun` + script path).
|
|
350
|
+
* @param deps - Injected dependencies.
|
|
351
|
+
* @returns The exit code wrapper.
|
|
352
|
+
*/
|
|
353
|
+
const runCli = async (argv, deps) => {
|
|
354
|
+
const top = parseTopLevel(argv);
|
|
355
|
+
if (top.kind === "version") {
|
|
356
|
+
deps.stdout(deps.version);
|
|
357
|
+
return { code: 0 };
|
|
358
|
+
}
|
|
359
|
+
if (top.kind === "help") {
|
|
360
|
+
deps.stdout(formatBanner(deps.version));
|
|
361
|
+
deps.stdout(formatHelp());
|
|
362
|
+
return { code: 0 };
|
|
363
|
+
}
|
|
364
|
+
if (top.kind === "unknown-flag") {
|
|
365
|
+
deps.stderr(`Unknown flag: ${top.flag}`);
|
|
366
|
+
deps.stdout(formatHelp());
|
|
367
|
+
return { code: 3 };
|
|
368
|
+
}
|
|
369
|
+
const versionCheck = checkBunVersion(deps.bunVersion, deps.bunEngine);
|
|
370
|
+
if (!versionCheck.ok) {
|
|
371
|
+
deps.stderr(versionCheck.message);
|
|
372
|
+
return { code: 4 };
|
|
373
|
+
}
|
|
374
|
+
deps.stdout(formatBanner(deps.version));
|
|
375
|
+
switch (top.command) {
|
|
376
|
+
case "build": return deps.buildCommand(top.rest);
|
|
377
|
+
case "dev": return deps.devCommand(top.rest);
|
|
378
|
+
case "preview": return deps.previewCommand(top.rest);
|
|
379
|
+
default:
|
|
380
|
+
deps.stderr(`Unknown command: ${top.command}`);
|
|
381
|
+
deps.stdout(formatHelp());
|
|
382
|
+
return { code: 3 };
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/bin/commands/prepare.ts
|
|
388
|
+
/**
|
|
389
|
+
* Parse argv, branch on help/error, then load the app. Reduces command
|
|
390
|
+
* complexity by hiding the four-way branch behind a single discriminant.
|
|
391
|
+
*
|
|
392
|
+
* @param options - Parser + loader + cwd.
|
|
393
|
+
* @returns A {@link PreparedOutcome}.
|
|
394
|
+
*/
|
|
395
|
+
const prepareApp = async (options) => {
|
|
396
|
+
const parsed = options.parse(options.argv);
|
|
397
|
+
if (!parsed.ok) return {
|
|
398
|
+
kind: "bad-args",
|
|
399
|
+
message: parsed.message
|
|
400
|
+
};
|
|
401
|
+
if (parsed.value.help) return { kind: "help" };
|
|
402
|
+
const loaded = await options.loadApp({
|
|
403
|
+
cwd: options.cwd,
|
|
404
|
+
folder: parsed.value.folder
|
|
405
|
+
});
|
|
406
|
+
if (!loaded.ok) return {
|
|
407
|
+
kind: "load-failed",
|
|
408
|
+
message: loaded.message
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
kind: "ready",
|
|
412
|
+
args: parsed.value,
|
|
413
|
+
app: loaded.value
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
/**
|
|
417
|
+
* Time a build run and report it.
|
|
418
|
+
*
|
|
419
|
+
* @param app - The loaded CliApp.
|
|
420
|
+
* @param stdout - Output channel.
|
|
421
|
+
* @returns Exit code (0 ok, 2 build failure) and message already emitted.
|
|
422
|
+
*/
|
|
423
|
+
const runBuildOnce = async (app, stdout, stderr) => {
|
|
424
|
+
const start = performance.now();
|
|
425
|
+
try {
|
|
426
|
+
await app.build.run();
|
|
427
|
+
} catch (error) {
|
|
428
|
+
stderr(`Build failed: ${error.message}`);
|
|
429
|
+
return { code: 2 };
|
|
430
|
+
}
|
|
431
|
+
stdout(`Built in ${Math.round((performance.now() - start) / 100) / 10}s`);
|
|
432
|
+
return { code: 0 };
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
//#endregion
|
|
436
|
+
//#region src/bin/commands/build.ts
|
|
437
|
+
/** @file `moku build` command — load app, run app.build.run(), report timing. */
|
|
438
|
+
/**
|
|
439
|
+
* Run the build subcommand.
|
|
440
|
+
*
|
|
441
|
+
* @param argv - Argv after the `build` keyword.
|
|
442
|
+
* @param deps - Injected IO / loader dependencies.
|
|
443
|
+
* @returns Exit code: 0 ok, 1 config error, 2 build error, 3 arg error.
|
|
444
|
+
*/
|
|
445
|
+
const buildCommand = async (argv, deps) => {
|
|
446
|
+
const prepared = await prepareApp({
|
|
447
|
+
argv,
|
|
448
|
+
parse: parseBuild,
|
|
449
|
+
loadApp: deps.loadApp,
|
|
450
|
+
cwd: deps.cwd
|
|
451
|
+
});
|
|
452
|
+
if (prepared.kind === "help") {
|
|
453
|
+
deps.stdout(formatBuildHelp());
|
|
454
|
+
return { code: 0 };
|
|
455
|
+
}
|
|
456
|
+
if (prepared.kind === "bad-args") {
|
|
457
|
+
deps.stderr(prepared.message);
|
|
458
|
+
deps.stdout(formatBuildHelp());
|
|
459
|
+
return { code: 3 };
|
|
460
|
+
}
|
|
461
|
+
if (prepared.kind === "load-failed") {
|
|
462
|
+
deps.stderr(prepared.message);
|
|
463
|
+
return { code: 1 };
|
|
464
|
+
}
|
|
465
|
+
return runBuildOnce(prepared.app, deps.stdout, deps.stderr);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/bin/commands/dev.ts
|
|
470
|
+
/** @file `moku dev` command — watch + invalidate + rebuild + serve loop. */
|
|
471
|
+
const makeRebuildHandler = (app, deps) => async (paths) => {
|
|
472
|
+
if (app.content !== void 0) app.content.invalidate(paths);
|
|
473
|
+
try {
|
|
474
|
+
await app.build.run();
|
|
475
|
+
deps.stdout(`[dev] Rebuilt (${paths.length} change${paths.length === 1 ? "" : "s"})`);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
deps.stderr(`[dev] Rebuild failed: ${error.message}`);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
const startDevServer = (app, deps, port) => {
|
|
481
|
+
const outdir = app.config?.build?.outdir ?? "dist";
|
|
482
|
+
const defaultLocale = app.config?.i18n?.defaultLocale ?? "en";
|
|
483
|
+
return deps.serve({
|
|
484
|
+
rootDir: (0, node_path.resolve)(deps.cwd, outdir),
|
|
485
|
+
port,
|
|
486
|
+
defaultLocale
|
|
487
|
+
});
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* Run the dev subcommand.
|
|
491
|
+
*
|
|
492
|
+
* Builds once, then watches `contentDir` and on each batch invalidates
|
|
493
|
+
* content paths BEFORE calling `app.build.run()` (hard rule from CLAUDE.md).
|
|
494
|
+
*
|
|
495
|
+
* @param argv - Argv after the `dev` keyword.
|
|
496
|
+
* @param deps - Injected IO / loader / watch / serve dependencies.
|
|
497
|
+
* @returns Exit code: 0 ok, 1 config error, 2 build error, 3 arg error.
|
|
498
|
+
*/
|
|
499
|
+
const devCommand = async (argv, deps) => {
|
|
500
|
+
const prepared = await prepareApp({
|
|
501
|
+
argv,
|
|
502
|
+
parse: parseDev,
|
|
503
|
+
loadApp: deps.loadApp,
|
|
504
|
+
cwd: deps.cwd
|
|
505
|
+
});
|
|
506
|
+
if (prepared.kind === "help") {
|
|
507
|
+
deps.stdout(formatDevHelp());
|
|
508
|
+
return { code: 0 };
|
|
509
|
+
}
|
|
510
|
+
if (prepared.kind === "bad-args") {
|
|
511
|
+
deps.stderr(prepared.message);
|
|
512
|
+
deps.stdout(formatDevHelp());
|
|
513
|
+
return { code: 3 };
|
|
514
|
+
}
|
|
515
|
+
if (prepared.kind === "load-failed") {
|
|
516
|
+
deps.stderr(prepared.message);
|
|
517
|
+
return { code: 1 };
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
await prepared.app.build.run();
|
|
521
|
+
} catch (error) {
|
|
522
|
+
deps.stderr(`Initial build failed: ${error.message}`);
|
|
523
|
+
return { code: 2 };
|
|
524
|
+
}
|
|
525
|
+
const handle = startDevServer(prepared.app, deps, prepared.args.port);
|
|
526
|
+
deps.stdout(`[dev] Serving at http://localhost:${handle.port}`);
|
|
527
|
+
deps.watch({
|
|
528
|
+
rootDir: deps.cwd,
|
|
529
|
+
contentDir: (0, node_path.resolve)(deps.cwd, "content"),
|
|
530
|
+
onChange: makeRebuildHandler(prepared.app, deps)
|
|
531
|
+
});
|
|
532
|
+
return { code: 0 };
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region src/bin/commands/preview.ts
|
|
537
|
+
/** @file `moku preview` command — build once, then serve the outdir. */
|
|
538
|
+
const startPreviewServer = (app, cwd, port, serve) => {
|
|
539
|
+
const outdir = app.config?.build?.outdir ?? "dist";
|
|
540
|
+
const defaultLocale = app.config?.i18n?.defaultLocale ?? "en";
|
|
541
|
+
return serve({
|
|
542
|
+
rootDir: (0, node_path.resolve)(cwd, outdir),
|
|
543
|
+
port,
|
|
544
|
+
defaultLocale
|
|
545
|
+
});
|
|
546
|
+
};
|
|
547
|
+
/**
|
|
548
|
+
* Run the preview subcommand.
|
|
549
|
+
*
|
|
550
|
+
* @param argv - Argv after the `preview` keyword.
|
|
551
|
+
* @param deps - Injected IO / loader / serve dependencies.
|
|
552
|
+
* @returns Exit code: 0 ok, 1 config error, 2 build error, 3 arg error.
|
|
553
|
+
*/
|
|
554
|
+
const previewCommand = async (argv, deps) => {
|
|
555
|
+
const prepared = await prepareApp({
|
|
556
|
+
argv,
|
|
557
|
+
parse: parsePreview,
|
|
558
|
+
loadApp: deps.loadApp,
|
|
559
|
+
cwd: deps.cwd
|
|
560
|
+
});
|
|
561
|
+
if (prepared.kind === "help") {
|
|
562
|
+
deps.stdout(formatPreviewHelp());
|
|
563
|
+
return { code: 0 };
|
|
564
|
+
}
|
|
565
|
+
if (prepared.kind === "bad-args") {
|
|
566
|
+
deps.stderr(prepared.message);
|
|
567
|
+
deps.stdout(formatPreviewHelp());
|
|
568
|
+
return { code: 3 };
|
|
569
|
+
}
|
|
570
|
+
if (prepared.kind === "load-failed") {
|
|
571
|
+
deps.stderr(prepared.message);
|
|
572
|
+
return { code: 1 };
|
|
573
|
+
}
|
|
574
|
+
const build = await runBuildOnce(prepared.app, deps.stdout, deps.stderr);
|
|
575
|
+
if (build.code !== 0) return build;
|
|
576
|
+
const handle = startPreviewServer(prepared.app, deps.cwd, prepared.args.port, deps.serve);
|
|
577
|
+
deps.stdout(`[preview] Serving at http://localhost:${handle.port}`);
|
|
578
|
+
return { code: 0 };
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
//#endregion
|
|
582
|
+
//#region src/bin/load-app.ts
|
|
583
|
+
/** @file Discovers and dynamically imports the consumer's main.ts entry. */
|
|
584
|
+
/**
|
|
585
|
+
* Dynamically import `{cwd}/{folder}/main.ts` and return its default export.
|
|
586
|
+
*
|
|
587
|
+
* Returns a structured result rather than throwing; the CLI maps errors to
|
|
588
|
+
* exit codes.
|
|
589
|
+
*
|
|
590
|
+
* @param options - The cwd and folder to look in.
|
|
591
|
+
* @returns A {@link LoadResult}.
|
|
592
|
+
*/
|
|
593
|
+
const loadApp = async (options) => {
|
|
594
|
+
const mainPath = (0, node_path.resolve)(options.cwd, options.folder, "main.ts");
|
|
595
|
+
if (!(0, node_fs.existsSync)(mainPath)) return {
|
|
596
|
+
ok: false,
|
|
597
|
+
message: `main.ts not found at ${mainPath}. Run \`moku <command> <folder>\` with the correct path.`,
|
|
598
|
+
path: mainPath
|
|
599
|
+
};
|
|
600
|
+
try {
|
|
601
|
+
const mod = await import(mainPath);
|
|
602
|
+
if (mod.default === void 0) return {
|
|
603
|
+
ok: false,
|
|
604
|
+
message: `${mainPath} has no default export. Export your createApp() result as default.`,
|
|
605
|
+
path: mainPath
|
|
606
|
+
};
|
|
607
|
+
return {
|
|
608
|
+
ok: true,
|
|
609
|
+
value: mod.default,
|
|
610
|
+
path: mainPath
|
|
611
|
+
};
|
|
612
|
+
} catch (error) {
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
message: `Failed to import ${mainPath}: ${error.message}`,
|
|
616
|
+
path: mainPath
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/bin/serve.ts
|
|
623
|
+
/** @file Static file handler used by `moku preview` and `moku dev`. */
|
|
624
|
+
const MIME_TYPES = new Map([
|
|
625
|
+
[".html", "text/html; charset=utf-8"],
|
|
626
|
+
[".css", "text/css; charset=utf-8"],
|
|
627
|
+
[".js", "application/javascript; charset=utf-8"],
|
|
628
|
+
[".mjs", "application/javascript; charset=utf-8"],
|
|
629
|
+
[".json", "application/json; charset=utf-8"],
|
|
630
|
+
[".svg", "image/svg+xml"],
|
|
631
|
+
[".png", "image/png"],
|
|
632
|
+
[".jpg", "image/jpeg"],
|
|
633
|
+
[".jpeg", "image/jpeg"],
|
|
634
|
+
[".gif", "image/gif"],
|
|
635
|
+
[".webp", "image/webp"],
|
|
636
|
+
[".ico", "image/x-icon"],
|
|
637
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
638
|
+
[".xml", "application/xml; charset=utf-8"],
|
|
639
|
+
[".woff", "font/woff"],
|
|
640
|
+
[".woff2", "font/woff2"]
|
|
641
|
+
]);
|
|
642
|
+
const mimeFor = (path) => MIME_TYPES.get((0, node_path.extname)(path).toLowerCase()) ?? "application/octet-stream";
|
|
643
|
+
const isPathInside = (parent, child) => `${child}${node_path.sep}`.startsWith(`${parent}${node_path.sep}`);
|
|
644
|
+
const resolveTarget = (rootDir, urlPath) => {
|
|
645
|
+
const target = (0, node_path.resolve)(rootDir, (0, node_path.normalize)(`./${decodeURIComponent(urlPath).replaceAll("\\", "/").replace(/\/+/g, "/")}`));
|
|
646
|
+
if (!isPathInside(rootDir, target) && target !== rootDir) return null;
|
|
647
|
+
return target;
|
|
648
|
+
};
|
|
649
|
+
const resolveFilePath = (target, urlPath) => {
|
|
650
|
+
const candidate = urlPath.endsWith("/") ? (0, node_path.resolve)(target, "index.html") : target;
|
|
651
|
+
if (!(0, node_fs.existsSync)(candidate)) return null;
|
|
652
|
+
if (!(0, node_fs.statSync)(candidate).isDirectory()) return candidate;
|
|
653
|
+
const indexed = (0, node_path.resolve)(candidate, "index.html");
|
|
654
|
+
return (0, node_fs.existsSync)(indexed) ? indexed : null;
|
|
655
|
+
};
|
|
656
|
+
const fileResponse = (path) => new Response((0, node_fs.readFileSync)(path), { headers: { "content-type": mimeFor(path) } });
|
|
657
|
+
/**
|
|
658
|
+
* Build a fetch-style handler that serves files from `rootDir`.
|
|
659
|
+
*
|
|
660
|
+
* Behavior:
|
|
661
|
+
* - Bare `/` redirects (307) to `/{defaultLocale}/`.
|
|
662
|
+
* - Paths ending in `/` look for `index.html`.
|
|
663
|
+
* - Path traversal attempts return 403.
|
|
664
|
+
* - Missing files return 404.
|
|
665
|
+
*
|
|
666
|
+
* @param options - The root directory and default locale.
|
|
667
|
+
* @returns A `(request: Request) => Promise<Response>` handler.
|
|
668
|
+
*/
|
|
669
|
+
const createStaticHandler = (options) => {
|
|
670
|
+
const root = (0, node_path.resolve)(options.rootDir);
|
|
671
|
+
return async (request) => {
|
|
672
|
+
const url = new URL(request.url);
|
|
673
|
+
if (url.pathname === "/") return new Response(null, {
|
|
674
|
+
status: 307,
|
|
675
|
+
headers: { location: `/${options.defaultLocale}/` }
|
|
676
|
+
});
|
|
677
|
+
const target = resolveTarget(root, url.pathname);
|
|
678
|
+
if (target === null) return new Response("Forbidden", { status: 403 });
|
|
679
|
+
const filePath = resolveFilePath(target, url.pathname);
|
|
680
|
+
if (filePath === null) return new Response("Not Found", { status: 404 });
|
|
681
|
+
return fileResponse(filePath);
|
|
682
|
+
};
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/bin/watch.ts
|
|
687
|
+
/**
|
|
688
|
+
* Create a trailing-edge debounced change batcher.
|
|
689
|
+
*
|
|
690
|
+
* Each `push(path)` (re)starts the debounce timer. When the timer fires the
|
|
691
|
+
* collected unique paths are passed to `onFlush` and a new batch begins.
|
|
692
|
+
* `flush()` drains immediately; `dispose()` cancels any pending flush without
|
|
693
|
+
* invoking the callback.
|
|
694
|
+
*
|
|
695
|
+
* @param options - Debounce window + flush callback.
|
|
696
|
+
* @returns The {@link ChangeBatcher} handle.
|
|
697
|
+
*/
|
|
698
|
+
const createChangeBatcher = (options) => {
|
|
699
|
+
let pending = /* @__PURE__ */ new Set();
|
|
700
|
+
let timer = null;
|
|
701
|
+
const clearTimer = () => {
|
|
702
|
+
if (timer !== null) {
|
|
703
|
+
clearTimeout(timer);
|
|
704
|
+
timer = null;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
const drain = () => {
|
|
708
|
+
clearTimer();
|
|
709
|
+
if (pending.size === 0) return;
|
|
710
|
+
const batch = [...pending];
|
|
711
|
+
pending = /* @__PURE__ */ new Set();
|
|
712
|
+
options.onFlush(batch);
|
|
713
|
+
};
|
|
714
|
+
return {
|
|
715
|
+
push: (path) => {
|
|
716
|
+
pending.add(path);
|
|
717
|
+
clearTimer();
|
|
718
|
+
timer = setTimeout(drain, options.debounceMs);
|
|
719
|
+
},
|
|
720
|
+
flush: drain,
|
|
721
|
+
dispose: () => {
|
|
722
|
+
clearTimer();
|
|
723
|
+
pending = /* @__PURE__ */ new Set();
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/bin/moku.ts
|
|
730
|
+
/** @file moku CLI entry — wires runtime IO to runCli. NOT a plugin. */
|
|
731
|
+
const findPackageJson = () => {
|
|
732
|
+
const candidates = [
|
|
733
|
+
(0, node_path.resolve)({}.dir, "..", "..", "package.json"),
|
|
734
|
+
(0, node_path.resolve)({}.dir, "..", "package.json"),
|
|
735
|
+
(0, node_path.resolve)(process.cwd(), "package.json")
|
|
736
|
+
];
|
|
737
|
+
for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate)) return JSON.parse((0, node_fs.readFileSync)(candidate, "utf8"));
|
|
738
|
+
return {};
|
|
739
|
+
};
|
|
740
|
+
const pkg = findPackageJson();
|
|
741
|
+
const stdout = (line) => {
|
|
742
|
+
process.stdout.write(`${line}\n`);
|
|
743
|
+
};
|
|
744
|
+
const stderr = (line) => {
|
|
745
|
+
process.stderr.write(`${line}\n`);
|
|
746
|
+
};
|
|
747
|
+
const serveStatic = (options) => {
|
|
748
|
+
const handler = createStaticHandler({
|
|
749
|
+
rootDir: options.rootDir,
|
|
750
|
+
defaultLocale: options.defaultLocale
|
|
751
|
+
});
|
|
752
|
+
const server = Bun.serve({
|
|
753
|
+
port: options.port,
|
|
754
|
+
fetch: handler
|
|
755
|
+
});
|
|
756
|
+
return {
|
|
757
|
+
port: server.port ?? options.port,
|
|
758
|
+
stop: () => server.stop()
|
|
759
|
+
};
|
|
760
|
+
};
|
|
761
|
+
const startWatcher = (options) => {
|
|
762
|
+
const batcher = createChangeBatcher({
|
|
763
|
+
debounceMs: 50,
|
|
764
|
+
onFlush: (paths) => options.onChange(paths)
|
|
765
|
+
});
|
|
766
|
+
if (!(0, node_fs.existsSync)(options.contentDir)) return { stop: () => batcher.dispose() };
|
|
767
|
+
const watcher = (0, node_fs.watch)(options.contentDir, { recursive: true }, (_event, filename) => {
|
|
768
|
+
if (filename !== null) batcher.push(filename.toString());
|
|
769
|
+
});
|
|
770
|
+
return { stop: () => {
|
|
771
|
+
watcher.close();
|
|
772
|
+
batcher.dispose();
|
|
773
|
+
} };
|
|
774
|
+
};
|
|
775
|
+
const main = async () => {
|
|
776
|
+
const result = await runCli(process.argv.slice(2), {
|
|
777
|
+
cwd: process.cwd(),
|
|
778
|
+
version: pkg.version ?? "0.0.0",
|
|
779
|
+
bunVersion: Bun.version,
|
|
780
|
+
bunEngine: pkg.engines?.bun,
|
|
781
|
+
stdout,
|
|
782
|
+
stderr,
|
|
783
|
+
buildCommand: (argv) => buildCommand(argv, {
|
|
784
|
+
cwd: process.cwd(),
|
|
785
|
+
stdout,
|
|
786
|
+
stderr,
|
|
787
|
+
loadApp
|
|
788
|
+
}),
|
|
789
|
+
devCommand: (argv) => devCommand(argv, {
|
|
790
|
+
cwd: process.cwd(),
|
|
791
|
+
stdout,
|
|
792
|
+
stderr,
|
|
793
|
+
loadApp,
|
|
794
|
+
watch: startWatcher,
|
|
795
|
+
serve: serveStatic
|
|
796
|
+
}),
|
|
797
|
+
previewCommand: (argv) => previewCommand(argv, {
|
|
798
|
+
cwd: process.cwd(),
|
|
799
|
+
stdout,
|
|
800
|
+
stderr,
|
|
801
|
+
loadApp,
|
|
802
|
+
serve: serveStatic
|
|
803
|
+
})
|
|
804
|
+
});
|
|
805
|
+
process.exit(result.code);
|
|
806
|
+
};
|
|
807
|
+
main();
|
|
808
|
+
|
|
809
|
+
//#endregion
|