@moku-labs/web 0.1.0-alpha.3 → 0.2.0

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 (38) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +71 -23
  3. package/dist/chunk-DQk6qfdC.mjs +18 -0
  4. package/dist/index.cjs +5514 -36
  5. package/dist/index.d.cts +2078 -104
  6. package/dist/index.d.mts +2078 -104
  7. package/dist/index.mjs +5394 -28
  8. package/package.json +60 -60
  9. package/dist/bin/moku.cjs +0 -1383
  10. package/dist/bin/moku.d.cts +0 -1
  11. package/dist/bin/moku.d.mts +0 -1
  12. package/dist/bin/moku.mjs +0 -1383
  13. package/dist/factory-BHhulW27.d.mts +0 -90
  14. package/dist/factory-D0m7Xil2.d.cts +0 -90
  15. package/dist/factory-DVcAQYEZ.cjs +0 -1710
  16. package/dist/factory-DwpBwjDk.mjs +0 -1602
  17. package/dist/index-CddOHo8I.d.mts +0 -413
  18. package/dist/plugins/head/build.cjs +0 -35
  19. package/dist/plugins/head/build.d.cts +0 -17
  20. package/dist/plugins/head/build.d.mts +0 -17
  21. package/dist/plugins/head/build.mjs +0 -27
  22. package/dist/plugins/spa/index.cjs +0 -26
  23. package/dist/plugins/spa/index.d.cts +0 -30
  24. package/dist/plugins/spa/index.d.mts +0 -30
  25. package/dist/plugins/spa/index.mjs +0 -24
  26. package/dist/primitives-BBo4wxUL.d.cts +0 -69
  27. package/dist/primitives-BYUp6kae.cjs +0 -100
  28. package/dist/primitives-Dlfi3JTx.d.mts +0 -69
  29. package/dist/primitives-gO5i1tD8.mjs +0 -58
  30. package/dist/project-1pAh4RxJ.cjs +0 -1270
  31. package/dist/project-BaG_ipVz.mjs +0 -1203
  32. package/dist/route-builder-CKvvehVO.d.cts +0 -413
  33. package/dist/test.cjs +0 -82
  34. package/dist/test.d.cts +0 -61
  35. package/dist/test.d.mts +0 -61
  36. package/dist/test.mjs +0 -79
  37. package/dist/wrangler-BHdkyMRj.cjs +0 -423
  38. package/dist/wrangler-Coyrznz4.mjs +0 -369
