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