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