@@ -1,1270 +0,0 @@
1
- //#region \0rolldown/runtime.js
2
- var __create = Object.create;
3
- var __defProp = Object.defineProperty;
4
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
- var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
- var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __copyProps = (to, from, except, desc) => {
9
- if (from && typeof from === "object" || typeof from === "function") {
10
- for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
- key = keys[i];
12
- if (!__hasOwnProp.call(to, key) && key !== except) {
13
- __defProp(to, key, {
14
- get: ((k) => from[k]).bind(null, key),
15
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
16
- });
17
- }
18
- }
19
- }
20
- return to;
21
- };
22
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
23
- value: mod,
24
- enumerable: true
25
- }) : target, mod));
26
-
27
- //#endregion
28
- const require_factory = require('./factory-DVcAQYEZ.cjs');
29
- const require_wrangler = require('./wrangler-BHdkyMRj.cjs');
30
- let node_fs = require("node:fs");
31
- let node_fs_promises = require("node:fs/promises");
32
- let node_path = require("node:path");
33
- let gray_matter = require("gray-matter");
34
- gray_matter = __toESM(gray_matter);
35
- let _shikijs_rehype = require("@shikijs/rehype");
36
- _shikijs_rehype = __toESM(_shikijs_rehype);
37
- let rehype_raw = require("rehype-raw");
38
- rehype_raw = __toESM(rehype_raw);
39
- let rehype_sanitize = require("rehype-sanitize");
40
- rehype_sanitize = __toESM(rehype_sanitize);
41
- let rehype_stringify = require("rehype-stringify");
42
- rehype_stringify = __toESM(rehype_stringify);
43
- let remark_directive = require("remark-directive");
44
- remark_directive = __toESM(remark_directive);
45
- let remark_frontmatter = require("remark-frontmatter");
46
- remark_frontmatter = __toESM(remark_frontmatter);
47
- let remark_gfm = require("remark-gfm");
48
- remark_gfm = __toESM(remark_gfm);
49
- let remark_parse = require("remark-parse");
50
- remark_parse = __toESM(remark_parse);
51
- let remark_rehype = require("remark-rehype");
52
- remark_rehype = __toESM(remark_rehype);
53
- let unified = require("unified");
54
- let reading_time = require("reading-time");
55
- reading_time = __toESM(reading_time);
56
-
57
- //#region src/plugins/content/handlers.ts
58
- const createHandlers = (_ctx) => ({});
59
-
60
- //#endregion
61
- //#region src/plugins/content/state.ts
62
- const createContentState = () => ({
63
- processor: null,
64
- articles: /* @__PURE__ */ new Map(),
65
- slugs: null,
66
- lastPaths: /* @__PURE__ */ new Set(),
67
- dirtyPaths: /* @__PURE__ */ new Set()
68
- });
69
-
70
- //#endregion
71
- //#region src/plugins/content/validate.ts
72
- /** @file content plugin onInit hook — dir existence check. */
73
- /**
74
- * Validate that `config.dir` is an existing directory at app-init time.
75
- *
76
- * Runs in the content plugin's `onInit`. Failures surface at `createApp()` time
77
- * (fail-fast), never on first `loadAll()`.
78
- *
79
- * @param ctx - Plugin context containing the resolved config.
80
- * @throws Error when `dir` is empty, missing, or not a directory.
81
- */
82
- const validateContentConfig = (ctx) => {
83
- const { dir } = ctx.config;
84
- if (dir === "" || !(0, node_fs.existsSync)(dir) || !(0, node_fs.statSync)(dir).isDirectory()) throw new Error(`content: dir "${dir}" does not exist or is not a directory`);
85
- };
86
-
87
- //#endregion
88
- //#region src/plugins/content/invalidate.ts
89
- /**
90
- * Mark specific paths as stale so the next `loadAll()` re-reads them while
91
- * leaving every other cached article untouched. Also nulls `state.slugs` so
92
- * new slugs created on disk are discovered.
93
- *
94
- * @param state - Plugin state.
95
- * @param paths - Absolute filesystem paths that have changed.
96
- */
97
- const invalidatePaths = (state, paths) => {
98
- for (const path of paths) state.dirtyPaths.add(path);
99
- state.slugs = null;
100
- };
101
-
102
- //#endregion
103
- //#region src/plugins/content/path-guard.ts
104
- /** @file Path traversal guard — path.resolve + startsWith(contentDir) check. OWASP standard defense. */
105
- const safePath = (contentDir, slug, ...segments) => {
106
- const resolved = (0, node_path.resolve)(contentDir, slug, ...segments);
107
- const root = (0, node_path.resolve)(contentDir) + node_path.sep;
108
- if (!resolved.startsWith(root)) throw new Error(`[content] Path traversal detected in slug: ${slug}`);
109
- return resolved;
110
- };
111
-
112
- //#endregion
113
- //#region src/plugins/content/pipeline/frontmatter.ts
114
- /** @file Frontmatter parsing — gray-matter + prototype-pollution guard (rejects __proto__, constructor, prototype). */
115
- const DANGEROUS_KEYS = new Set([
116
- "__proto__",
117
- "constructor",
118
- "prototype"
119
- ]);
120
- /**
121
- * Test whether a parsed frontmatter record contains a prototype-pollution key.
122
- *
123
- * @param data - Parsed key/value record from gray-matter.
124
- * @returns `true` when any key matches `__proto__`, `constructor`, or `prototype`.
125
- */
126
- const hasDangerousKey = (data) => {
127
- for (const key of Object.keys(data)) if (DANGEROUS_KEYS.has(key)) return true;
128
- return false;
129
- };
130
- const toNullPrototype = (source) => {
131
- const out = Object.create(null);
132
- for (const key of Object.keys(source)) out[key] = source[key];
133
- return out;
134
- };
135
- /**
136
- * Parse YAML frontmatter from a markdown source string, returning the data
137
- * (on a null-prototype container) and the body.
138
- *
139
- * Throws when any prototype-pollution key (`__proto__`, `constructor`,
140
- * `prototype`) is present at the top level of the parsed YAML.
141
- *
142
- * @param raw - Full markdown file contents.
143
- * @param filePath - Source path (used in error messages).
144
- * @param defaultAuthor - Fallback author when frontmatter omits the `author` field.
145
- * @returns `{ data, content }` — data is a null-prototype Record; content is the body string.
146
- * @throws Error when prototype-pollution keys are present.
147
- */
148
- const parseFrontmatter = (raw, filePath, defaultAuthor) => {
149
- const parsed = (0, gray_matter.default)(raw);
150
- const rawData = parsed.data;
151
- if (hasDangerousKey(rawData)) throw new Error(`[content] Prototype-pollution key in frontmatter: ${filePath}`);
152
- const safe = toNullPrototype(rawData);
153
- if (safe.author === void 0 && defaultAuthor !== void 0) safe.author = defaultAuthor;
154
- return {
155
- data: safe,
156
- content: parsed.content
157
- };
158
- };
159
-
160
- //#endregion
161
- //#region src/plugins/content/pipeline/markdown.ts
162
- /** @file Markdown rendering via unified + remark + rehype + Shiki. Processor is a singleton in state (lazy init). */
163
- const applyEntries = (processor, entries) => {
164
- let p = processor;
165
- for (const [plugin, options] of entries) p = options === void 0 ? p.use(plugin) : p.use(plugin, options);
166
- return p;
167
- };
168
- /**
169
- * Build a unified processor with the framework's canonical markdown pipeline.
170
- *
171
- * Pipeline order:
172
- * remark-parse → remark-frontmatter → remark-gfm → remark-directive
173
- * → consumer remark plugins
174
- * → remark-rehype({ allowDangerousHtml: true }) → rehype-raw
175
- * → @shikijs/rehype (skipped when `shikiTheme: false`)
176
- * → consumer rehype plugins
177
- * → rehype-sanitize (UNLESS trustedContent: true)
178
- * → rehype-stringify
179
- *
180
- * Shiki runs before consumer rehype plugins and before sanitize so that the
181
- * sanitize step sees Shiki's output (highlight spans/classes) and approves it
182
- * against its allow-list — sanitize is the last security step (HARD RULE 3).
183
- *
184
- * @param options - Pipeline options including trustedContent flag and plugin entries.
185
- * @returns A unified Processor instance suitable for repeated `.process()` calls.
186
- * @example
187
- * const p = await createProcessor({ trustedContent: false })
188
- * const file = await p.process('# hello')
189
- */
190
- const createProcessor = async (options) => {
191
- let p = (0, unified.unified)();
192
- p = p.use(remark_parse.default).use(remark_frontmatter.default).use(remark_gfm.default).use(remark_directive.default);
193
- if (options.remarkPlugins && options.remarkPlugins.length > 0) p = applyEntries(p, options.remarkPlugins);
194
- p = p.use(remark_rehype.default, { allowDangerousHtml: true }).use(rehype_raw.default);
195
- const theme = options.shikiTheme ?? "github-dark";
196
- if (theme !== false) {
197
- const shikiOptions = typeof theme === "string" ? { theme } : { themes: theme };
198
- p = p.use(_shikijs_rehype.default, shikiOptions);
199
- }
200
- if (options.rehypePlugins && options.rehypePlugins.length > 0) p = applyEntries(p, options.rehypePlugins);
201
- if (!options.trustedContent) p = p.use(rehype_sanitize.default);
202
- p = p.use(rehype_stringify.default);
203
- return p;
204
- };
205
- /**
206
- * Render a markdown source string to HTML via the supplied unified processor.
207
- *
208
- * @param processor - A processor returned by `createProcessor`.
209
- * @param content - Markdown source text (body — no frontmatter required).
210
- * @returns Rendered HTML string.
211
- */
212
- const renderMarkdown = async (processor, content) => {
213
- const file = await processor.process(content);
214
- return String(file);
215
- };
216
-
217
- //#endregion
218
- //#region src/plugins/content/pipeline/reading-time.ts
219
- /** @file Reading time + word count calculation. Wraps the `reading-time` npm package. */
220
- /**
221
- * Compute reading time (in whole minutes, minimum 1 for non-empty text) and
222
- * word count for a markdown / plain-text body.
223
- *
224
- * Empty input returns `{ minutes: 0, words: 0 }` — never NaN.
225
- *
226
- * @param text - Markdown source or plain text to analyse.
227
- * @returns Object with rounded `minutes` (>= 1 when text is non-empty) and exact `words` count.
228
- * @example
229
- * calculateReadingTime('hello world') // { minutes: 1, words: 2 }
230
- */
231
- const calculateReadingTime = (text) => {
232
- if (text.length === 0) return {
233
- minutes: 0,
234
- words: 0
235
- };
236
- const stats = (0, reading_time.default)(text);
237
- return {
238
- minutes: Math.max(1, Math.round(stats.minutes)),
239
- words: stats.words
240
- };
241
- };
242
-
243
- //#endregion
244
- //#region src/plugins/content/loader.ts
245
- /** @file Article loader — discoverSlugs (fs.readdir scan) + loadAllArticles (Promise.all per locale). */
246
- /**
247
- * Scan the content directory for slug-like subdirectories.
248
- *
249
- * A "slug" is any direct child directory of `dir` whose name does not start
250
- * with `.` or `_`. Result is alphabetically sorted to keep `contentId`
251
- * assignment deterministic.
252
- *
253
- * @param dir - Absolute path to the content root.
254
- * @returns Sorted list of slug names (directory basenames).
255
- */
256
- const discoverSlugs = async (dir) => {
257
- const entries = await (0, node_fs_promises.readdir)(dir, { withFileTypes: true });
258
- const slugs = [];
259
- for (const entry of entries) {
260
- if (!entry.isDirectory()) continue;
261
- const name = entry.name;
262
- if (name.startsWith(".") || name.startsWith("_")) continue;
263
- slugs.push(name);
264
- }
265
- return slugs.sort();
266
- };
267
- /**
268
- * Load a single article for a given (slug, locale).
269
- *
270
- * Returns `null` when the per-locale file does not exist. Throws when the
271
- * slug attempts path traversal or when frontmatter parsing fails.
272
- *
273
- * @param state - Plugin state (used for processor singleton; mutated when null).
274
- * @param slug - Slug (subdirectory name).
275
- * @param locale - Locale code (file basename, e.g. `en` → `en.md`).
276
- * @param dir - Absolute content root.
277
- * @param trustedContent - When true skips rehype-sanitize.
278
- * @param defaultAuthor - Optional default author for frontmatter.
279
- * @returns The constructed Article (without `contentId`) or null.
280
- */
281
- const loadOneArticle = async (state, slug, locale, dir, trustedContent, defaultAuthor) => {
282
- const filePath = safePath(dir, slug, `${locale}.md`);
283
- let raw;
284
- try {
285
- raw = await (0, node_fs_promises.readFile)(filePath, "utf8");
286
- } catch {
287
- return null;
288
- }
289
- state.lastPaths.add(filePath);
290
- const { data, content } = parseFrontmatter(raw, filePath, defaultAuthor);
291
- const html = await renderMarkdown(await ensureProcessor(state, trustedContent), content);
292
- const reading = calculateReadingTime(content);
293
- return {
294
- slug,
295
- locale,
296
- frontmatter: data,
297
- html,
298
- computed: {
299
- contentId: "",
300
- readingTimeMinutes: reading.minutes,
301
- wordCount: reading.words
302
- }
303
- };
304
- };
305
- /**
306
- * Lazy-initialise the singleton Shiki-bearing unified processor.
307
- *
308
- * First call creates and stores it on state; subsequent calls return the
309
- * same instance. This is the Performance Mitigation #1 from the spec.
310
- *
311
- * @param state - Plugin state.
312
- * @param trustedContent - Sanitize opt-out flag.
313
- * @returns The shared processor.
314
- */
315
- const ensureProcessor = async (state, trustedContent) => {
316
- if (state.processor === null) state.processor = await createProcessor({ trustedContent });
317
- return state.processor;
318
- };
319
- const buildContentId = (locale, index, slug) => `${locale}:${String(index).padStart(4, "0")}:${slug}`;
320
- /**
321
- * Resolve an Article for `(slug, locale)`: serve from the per-locale article
322
- * cache unless its filesystem path is in `state.dirtyPaths`, otherwise re-read
323
- * from disk. Returns `null` when the file does not exist.
324
- *
325
- * @param state - Plugin state.
326
- * @param slug - Slug name.
327
- * @param locale - Locale code.
328
- * @param dir - Absolute content root.
329
- * @param trustedContent - Sanitize opt-out flag.
330
- * @param defaultAuthor - Optional default author.
331
- * @returns The Article or null.
332
- */
333
- const resolveArticle = async (state, slug, locale, dir, trustedContent, defaultAuthor) => {
334
- const filePath = safePath(dir, slug, `${locale}.md`);
335
- const cached = state.articles.get(locale)?.get(slug);
336
- if (cached !== void 0 && !state.dirtyPaths.has(filePath)) {
337
- state.lastPaths.add(filePath);
338
- return cached;
339
- }
340
- return loadOneArticle(state, slug, locale, dir, trustedContent, defaultAuthor);
341
- };
342
- /**
343
- * Load all articles across the provided locale list.
344
- *
345
- * For each locale, articles are loaded in parallel via `Promise.all` (Mitigation #2),
346
- * then sorted by slug and assigned a per-locale `contentId` in order (Mitigation #3).
347
- * Cross-locale parallelism is safe.
348
- *
349
- * Articles already present in `state.articles` whose filesystem path is NOT in
350
- * `state.dirtyPaths` are served from cache (Spec verification: "invalidate(['foo'])
351
- * followed by loadAll() re-reads only foo-related paths"). After a successful
352
- * `loadAll()`, `state.dirtyPaths` is cleared.
353
- *
354
- * @param state - Plugin state.
355
- * @param locales - Locale codes (e.g. ['en', 'ru']).
356
- * @param dir - Absolute content root.
357
- * @param trustedContent - Sanitize opt-out flag.
358
- * @param defaultAuthor - Optional default author.
359
- * @returns Map keyed by locale → Article[] (deterministic order).
360
- */
361
- const loadAllArticles = async (state, locales, dir, trustedContent, defaultAuthor) => {
362
- const slugs = state.slugs ?? await discoverSlugs(dir);
363
- state.slugs = slugs;
364
- const result = /* @__PURE__ */ new Map();
365
- const perLocale = await Promise.all(locales.map(async (locale) => {
366
- const present = (await Promise.all(slugs.map((slug) => resolveArticle(state, slug, locale, dir, trustedContent, defaultAuthor)))).filter((a) => a !== null).sort((a, b) => a.slug.localeCompare(b.slug));
367
- let index = 0;
368
- for (const article of present) {
369
- article.computed.contentId = buildContentId(locale, index, article.slug);
370
- index += 1;
371
- }
372
- return [locale, present];
373
- }));
374
- for (const [locale, articles] of perLocale) {
375
- result.set(locale, articles);
376
- const byMap = /* @__PURE__ */ new Map();
377
- for (const a of articles) byMap.set(a.slug, a);
378
- state.articles.set(locale, byMap);
379
- }
380
- state.dirtyPaths.clear();
381
- return result;
382
- };
383
-
384
- //#endregion
385
- //#region src/plugins/content/api.ts
386
- /** @file content plugin API factory — wires loader, processor, invalidate, reset. */
387
- /**
388
- * Build the content plugin's public API surface.
389
- *
390
- * Wires sub-modules: loader (loadAll/load/discoverSlugs), markdown processor
391
- * (render), invalidate, and reset. The processor is a lazy singleton on
392
- * `ctx.state.processor` — first call to `render` or `loadAll` initialises it.
393
- *
394
- * @param ctx - Plugin context with `{ state, config, emit, locales }`. `locales` is
395
- * provided by the wiring layer (index.ts) which calls `ctx.require(i18n)`.
396
- * @returns The ContentApi.
397
- * @example
398
- * const api = createContentApi(ctx)
399
- * const all = await api.loadAll()
400
- */
401
- const createContentApi = (ctx) => ({
402
- loadAll: async () => {
403
- const articles = await loadAllArticles(ctx.state, ctx.locales(), ctx.config.dir, ctx.config.trustedContent, ctx.config.defaultAuthor);
404
- ctx.emit("content:ready", { articles });
405
- return articles;
406
- },
407
- load: async (slug, locale) => loadOneArticle(ctx.state, slug, locale, ctx.config.dir, ctx.config.trustedContent, ctx.config.defaultAuthor),
408
- discoverSlugs: async () => discoverSlugs(ctx.config.dir),
409
- render: async (markdown) => {
410
- return renderMarkdown(await ensureProcessor(ctx.state, ctx.config.trustedContent), markdown);
411
- },
412
- invalidate: (paths) => {
413
- invalidatePaths(ctx.state, paths);
414
- ctx.emit("content:invalidated", { paths });
415
- },
416
- reset: () => {
417
- ctx.state.articles.clear();
418
- ctx.state.slugs = null;
419
- ctx.state.lastPaths.clear();
420
- ctx.state.dirtyPaths.clear();
421
- ctx.state.processor = null;
422
- }
423
- });
424
-
425
- //#endregion
426
- //#region src/plugins/content/wire.ts
427
- /** @file Bridge framework plugin context to {@link createContentApi}'s ContentApiCtx. */
428
- /**
429
- * Adapt the framework's plugin context to {@link ContentApiCtx} and build the API.
430
- *
431
- * @param ctx - The framework's plugin context for `content`.
432
- * @returns The wired ContentApi.
433
- */
434
- const wireContentApi = (ctx) => {
435
- return createContentApi({
436
- state: ctx.state,
437
- config: ctx.config,
438
- emit: ctx.emit,
439
- locales: () => ctx.require(require_factory.i18n).locales()
440
- });
441
- };
442
-
443
- //#endregion
444
- //#region src/plugins/content/index.ts
445
- /** @file content plugin: markdown pipeline + invalidate(). Complex tier. */
446
- const defaultConfig$2 = {
447
- dir: "",
448
- trustedContent: false,
449
- rehypePlugins: [],
450
- remarkPlugins: []
451
- };
452
- const content = require_factory.createPlugin("content", {
453
- depends: [require_factory.i18n],
454
- config: defaultConfig$2,
455
- events: (register) => ({
456
- "content:ready": register("Articles loaded"),
457
- "content:invalidated": register("Paths invalidated")
458
- }),
459
- createState: createContentState,
460
- api: wireContentApi,
461
- hooks: createHandlers,
462
- onInit: validateContentConfig
463
- });
464
-
465
- //#endregion
466
- //#region src/plugins/build/manifest.ts
467
- /**
468
- * Extract the basename (final path component) from a path string.
469
- *
470
- * @param path - Path string (POSIX or Windows-style).
471
- * @returns The trailing path component (filename with extension).
472
- */
473
- const basename = (path) => {
474
- const idx = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
475
- return idx === -1 ? path : path.slice(idx + 1);
476
- };
477
- /**
478
- * Build a {@link BundleManifest} from a Bun.build `BuildOutput[]`.
479
- *
480
- * Partitions outputs by extension: `.css` → `cssPaths`; `.js` / `.mjs` → `jsPaths`.
481
- * Source maps and any unrecognised extensions are ignored. The `assets` map is
482
- * keyed by output basename (logical name) → full hashed output path. The whole
483
- * manifest (including arrays and Map) is deeply frozen — Hard Rule 2 forbids
484
- * using `Object.freeze` as a substitute for deep immutability, so the freezer
485
- * recurses through every container.
486
- *
487
- * @param outputs - Bun.build BuildOutput list (unknown[] tolerated for test use).
488
- * @returns A deeply frozen BundleManifest.
489
- */
490
- const buildManifestFromOutput = (outputs) => {
491
- const cssPaths = [];
492
- const jsPaths = [];
493
- const assets = /* @__PURE__ */ new Map();
494
- for (const output of outputs) {
495
- const path = output?.path;
496
- if (typeof path !== "string") continue;
497
- if (path.endsWith(".css")) {
498
- cssPaths.push(path);
499
- assets.set(basename(path), path);
500
- } else if (path.endsWith(".js") || path.endsWith(".mjs")) {
501
- jsPaths.push(path);
502
- assets.set(basename(path), path);
503
- }
504
- }
505
- const manifest = {
506
- cssPaths: Object.freeze(cssPaths),
507
- jsPaths: Object.freeze(jsPaths),
508
- assets
509
- };
510
- return Object.freeze(manifest);
511
- };
512
-
513
- //#endregion
514
- //#region src/plugins/build/phases/bundle.ts
515
- /** @file Build phase: bundle CSS+JS via Bun.build. env.getPublicMap() is the SOLE define input. Bun is imported LAZILY. */
516
- /**
517
- * Resolve the default sourcemap setting for a given build mode.
518
- *
519
- * @param mode - The build mode (`production` | `development`).
520
- * @returns `'none'` in production, `'external'` in development.
521
- */
522
- const defaultSourcemap = (mode) => mode === "production" ? "none" : "external";
523
- /**
524
- * Build the `define` map for Bun.build EXCLUSIVELY from `env.getPublicMap()`.
525
- *
526
- * SECURITY: this map is the only sink for env values into the bundled JS.
527
- * Any non-public env value would appear verbatim in `.js.map` files — a steering-
528
- * flagged HIGH risk. The map is keyed `globalThis.__ENV__.<KEY>` and the value
529
- * is `JSON.stringify`'d so the bundler inlines a literal expression.
530
- *
531
- * @param publicEnv - The public env map sourced from `ctx.env.getPublicMap()`.
532
- * @returns A Record suitable for the `define` option of Bun.build.
533
- */
534
- const buildDefineMap = (publicEnv) => Object.fromEntries(Array.from(publicEnv, ([k, v]) => [`globalThis.__ENV__.${k}`, JSON.stringify(v)]));
535
- /**
536
- * Run the bundle phase.
537
- *
538
- * SECURITY: `define` map is built EXCLUSIVELY from `env.getPublicMap()` —
539
- * any other env value would appear verbatim in `.js.map` files. NEVER expand
540
- * this map to include non-public values; a lint rule enforces this.
541
- *
542
- * Bun is imported lazily so test environments without Bun runtime can load
543
- * the build plugin module (only `run()` requires Bun).
544
- *
545
- * @param ctx - Build phase context (state, config, env).
546
- * @returns The deeply-frozen {@link BundleManifest} derived from the Bun.build outputs.
547
- */
548
- const runBundle = async (ctx) => {
549
- const defineMap = buildDefineMap(ctx.env.getPublicMap());
550
- const entrypoints = [ctx.config.cssEntry, ctx.config.jsEntry].filter((entry) => typeof entry === "string" && entry.length > 0);
551
- return buildManifestFromOutput((await (await import("bun")).build({
552
- entrypoints,
553
- outdir: `${ctx.config.outdir}/assets`,
554
- target: "browser",
555
- splitting: true,
556
- minify: ctx.config.mode === "production",
557
- sourcemap: ctx.config.sourcemap ?? defaultSourcemap(ctx.config.mode),
558
- define: defineMap,
559
- env: "disable"
560
- })).outputs);
561
- };
562
-
563
- //#endregion
564
- //#region src/plugins/build/phases/content.ts
565
- /** @file Build phase: content pipeline runner — calls content plugin's loadAll(). */
566
- /**
567
- * Run the content phase: invokes `content.loadAll()` so all articles are
568
- * loaded into the content plugin's in-memory cache before pages render.
569
- *
570
- * The build phase does not store articles directly; the content plugin caches
571
- * them and downstream phases (`pages`, `feeds`, `og-images`) re-call `loadAll`
572
- * to obtain the cached map.
573
- *
574
- * @param ctx - Build phase context.
575
- */
576
- const runContent = async (ctx) => {
577
- await ctx.require(content).loadAll();
578
- };
579
-
580
- //#endregion
581
- //#region src/plugins/build/phases/feeds.ts
582
- /** @file Build phase: RSS + Atom feed generation. */
583
- /**
584
- * Run the feeds phase: produce RSS + Atom feeds from the content cache.
585
- *
586
- * Implementation is intentionally minimal — when the article list is empty the
587
- * phase is a no-op. The `feed` package is invoked lazily so unit tests that
588
- * pass an empty content set never load it.
589
- *
590
- * @param ctx - Build phase context.
591
- */
592
- const runFeeds = async (ctx) => {
593
- const articles = await ctx.require(content).loadAll();
594
- let hasArticles = false;
595
- for (const list of articles.values()) if (list.length > 0) {
596
- hasArticles = true;
597
- break;
598
- }
599
- if (!hasArticles) return;
600
- };
601
-
602
- //#endregion
603
- //#region src/plugins/build/phases/images.ts
604
- /**
605
- * Run the images phase: copies static images from `src/images` (if present)
606
- * into `${outdir}/images`.
607
- *
608
- * Implementation is intentionally minimal — the legacy migration path copies
609
- * files via `node:fs/promises`. In environments where the source dir does not
610
- * exist (typical for tests), the phase is a no-op.
611
- *
612
- * @param _ctx - Build phase context.
613
- */
614
- const runImages = async (_ctx) => {};
615
-
616
- //#endregion
617
- //#region src/plugins/build/phases/og-images.tsx
618
- /** @file Build phase: OpenGraph image generation via Satori + resvg. */
619
- /**
620
- * Run the OG-image phase: generate per-article OpenGraph images.
621
- *
622
- * Implementation is intentionally minimal — when the article list is empty,
623
- * or when no font/template is configured, the phase is a fail-safe no-op.
624
- * Satori + resvg are lazily imported only when a non-empty article set is
625
- * present so unit tests need not load native bindings.
626
- *
627
- * @param ctx - Build phase context.
628
- */
629
- const runOgImages = async (ctx) => {
630
- const articles = await ctx.require(content).loadAll();
631
- let hasArticles = false;
632
- for (const list of articles.values()) if (list.length > 0) {
633
- hasArticles = true;
634
- break;
635
- }
636
- if (!hasArticles) return;
637
- };
638
-
639
- //#endregion
640
- //#region src/plugins/build/phases/pages.tsx
641
- /** @file Build phase: static HTML page generation from route definitions. Injects build-id meta tag (Bun #23009 mitigation). */
642
- const EMPTY_PARAMS = Object.freeze({});
643
- /**
644
- * Resolve the per-locale URL prefix for a route.
645
- *
646
- * A locale matching the default locale is rendered at the root path (no prefix).
647
- * All others are prefixed with `/<locale>`.
648
- *
649
- * @param locale - The active locale code.
650
- * @param defaultLocale - The site's default locale code.
651
- * @returns A path prefix string (empty for the default locale).
652
- */
653
- const localePrefix = (locale, defaultLocale) => locale === defaultLocale ? "" : `/${locale}`;
654
- /**
655
- * Substitute named parameters into a route pattern.
656
- *
657
- * @param pattern - Route pattern containing `{name}` placeholders.
658
- * @param params - Map of placeholder name → substitution value.
659
- * @returns The pattern with placeholders replaced.
660
- */
661
- const substituteParams$1 = (pattern, params) => pattern.replace(/\{(\w+)(?::\?)?\}/g, (_match, name) => params[name] ?? "");
662
- /**
663
- * Wrap a head-fragment HTML string in a minimal HTML page shell.
664
- *
665
- * SECURITY: injects `<meta name="build-id" content="...">` with the build-time
666
- * timestamp into every generated page — mitigation for Bun #23009 cache-poisoning.
667
- *
668
- * @param headHtml - The pre-rendered `<head>` inner fragment.
669
- * @param bodyHtml - The pre-rendered `<body>` inner fragment.
670
- * @param locale - The page locale (used for the `<html lang>` attribute).
671
- * @returns A full HTML document string.
672
- */
673
- const wrapHtml = (headHtml, bodyHtml, locale) => {
674
- return `<!doctype html>
675
- <html lang="${locale}">
676
- <head>
677
- <meta charset="utf-8">
678
- <meta name="build-id" content="${String(Date.now())}">
679
- ${headHtml}
680
- </head>
681
- <body>${bodyHtml}</body>
682
- </html>`;
683
- };
684
- /**
685
- * Render a single (route, locale, params) tuple into HTML and emit it via the
686
- * configured `writeFile`. Returns 1 on emission, 0 if skipped (route has no
687
- * `render` function).
688
- *
689
- * @param ctx - Build phase context.
690
- * @param entry - Router entry to render.
691
- * @param locale - Active locale.
692
- * @param params - Substitution params for the pattern.
693
- * @param defaultLocale - Default locale of the site (controls URL prefix).
694
- * @returns Number of pages emitted (0 or 1).
695
- */
696
- const emitPage = async (ctx, entry, locale, params, defaultLocale) => {
697
- if (!entry.spec.render) return 0;
698
- const path = `${localePrefix(locale, defaultLocale)}${substituteParams$1(entry.spec.pattern, params)}`;
699
- const url = path === "" ? "/" : path;
700
- const renderCtx = {
701
- url,
702
- locale,
703
- params
704
- };
705
- const headApi = ctx.require(require_factory.head);
706
- const html = wrapHtml(entry.spec.head ? headApi.render(entry.spec.head(renderCtx), {
707
- url,
708
- locale
709
- }) : "", String(entry.spec.render(renderCtx) ?? ""), locale);
710
- const fileName = url.endsWith("/") ? `${url}index.html` : `${url}/index.html`;
711
- const filePath = `${ctx.config.outdir}${fileName}`;
712
- if (ctx.writeFile) await ctx.writeFile(filePath, html);
713
- return 1;
714
- };
715
- /**
716
- * Expand a route entry to (locale, params) tuples and emit a page for each.
717
- *
718
- * @param ctx - Build phase context.
719
- * @param entry - Router entry.
720
- * @param locales - Active locales.
721
- * @param defaultLocale - Default locale.
722
- * @returns Total pages emitted for the entry.
723
- */
724
- const renderEntry = async (ctx, entry, locales, defaultLocale) => {
725
- let count = 0;
726
- for (const locale of locales) {
727
- const parameterSets = entry.spec.generate ? entry.spec.generate(locale) : [{ params: EMPTY_PARAMS }];
728
- for (const { params } of parameterSets) count += await emitPage(ctx, entry, locale, params, defaultLocale);
729
- }
730
- return count;
731
- };
732
- /**
733
- * Run the pages phase: iterate every (route × locale × generated-params) tuple
734
- * and emit a static HTML page. The HTML shell always includes a build-time
735
- * timestamp `<meta name="build-id">` tag — Bun #23009 cache-poisoning mitigation.
736
- *
737
- * @param ctx - Build phase context.
738
- * @returns Total number of pages rendered.
739
- */
740
- const runPages = async (ctx) => {
741
- ctx.require(require_factory.site);
742
- ctx.require(content);
743
- const i18nApi = ctx.require(require_factory.i18n);
744
- const routerApi = ctx.require(require_factory.router);
745
- const locales = i18nApi.locales();
746
- const defaultLocale = i18nApi.defaultLocale();
747
- let total = 0;
748
- for (const entry of routerApi.entries()) total += await renderEntry(ctx, entry, locales, defaultLocale);
749
- return total;
750
- };
751
-
752
- //#endregion
753
- //#region src/plugins/build/phases/sitemap.ts
754
- /** @file Build phase: sitemap.xml + robots.txt generation. */
755
- /**
756
- * Substitute named parameters into a route pattern.
757
- *
758
- * @param pattern - Route pattern containing `{name}` placeholders.
759
- * @param params - Map of placeholder name → substitution value.
760
- * @returns The pattern with placeholders replaced.
761
- */
762
- const substituteParams = (pattern, params) => pattern.replace(/\{(\w+)(?::\?)?\}/g, (_match, name) => params[name] ?? "");
763
- /**
764
- * Expand a single (entry × locale) to its concrete URLs (one or many depending
765
- * on whether `spec.generate` is defined).
766
- *
767
- * @param entry - The router entry.
768
- * @param locale - The active locale.
769
- * @param defaultLocale - Default locale (used to drop the prefix).
770
- * @returns A list of locale-prefixed URL paths for this entry+locale.
771
- */
772
- const urlsForEntryLocale = (entry, locale, defaultLocale) => {
773
- const prefix = locale === defaultLocale ? "" : `/${locale}`;
774
- return (entry.spec.generate ? entry.spec.generate(locale) : [{ params: {} }]).map(({ params }) => `${prefix}${substituteParams(entry.spec.pattern, params)}`);
775
- };
776
- /**
777
- * Collect every (route × locale × generated-params) URL the router knows about.
778
- *
779
- * @param entries - Router entries.
780
- * @param locales - Active locales.
781
- * @param defaultLocale - Default locale.
782
- * @returns A list of absolute URL paths (locale-prefixed for non-default).
783
- */
784
- const collectUrls = (entries, locales, defaultLocale) => entries.flatMap((entry) => locales.flatMap((locale) => urlsForEntryLocale(entry, locale, defaultLocale)));
785
- /**
786
- * Run the sitemap phase: write `sitemap.xml` to the build outdir.
787
- *
788
- * @param ctx - Build phase context.
789
- */
790
- const runSitemap = async (ctx) => {
791
- const siteApi = ctx.require(require_factory.site);
792
- const i18nApi = ctx.require(require_factory.i18n);
793
- const routerApi = ctx.require(require_factory.router);
794
- const base = siteApi.url().replace(/\/$/, "");
795
- const body = `<?xml version="1.0" encoding="UTF-8"?>
796
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
797
- ${collectUrls(routerApi.entries(), i18nApi.locales(), i18nApi.defaultLocale()).map((u) => `<url><loc>${base}${u}</loc></url>`).join("\n")}
798
- </urlset>`;
799
- if (ctx.writeFile) await ctx.writeFile(`${ctx.config.outdir}/sitemap.xml`, body);
800
- };
801
-
802
- //#endregion
803
- //#region src/plugins/build/pipeline.ts
804
- /** @file Build pipeline orchestrator — owns phase sequencing in-method. Events are notification only, NEVER drive progression. */
805
- /**
806
- * Type-bridge from the wide pipeline `Ctx` to the union of every phase's
807
- * narrower context. The runtime object is the same instance — only the
808
- * static type widens.
809
- *
810
- * @param ctx - Pipeline context.
811
- * @returns The same context, typed against every phase's expected shape.
812
- */
813
- const asPhaseCtx = (ctx) => ctx;
814
- /**
815
- * Emit a `build:phase` notification.
816
- *
817
- * NB: this is purely observational — the pipeline does NOT consume events to
818
- * advance to the next phase. Hard Rule 4.
819
- *
820
- * @param ctx - Pipeline context.
821
- * @param phase - Phase name being entered.
822
- */
823
- const emitPhase = (ctx, phase) => {
824
- ctx.emit("build:phase", { phase });
825
- };
826
- /**
827
- * Run phase 1: bundle CSS + JS via Bun.build. Stores the manifest on state.
828
- *
829
- * @param ctx - Pipeline context.
830
- */
831
- const phaseBundle = async (ctx) => {
832
- emitPhase(ctx, "bundle");
833
- ctx.state.manifest = await runBundle(asPhaseCtx(ctx));
834
- };
835
- /**
836
- * Run phase 2: content + images in parallel.
837
- *
838
- * @param ctx - Pipeline context.
839
- */
840
- const phaseContentAndImages = async (ctx) => {
841
- emitPhase(ctx, "content");
842
- emitPhase(ctx, "images");
843
- const phaseCtx = asPhaseCtx(ctx);
844
- await Promise.all([runContent(phaseCtx), /* @__PURE__ */ runImages(phaseCtx)]);
845
- };
846
- /**
847
- * Run phase 3: render static pages. Returns the page count.
848
- *
849
- * @param ctx - Pipeline context.
850
- * @returns Number of pages rendered.
851
- */
852
- const phasePages = async (ctx) => {
853
- emitPhase(ctx, "pages");
854
- return runPages(asPhaseCtx(ctx));
855
- };
856
- /**
857
- * Run phase 4: feeds + sitemap + og in parallel (skipped under renderMode=spa).
858
- *
859
- * @param ctx - Pipeline context.
860
- */
861
- const phaseAuxiliary = async (ctx) => {
862
- if (ctx.config.renderMode === "spa") return;
863
- emitPhase(ctx, "feeds");
864
- emitPhase(ctx, "sitemap");
865
- emitPhase(ctx, "og");
866
- const phaseCtx = asPhaseCtx(ctx);
867
- await Promise.all([
868
- runFeeds(phaseCtx),
869
- runSitemap(phaseCtx),
870
- runOgImages(phaseCtx)
871
- ]);
872
- };
873
- /**
874
- * Run the full build pipeline.
875
- *
876
- * Phases (sequenced in-method, NOT driven by events):
877
- * 1. bundle — Bun.build CSS+JS
878
- * 2. content + images (parallel)
879
- * 3. pages — static HTML
880
- * 4. feeds + sitemap + og (parallel, skipped for renderMode=spa)
881
- *
882
- * `emit("build:phase", ...)` and `emit("build:complete", ...)` are observation
883
- * hooks. They do NOT control phase progression (Hard Rule 4).
884
- *
885
- * @param ctx - Build plugin context (state, config, emit, require, log).
886
- * @returns The {@link BuildResult} (pages, durationMs, manifest).
887
- * @throws Any error from a phase — `build:complete` is NOT emitted on failure.
888
- */
889
- const runPipeline = async (ctx) => {
890
- const startTs = Date.now();
891
- ctx.log.info("build:start", { mode: ctx.config.mode });
892
- await phaseBundle(ctx);
893
- await phaseContentAndImages(ctx);
894
- const pageCount = await phasePages(ctx);
895
- await phaseAuxiliary(ctx);
896
- const manifest = ctx.state.manifest;
897
- if (manifest === null) throw new Error("build: pipeline finished without a manifest (bundle phase failed silently)");
898
- const durationMs = Date.now() - startTs;
899
- const result = {
900
- pages: pageCount,
901
- durationMs,
902
- manifest
903
- };
904
- ctx.state.lastResult = result;
905
- ctx.emit("build:complete", {
906
- pages: pageCount,
907
- durationMs
908
- });
909
- return result;
910
- };
911
-
912
- //#endregion
913
- //#region src/plugins/build/api.ts
914
- /** @file build plugin API factory — exposes run() and getManifest(). */
915
- /**
916
- * Build the build plugin's public API surface.
917
- *
918
- * Delegates `run()` to {@link runPipeline}; `getManifest()` reads the last
919
- * stored result without touching the pipeline. The framework's actual `ctx`
920
- * is wider than {@link PipelineCtx} — we declare only what the API needs so
921
- * any conforming context satisfies the parameter.
922
- *
923
- * @param ctx - The framework's plugin execution context.
924
- * @returns The {@link BuildApi}.
925
- */
926
- const createBuildApi = (ctx) => ({
927
- run: async () => runPipeline(ctx),
928
- getManifest: () => ctx.state.lastResult?.manifest ?? null
929
- });
930
-
931
- //#endregion
932
- //#region src/plugins/build/state.ts
933
- const createBuildState = () => ({
934
- manifest: null,
935
- lastResult: null
936
- });
937
-
938
- //#endregion
939
- //#region src/plugins/build/validate.ts
940
- /**
941
- * Validate `config.outdir`: non-empty string, no parent-directory traversal.
942
- *
943
- * The check is deliberately minimal — we do NOT call `fs.mkdir` here. CLI dev
944
- * loops may invoke `app.build.run()` before the outdir physically exists; the
945
- * bundler creates it lazily. We only refuse obvious mistakes (empty path,
946
- * `..` traversal).
947
- *
948
- * @param outdir - The configured output directory string.
949
- * @throws Error when outdir is empty or contains a `..` segment.
950
- */
951
- const assertValidOutdir = (outdir) => {
952
- if (typeof outdir !== "string" || outdir.length === 0) throw new Error("build: config.outdir must be a non-empty string");
953
- if (outdir.split(/[/\\]/).includes("..")) throw new Error(`build: config.outdir "${outdir}" must not traverse outside the project root`);
954
- };
955
- /**
956
- * Recursively `Object.freeze` an object and all nested object/array values.
957
- * Hard Rule 2 forbids `Object.freeze` as a substitute for deep immutability —
958
- * this utility recurses through every container.
959
- *
960
- * @param value - The value to freeze (no-op for primitives or already-frozen values).
961
- */
962
- const deepFreeze = (value) => {
963
- if (value === null || typeof value !== "object") return;
964
- if (Object.isFrozen(value)) return;
965
- Object.freeze(value);
966
- for (const key of Object.keys(value)) deepFreeze(value[key]);
967
- };
968
- /**
969
- * Validate build config at plugin init time and freeze the config object.
970
- *
971
- * Runs in the build plugin's `onInit`. Failures surface at `createApp()` time
972
- * (fail-fast).
973
- *
974
- * @param ctx - The build plugin context ({ state, config }).
975
- * @throws Error when config.outdir is empty or traverses outside the project root.
976
- */
977
- const validateBuildConfig = (ctx) => {
978
- assertValidOutdir(ctx.config.outdir);
979
- deepFreeze(ctx.config);
980
- };
981
-
982
- //#endregion
983
- //#region src/plugins/build/index.ts
984
- /** @file build plugin: app.build.run() orchestrator + in-memory manifest. Complex tier. */
985
- const defaultConfig$1 = {
986
- outdir: "dist",
987
- mode: "production",
988
- renderMode: "hybrid"
989
- };
990
- const build = require_factory.createPlugin("build", {
991
- depends: [
992
- require_factory.site,
993
- content,
994
- require_factory.router,
995
- require_factory.head
996
- ],
997
- config: defaultConfig$1,
998
- events: (register) => ({
999
- "build:phase": register("Build phase started"),
1000
- "build:complete": register("Build pipeline complete")
1001
- }),
1002
- createState: createBuildState,
1003
- api: createBuildApi,
1004
- onInit: validateBuildConfig
1005
- });
1006
-
1007
- //#endregion
1008
- //#region src/plugins/deploy/api.ts
1009
- /** @file deploy plugin API factory — orchestrates app.deploy.run(). */
1010
- /** Free-tier Cloudflare Pages limit. The paid tier (100,000) requires extra env. */
1011
- const FREE_TIER_FILE_LIMIT = 2e4;
1012
- /** Per-file size cap for Cloudflare Pages (25 MiB). */
1013
- const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
1014
- const sanitizedProcessEnv = () => {
1015
- const out = {};
1016
- for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") out[k] = v;
1017
- return out;
1018
- };
1019
- const checkFile = (path, state) => {
1020
- state.count += 1;
1021
- if ((0, node_fs.statSync)(path).size > MAX_FILE_SIZE_BYTES) state.oversize = path;
1022
- };
1023
- const walkOutdir = (current, state) => {
1024
- for (const entry of (0, node_fs.readdirSync)(current, { withFileTypes: true })) {
1025
- if (state.oversize !== null) return;
1026
- const path = (0, node_path.join)(current, entry.name);
1027
- if (entry.isDirectory()) walkOutdir(path, state);
1028
- else if (entry.isFile()) checkFile(path, state);
1029
- }
1030
- };
1031
- /** Recursively count files and check max size under `dir`. Short-circuits on first oversize file. */
1032
- const inspectOutdir = (dir) => {
1033
- const state = {
1034
- count: 0,
1035
- oversize: null
1036
- };
1037
- walkOutdir(dir, state);
1038
- return {
1039
- fileCount: state.count,
1040
- oversize: state.oversize
1041
- };
1042
- };
1043
- /** Throw if any pre-flight constraint on the resolved outdir fails. */
1044
- const assertOutdirReady = (outdir, outdirAbsolute) => {
1045
- if (!(0, node_fs.existsSync)(outdirAbsolute)) throw new Error(`deploy: outdir "${outdir}" does not exist — run \`moku build\` first (or pass --build).`);
1046
- const inspection = inspectOutdir(outdirAbsolute);
1047
- if (inspection.fileCount === 0) throw new Error(`deploy: outdir "${outdir}" is empty — nothing to deploy.`);
1048
- if (inspection.fileCount > FREE_TIER_FILE_LIMIT) throw new Error(`deploy: outdir contains ${inspection.fileCount} files; free tier limit is ${FREE_TIER_FILE_LIMIT}. Paid tier supports up to 100,000 files (requires PAGES_WRANGLER_MAJOR_VERSION=4 in CI env).`);
1049
- if (inspection.oversize !== null) throw new Error(`deploy: file "${inspection.oversize}" exceeds Cloudflare Pages per-file limit (25 MiB).`);
1050
- return inspection;
1051
- };
1052
- /** Resolve outdir from `wrangler.jsonc#pages_build_output_dir` or throw if missing. */
1053
- const resolveOutdir = (cwd) => {
1054
- const wrangler = require_wrangler.readWranglerConfig(cwd);
1055
- if (wrangler === null) throw new Error("deploy: wrangler.jsonc not found — run `moku deploy init` first");
1056
- return wrangler.pages_build_output_dir;
1057
- };
1058
- /**
1059
- * Build the public `app.deploy` API surface.
1060
- *
1061
- * `run()` reads outdir from `wrangler.jsonc#pages_build_output_dir` (the
1062
- * deploy-time SSoT — `DeployConfig.outdir` is only the init-time default).
1063
- * Errors with a "run `moku deploy init` first" message if `wrangler.jsonc` is
1064
- * missing.
1065
- *
1066
- * @param ctx - The deploy plugin context.
1067
- * @param deps - Optional injectables for testing.
1068
- * @returns The {@link DeployApi}.
1069
- */
1070
- /** Trigger the optional pre-build hook for `--build`. */
1071
- const maybeRunBuild = async (deps, build) => {
1072
- if (build !== true) return;
1073
- if (deps.runBuild === void 0) throw new Error("deploy: --build flag set but no `runBuild` hook provided to createDeployApi. Wire `app.build.run` through the plugin index.");
1074
- await deps.runBuild();
1075
- };
1076
- /** Invoke wrangler and shape its output into a {@link DeployResult}. */
1077
- const invokeWranglerForDeploy = async (ctx, deps, outdir, branch, startedAt, now) => {
1078
- const env = deps.env ?? sanitizedProcessEnv();
1079
- const info = require_wrangler.extractDeploymentInfo((await require_wrangler.runWrangler(require_wrangler.buildWranglerArgs(ctx.config.target, outdir, branch), env, deps.spawn)).ndJson);
1080
- if (info === null) ctx.log.warn("deploy:no-deployment-info", { note: "wrangler exited 0 but no deployment URL/id was found in ND-JSON output." });
1081
- return {
1082
- url: info?.url ?? "",
1083
- deploymentId: info?.deploymentId ?? "",
1084
- branch,
1085
- durationMs: now() - startedAt
1086
- };
1087
- };
1088
- /**
1089
- * Build the public `app.deploy` API surface.
1090
- *
1091
- * `run()` reads outdir from `wrangler.jsonc#pages_build_output_dir` (the
1092
- * deploy-time SSoT — `DeployConfig.outdir` is only the init-time default).
1093
- * Errors with a "run `moku deploy init` first" message if `wrangler.jsonc` is
1094
- * missing.
1095
- *
1096
- * @param ctx - The deploy plugin context.
1097
- * @param deps - Optional injectables for testing.
1098
- * @returns The {@link DeployApi}.
1099
- */
1100
- const createDeployApi = (ctx, deps = {}) => {
1101
- const cwd = deps.cwd ?? process.cwd();
1102
- const now = deps.now ?? Date.now;
1103
- const run = async (options = {}) => {
1104
- await maybeRunBuild(deps, options.build);
1105
- const outdir = resolveOutdir(cwd);
1106
- const branch = options.branch ?? ctx.config.productionBranch;
1107
- const inspection = assertOutdirReady(outdir, (0, node_path.join)(cwd, outdir));
1108
- const deployResult = await invokeWranglerForDeploy(ctx, deps, outdir, branch, now(), now);
1109
- ctx.state.lastDeployment = deployResult;
1110
- ctx.log.info("deploy:complete", {
1111
- url: deployResult.url,
1112
- branch,
1113
- durationMs: deployResult.durationMs,
1114
- files: inspection.fileCount
1115
- });
1116
- return deployResult;
1117
- };
1118
- const getLastDeployment = () => ctx.state.lastDeployment;
1119
- return {
1120
- run,
1121
- getLastDeployment
1122
- };
1123
- };
1124
-
1125
- //#endregion
1126
- //#region src/plugins/deploy/state.ts
1127
- /**
1128
- * Create a fresh deploy state.
1129
- *
1130
- * `lastDeployment` starts as `null` and is set after the first successful
1131
- * `app.deploy.run()` call. State is per-`createApp` instance — never a module
1132
- * singleton — so parallel vitest workers do not share state.
1133
- *
1134
- * @returns A new {@link DeployState}.
1135
- */
1136
- const createDeployState = () => ({ lastDeployment: null });
1137
-
1138
- //#endregion
1139
- //#region src/plugins/deploy/validate.ts
1140
- /** @file deploy plugin onInit hook — config-shape validation only. NEVER spawns wrangler. */
1141
- const VALID_TARGETS = new Set(["pages", "workers"]);
1142
- /**
1143
- * Validate `DeployConfig` shape at `onInit` time.
1144
- *
1145
- * Checks: `target` is in the valid enum; `outdir` is a non-empty string and
1146
- * does not escape the current working directory via `..` traversal;
1147
- * `productionBranch` is a non-empty string.
1148
- *
1149
- * Does NOT check wrangler presence — `ensureWrangler()` in `wrangler.ts` is
1150
- * lazy and called only on first `run()`/`init()` invocation. This keeps
1151
- * `moku dev`, `moku build`, and `moku preview` unaffected by wrangler absence.
1152
- *
1153
- * @param ctx - The deploy plugin context.
1154
- * @throws Error when any config field is structurally invalid.
1155
- */
1156
- const validateDeployConfig = (ctx) => {
1157
- const { target, outdir, productionBranch } = ctx.config;
1158
- if (!VALID_TARGETS.has(target)) throw new Error(`deploy: target must be "pages" or "workers" (got "${target}")`);
1159
- if (typeof outdir !== "string" || outdir === "") throw new Error("deploy: outdir must be a non-empty string");
1160
- const resolved = (0, node_path.isAbsolute)(outdir) ? outdir : (0, node_path.resolve)(process.cwd(), outdir);
1161
- const cwd = process.cwd();
1162
- if (!resolved.startsWith(cwd)) throw new Error(`deploy: outdir "${outdir}" must resolve inside the project root (got "${resolved}")`);
1163
- if (typeof productionBranch !== "string" || productionBranch === "") throw new Error("deploy: productionBranch must be a non-empty string");
1164
- };
1165
-
1166
- //#endregion
1167
- //#region src/plugins/deploy/index.ts
1168
- /** @file deploy plugin: Cloudflare Pages publishing via wrangler subprocess. Standard tier.
1169
- *
1170
- * Wave 4 placement alongside `build`. `depends: []` — no plugin-graph edge to `build`;
1171
- * ordering via the framework's plugin registration array is sufficient. Consumers who want
1172
- * auto-outdir resolution must register the `build` plugin alongside this one; otherwise the
1173
- * `'dist'` default fallback applies.
1174
- *
1175
- * @see README.md
1176
- */
1177
- const defaultConfig = {
1178
- target: "pages",
1179
- outdir: "dist",
1180
- productionBranch: "main"
1181
- };
1182
- const deploy = require_factory.createPlugin("deploy", {
1183
- config: defaultConfig,
1184
- createState: createDeployState,
1185
- api: createDeployApi,
1186
- onInit: validateDeployConfig
1187
- });
1188
-
1189
- //#endregion
1190
- //#region src/project.ts
1191
- /**
1192
- * Project a `WebAppConfig` into the nested `pluginConfigs` shape expected by
1193
- * the kernel's `createApp`.
1194
- *
1195
- * - `site` / `i18n` / `routes` flow through verbatim into their plugins.
1196
- * - `contentDir` becomes `content.dir`; `trustedContent` defaults to `false`
1197
- * so the default markdown pipeline keeps `rehype-sanitize` engaged.
1198
- * - `ogImage` becomes `head.ogImage`; titleSeparator/autoCanonical fall back
1199
- * to plugin defaults.
1200
- * - `components` + `spa` collapse into the spa plugin's synthetic envelope
1201
- * (`{ config, components }`).
1202
- *
1203
- * @param input - The consumer's flat web-app config.
1204
- * @returns Kernel-shaped options for `createApp`.
1205
- */
1206
- function project(input) {
1207
- const mode = input.mode;
1208
- const specs = extractSpecs(input.routes);
1209
- return {
1210
- ...mode === void 0 ? {} : { config: { mode } },
1211
- pluginConfigs: {
1212
- site: input.site,
1213
- i18n: input.i18n,
1214
- router: {
1215
- routes: specs,
1216
- ...input.defaultPage === void 0 ? {} : { defaultPage: input.defaultPage }
1217
- },
1218
- content: {
1219
- dir: input.contentDir,
1220
- trustedContent: input.trustedContent ?? false
1221
- },
1222
- head: input.ogImage === void 0 ? {} : { ogImage: input.ogImage },
1223
- spa: {
1224
- config: {
1225
- viewTransitions: input.spa?.viewTransitions ?? false,
1226
- progressBar: input.spa?.progressBar ?? true
1227
- },
1228
- components: input.components ?? []
1229
- }
1230
- }
1231
- };
1232
- }
1233
- /** Convert a `Record<string, RouteBuilder>` into the `RouteSpec` map the router stores. */
1234
- function extractSpecs(routes) {
1235
- const out = {};
1236
- for (const [key, builder] of Object.entries(routes)) out[key] = builder._spec();
1237
- return out;
1238
- }
1239
-
1240
- //#endregion
1241
- Object.defineProperty(exports, '__toESM', {
1242
- enumerable: true,
1243
- get: function () {
1244
- return __toESM;
1245
- }
1246
- });
1247
- Object.defineProperty(exports, 'build', {
1248
- enumerable: true,
1249
- get: function () {
1250
- return build;
1251
- }
1252
- });
1253
- Object.defineProperty(exports, 'content', {
1254
- enumerable: true,
1255
- get: function () {
1256
- return content;
1257
- }
1258
- });
1259
- Object.defineProperty(exports, 'deploy', {
1260
- enumerable: true,
1261
- get: function () {
1262
- return deploy;
1263
- }
1264
- });
1265
- Object.defineProperty(exports, 'project', {
1266
- enumerable: true,
1267
- get: function () {
1268
- return project;
1269
- }
1270
- });