@moku-labs/web 0.1.0-alpha.1

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