@moku-labs/web 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -12
- package/dist/convention-Dr8jxG70.cjs +81 -0
- package/dist/convention-X3zLTlJ8.mjs +33 -0
- package/dist/index.cjs +1304 -318
- package/dist/index.d.cts +940 -622
- package/dist/index.d.mts +939 -621
- package/dist/index.mjs +1275 -268
- package/dist/render-BL9Fv6G6.mjs +20 -0
- package/dist/render-BSTM0Akv.cjs +20 -0
- package/dist/writer-BcWqa_7I.mjs +90 -0
- package/dist/writer-DAF0pM25.cjs +92 -0
- package/package.json +3 -2
package/dist/index.cjs
CHANGED
|
@@ -1,72 +1,39 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
|
|
3
|
-
var __create = Object.create;
|
|
4
|
-
var __defProp = Object.defineProperty;
|
|
5
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
-
var __exportAll = (all, no_symbols) => {
|
|
10
|
-
let target = {};
|
|
11
|
-
for (var name in all) __defProp(target, name, {
|
|
12
|
-
get: all[name],
|
|
13
|
-
enumerable: true
|
|
14
|
-
});
|
|
15
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
16
|
-
return target;
|
|
17
|
-
};
|
|
18
|
-
var __copyProps = (to, from, except, desc) => {
|
|
19
|
-
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
20
|
-
key = keys[i];
|
|
21
|
-
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
22
|
-
get: ((k) => from[k]).bind(null, key),
|
|
23
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
return to;
|
|
27
|
-
};
|
|
28
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
29
|
-
value: mod,
|
|
30
|
-
enumerable: true
|
|
31
|
-
}) : target, mod));
|
|
32
|
-
//#endregion
|
|
2
|
+
const require_convention = require("./convention-Dr8jxG70.cjs");
|
|
33
3
|
let _moku_labs_core = require("@moku-labs/core");
|
|
34
|
-
let node_fs = require("node:fs");
|
|
35
4
|
let node_fs_promises = require("node:fs/promises");
|
|
36
5
|
let node_path = require("node:path");
|
|
37
|
-
let node_path$1 = __toESM(node_path, 1);
|
|
38
|
-
node_path = __toESM(node_path);
|
|
6
|
+
let node_path$1 = require_convention.__toESM(node_path, 1);
|
|
7
|
+
node_path = require_convention.__toESM(node_path);
|
|
39
8
|
let gray_matter = require("gray-matter");
|
|
40
|
-
gray_matter = __toESM(gray_matter, 1);
|
|
9
|
+
gray_matter = require_convention.__toESM(gray_matter, 1);
|
|
41
10
|
let _shikijs_rehype = require("@shikijs/rehype");
|
|
42
|
-
_shikijs_rehype = __toESM(_shikijs_rehype, 1);
|
|
11
|
+
_shikijs_rehype = require_convention.__toESM(_shikijs_rehype, 1);
|
|
43
12
|
let rehype_sanitize = require("rehype-sanitize");
|
|
44
|
-
rehype_sanitize = __toESM(rehype_sanitize, 1);
|
|
13
|
+
rehype_sanitize = require_convention.__toESM(rehype_sanitize, 1);
|
|
45
14
|
let rehype_stringify = require("rehype-stringify");
|
|
46
|
-
rehype_stringify = __toESM(rehype_stringify, 1);
|
|
15
|
+
rehype_stringify = require_convention.__toESM(rehype_stringify, 1);
|
|
47
16
|
let unified = require("unified");
|
|
48
17
|
let rehype_raw = require("rehype-raw");
|
|
49
|
-
rehype_raw = __toESM(rehype_raw, 1);
|
|
18
|
+
rehype_raw = require_convention.__toESM(rehype_raw, 1);
|
|
50
19
|
let remark_directive = require("remark-directive");
|
|
51
|
-
remark_directive = __toESM(remark_directive, 1);
|
|
20
|
+
remark_directive = require_convention.__toESM(remark_directive, 1);
|
|
52
21
|
let remark_frontmatter = require("remark-frontmatter");
|
|
53
|
-
remark_frontmatter = __toESM(remark_frontmatter, 1);
|
|
22
|
+
remark_frontmatter = require_convention.__toESM(remark_frontmatter, 1);
|
|
54
23
|
let remark_gfm = require("remark-gfm");
|
|
55
|
-
remark_gfm = __toESM(remark_gfm, 1);
|
|
24
|
+
remark_gfm = require_convention.__toESM(remark_gfm, 1);
|
|
56
25
|
let remark_parse = require("remark-parse");
|
|
57
|
-
remark_parse = __toESM(remark_parse, 1);
|
|
26
|
+
remark_parse = require_convention.__toESM(remark_parse, 1);
|
|
58
27
|
let remark_rehype = require("remark-rehype");
|
|
59
|
-
remark_rehype = __toESM(remark_rehype, 1);
|
|
28
|
+
remark_rehype = require_convention.__toESM(remark_rehype, 1);
|
|
60
29
|
let unist_util_visit = require("unist-util-visit");
|
|
61
30
|
let hast_util_sanitize = require("hast-util-sanitize");
|
|
62
31
|
let reading_time = require("reading-time");
|
|
63
|
-
reading_time = __toESM(reading_time, 1);
|
|
32
|
+
reading_time = require_convention.__toESM(reading_time, 1);
|
|
33
|
+
let node_fs = require("node:fs");
|
|
64
34
|
let node_crypto = require("node:crypto");
|
|
65
35
|
let feed = require("feed");
|
|
66
|
-
let
|
|
67
|
-
let satori = require("satori");
|
|
68
|
-
satori = __toESM(satori);
|
|
69
|
-
let preact_jsx_runtime = require("preact/jsx-runtime");
|
|
36
|
+
let preact = require("preact");
|
|
70
37
|
let preact_render_to_string = require("preact-render-to-string");
|
|
71
38
|
//#region src/plugins/env/api.ts
|
|
72
39
|
/** Error prefix for all env API failures. */
|
|
@@ -295,112 +262,45 @@ function validateSchema(ctx) {
|
|
|
295
262
|
freezeMap(state.publicMap);
|
|
296
263
|
}
|
|
297
264
|
//#endregion
|
|
298
|
-
//#region src/plugins/env/providers.ts
|
|
299
|
-
/**
|
|
300
|
-
|
|
301
|
-
*/
|
|
302
|
-
/** Default dotenv file path: optional local overrides. */
|
|
303
|
-
const DEFAULT_DOTENV_PATH = ".env.local";
|
|
304
|
-
/**
|
|
305
|
-
* Strips a single matching pair of surrounding double or single quotes from a
|
|
306
|
-
* value. Leaves unquoted values (and trailing inline comments) untouched.
|
|
307
|
-
*
|
|
308
|
-
* @param value - The already-trimmed raw value.
|
|
309
|
-
* @returns The value with one outer quote pair removed, if present.
|
|
310
|
-
* @example
|
|
311
|
-
* ```ts
|
|
312
|
-
* stripQuotes('"a"'); // "a"
|
|
313
|
-
* stripQuotes("plain # c"); // "plain # c"
|
|
314
|
-
* ```
|
|
315
|
-
*/
|
|
316
|
-
function stripQuotes(value) {
|
|
317
|
-
if (value.length >= 2) {
|
|
318
|
-
const first = value[0];
|
|
319
|
-
const last = value.at(-1);
|
|
320
|
-
if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
|
|
321
|
-
}
|
|
322
|
-
return value;
|
|
323
|
-
}
|
|
324
|
-
/**
|
|
325
|
-
* Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
|
|
326
|
-
* full-line `#` comments, first-`=` splitting, key/value trimming, and a single
|
|
327
|
-
* outer quote pair. Does not strip trailing inline comments on unquoted values.
|
|
328
|
-
*
|
|
329
|
-
* @param text - The raw file contents.
|
|
330
|
-
* @returns A flat record of parsed key/value pairs.
|
|
331
|
-
* @example
|
|
332
|
-
* ```ts
|
|
333
|
-
* parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
|
|
334
|
-
* ```
|
|
335
|
-
*/
|
|
336
|
-
function parseDotenv(text) {
|
|
337
|
-
const out = {};
|
|
338
|
-
for (const line of text.split(/\r?\n/)) {
|
|
339
|
-
const trimmed = line.trim();
|
|
340
|
-
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
341
|
-
const eq = trimmed.indexOf("=");
|
|
342
|
-
if (eq === -1) continue;
|
|
343
|
-
const key = trimmed.slice(0, eq).trim();
|
|
344
|
-
out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
|
|
345
|
-
}
|
|
346
|
-
return out;
|
|
347
|
-
}
|
|
265
|
+
//#region src/plugins/env/providers.browser.ts
|
|
266
|
+
/** Default `globalThis` property holding a runtime-injected public-env snapshot. */
|
|
267
|
+
const DEFAULT_GLOBAL_KEY = "__ENV__";
|
|
348
268
|
/**
|
|
349
|
-
* A
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
269
|
+
* A browser-safe {@link EnvProvider} that reads `import.meta.env` and an optional
|
|
270
|
+
* `globalThis[globalKey]` snapshot, merging them with the runtime global winning.
|
|
271
|
+
* Contains zero `node:*` imports, so it is safe to include in the client bundle.
|
|
272
|
+
* Never throws on missing sources — each absent source resolves to `{}`.
|
|
353
273
|
*
|
|
354
|
-
* @param
|
|
355
|
-
* @
|
|
274
|
+
* @param options - Optional settings.
|
|
275
|
+
* @param options.globalKey - `globalThis` key to read a public-env snapshot from. Defaults to `"__ENV__"`.
|
|
276
|
+
* @returns An {@link EnvProvider} named `browser-env`.
|
|
356
277
|
* @example
|
|
357
278
|
* ```ts
|
|
358
|
-
* const provider =
|
|
279
|
+
* const provider = browserEnv();
|
|
359
280
|
* provider.load(); // { PUBLIC_API_URL: "/api", ... }
|
|
360
281
|
* ```
|
|
361
282
|
*/
|
|
362
|
-
function
|
|
363
|
-
|
|
364
|
-
name: `dotenv:${path}`,
|
|
365
|
-
/**
|
|
366
|
-
* Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
|
|
367
|
-
*
|
|
368
|
-
* @returns The parsed environment record, or `{}` when the file is absent.
|
|
369
|
-
* @example
|
|
370
|
-
* ```ts
|
|
371
|
-
* dotenv(".env.local").load();
|
|
372
|
-
* ```
|
|
373
|
-
*/
|
|
374
|
-
load() {
|
|
375
|
-
if (!(0, node_fs.existsSync)(path)) return {};
|
|
376
|
-
return parseDotenv((0, node_fs.readFileSync)(path, "utf8"));
|
|
377
|
-
}
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* A provider that returns a shallow copy of `process.env` at `load()` time.
|
|
382
|
-
*
|
|
383
|
-
* @returns An {@link EnvProvider} named `process-env`.
|
|
384
|
-
* @example
|
|
385
|
-
* ```ts
|
|
386
|
-
* const provider = processEnv();
|
|
387
|
-
* provider.load().HOME; // current process value
|
|
388
|
-
* ```
|
|
389
|
-
*/
|
|
390
|
-
function processEnv() {
|
|
283
|
+
function browserEnv(options) {
|
|
284
|
+
const globalKey = options?.globalKey ?? DEFAULT_GLOBAL_KEY;
|
|
391
285
|
return {
|
|
392
|
-
name: "
|
|
286
|
+
name: "browser-env",
|
|
393
287
|
/**
|
|
394
|
-
*
|
|
288
|
+
* Merges `import.meta.env` with `globalThis[globalKey]`, the runtime global
|
|
289
|
+
* winning. Each absent source resolves to `{}`; never throws.
|
|
395
290
|
*
|
|
396
|
-
* @returns
|
|
291
|
+
* @returns The merged environment record.
|
|
397
292
|
* @example
|
|
398
293
|
* ```ts
|
|
399
|
-
*
|
|
294
|
+
* browserEnv().load();
|
|
400
295
|
* ```
|
|
401
296
|
*/
|
|
402
297
|
load() {
|
|
403
|
-
|
|
298
|
+
const importEnv = {}.env ?? {};
|
|
299
|
+
const globalObject = globalThis[globalKey] ?? {};
|
|
300
|
+
return {
|
|
301
|
+
...importEnv,
|
|
302
|
+
...globalObject
|
|
303
|
+
};
|
|
404
304
|
}
|
|
405
305
|
};
|
|
406
306
|
}
|
|
@@ -846,10 +746,7 @@ const logPlugin = (0, _moku_labs_core.createCorePlugin)("log", {
|
|
|
846
746
|
const coreConfig = (0, _moku_labs_core.createCoreConfig)("web", {
|
|
847
747
|
config: { mode: "production" },
|
|
848
748
|
plugins: [logPlugin, envPlugin],
|
|
849
|
-
pluginConfigs: {
|
|
850
|
-
log: { mode: "production" },
|
|
851
|
-
env: { providers: [dotenv(), processEnv()] }
|
|
852
|
-
}
|
|
749
|
+
pluginConfigs: { log: { mode: "production" } }
|
|
853
750
|
});
|
|
854
751
|
/**
|
|
855
752
|
* Create a custom plugin bound to this framework's `Config`/`Events` and the core
|
|
@@ -1418,6 +1315,23 @@ function articleToUrl(locale, slug) {
|
|
|
1418
1315
|
return `/${locale}/${slug}/`;
|
|
1419
1316
|
}
|
|
1420
1317
|
/**
|
|
1318
|
+
* Build the canonical "article not found" error for {@link createContentApi.load}.
|
|
1319
|
+
* Centralised so the null-resolve path and the production draft-suppression path
|
|
1320
|
+
* throw an IDENTICAL message — drafts must be indistinguishable from missing
|
|
1321
|
+
* articles in production (no new error shape).
|
|
1322
|
+
*
|
|
1323
|
+
* @param slug - Article directory name.
|
|
1324
|
+
* @param locale - Requested locale code.
|
|
1325
|
+
* @returns The not-found Error to throw.
|
|
1326
|
+
* @example
|
|
1327
|
+
* ```ts
|
|
1328
|
+
* throw articleNotFound("intro", "uk");
|
|
1329
|
+
* ```
|
|
1330
|
+
*/
|
|
1331
|
+
function articleNotFound(slug, locale) {
|
|
1332
|
+
return /* @__PURE__ */ new Error(`[web] content article "${slug}" not found for locale "${locale}".\n Looked for ${slug}/${locale}.md and the default-locale fallback.`);
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1421
1335
|
* Plugin `api` factory: assembles the kernel-free {@link ContentApiContext} from
|
|
1422
1336
|
* the plugin context (resolving i18n via `ctx.require`) and delegates to
|
|
1423
1337
|
* {@link createContentApi}. Referenced directly as the plugin's `api` so
|
|
@@ -1651,11 +1565,16 @@ function createContentApi(ctx) {
|
|
|
1651
1565
|
/**
|
|
1652
1566
|
* Resolve and render a single article for one locale with locale fallback.
|
|
1653
1567
|
* Throws a `[web] content` error when neither the requested nor the
|
|
1654
|
-
* default-locale file exists.
|
|
1568
|
+
* default-locale file exists. In production a `draft` article is suppressed
|
|
1569
|
+
* and throws the SAME not-found error (drafts must be indistinguishable from
|
|
1570
|
+
* missing articles so unpublished content is never disclosed); in
|
|
1571
|
+
* development drafts load normally.
|
|
1655
1572
|
*
|
|
1656
1573
|
* @param slug - Article directory name.
|
|
1657
1574
|
* @param locale - Requested locale code.
|
|
1658
1575
|
* @returns The resolved Article.
|
|
1576
|
+
* @throws {Error} `[web] content` not-found when no file matches, or when the
|
|
1577
|
+
* resolved article is a draft and `global.mode === "production"`.
|
|
1659
1578
|
* @example
|
|
1660
1579
|
* ```ts
|
|
1661
1580
|
* const article = await api.load("intro", "uk");
|
|
@@ -1663,7 +1582,8 @@ function createContentApi(ctx) {
|
|
|
1663
1582
|
*/
|
|
1664
1583
|
async load(slug, locale) {
|
|
1665
1584
|
const article = await resolveArticle(ctx, slug, locale);
|
|
1666
|
-
if (article === null) throw
|
|
1585
|
+
if (article === null) throw articleNotFound(slug, locale);
|
|
1586
|
+
if (ctx.global.mode === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
|
|
1667
1587
|
const cache = ctx.state.articles.get(locale) ?? /* @__PURE__ */ new Map();
|
|
1668
1588
|
cache.set(slug, article);
|
|
1669
1589
|
ctx.state.articles.set(locale, cache);
|
|
@@ -2141,6 +2061,24 @@ function toTypedRoute(entry) {
|
|
|
2141
2061
|
};
|
|
2142
2062
|
}
|
|
2143
2063
|
/**
|
|
2064
|
+
* Project a compiled route into the serializable {@link ClientRoute} view: only
|
|
2065
|
+
* `pattern` / `name` / `meta`, with a fresh `meta` copy and NO `_handlers` closures.
|
|
2066
|
+
*
|
|
2067
|
+
* @param entry - The compiled route entry.
|
|
2068
|
+
* @returns A `ClientRoute` carrying only JSON-serializable fields.
|
|
2069
|
+
* @example
|
|
2070
|
+
* ```ts
|
|
2071
|
+
* toClientRoute(compiledEntry); // { pattern, name, meta }
|
|
2072
|
+
* ```
|
|
2073
|
+
*/
|
|
2074
|
+
function toClientRoute(entry) {
|
|
2075
|
+
return {
|
|
2076
|
+
pattern: entry.pattern,
|
|
2077
|
+
name: entry.name,
|
|
2078
|
+
meta: { ...entry.meta }
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2144
2082
|
* Creates the router plugin API surface. Every closure reads the compiled table
|
|
2145
2083
|
* from `ctx.state` and returns values/fresh copies — never the raw state arrays.
|
|
2146
2084
|
*
|
|
@@ -2210,11 +2148,113 @@ function createApi$4(ctx) {
|
|
|
2210
2148
|
*/
|
|
2211
2149
|
manifest() {
|
|
2212
2150
|
return [...readTable(state).byName.values()].map((entry) => entry.definition);
|
|
2151
|
+
},
|
|
2152
|
+
/**
|
|
2153
|
+
* Serializable, specificity-sorted projection of the route table for client
|
|
2154
|
+
* shipping — `{ pattern, name, meta }` entries with NO `_handlers` closures.
|
|
2155
|
+
*
|
|
2156
|
+
* @returns A fresh, frozen, specificity-sorted read-only array of client routes.
|
|
2157
|
+
* @example
|
|
2158
|
+
* ```ts
|
|
2159
|
+
* const json = JSON.stringify(api.clientManifest());
|
|
2160
|
+
* ```
|
|
2161
|
+
*/
|
|
2162
|
+
clientManifest() {
|
|
2163
|
+
return Object.freeze(readTable(state).compiled.map((entry) => toClientRoute(entry)));
|
|
2164
|
+
},
|
|
2165
|
+
/**
|
|
2166
|
+
* The resolved render mode (single source of truth for static/hybrid/spa).
|
|
2167
|
+
*
|
|
2168
|
+
* @returns `"ssg" | "spa" | "hybrid"`.
|
|
2169
|
+
* @example
|
|
2170
|
+
* ```ts
|
|
2171
|
+
* if (api.mode() !== "ssg") emitClientData();
|
|
2172
|
+
* ```
|
|
2173
|
+
*/
|
|
2174
|
+
mode() {
|
|
2175
|
+
return state.mode;
|
|
2213
2176
|
}
|
|
2214
2177
|
};
|
|
2215
2178
|
}
|
|
2216
2179
|
//#endregion
|
|
2180
|
+
//#region src/plugins/router/iso-match.ts
|
|
2181
|
+
/**
|
|
2182
|
+
* Parse a single path segment into its `{…}` placeholder, or `false` for a static
|
|
2183
|
+
* segment. Plain loop over the brace delimiters (no backtracking regex). Shared by
|
|
2184
|
+
* the build-time compiler and this isomorphic matcher so the two never diverge.
|
|
2185
|
+
*
|
|
2186
|
+
* @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
|
|
2187
|
+
* @returns The parsed placeholder, or `false` when the segment is static.
|
|
2188
|
+
* @example
|
|
2189
|
+
* ```ts
|
|
2190
|
+
* parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
|
|
2191
|
+
* ```
|
|
2192
|
+
*/
|
|
2193
|
+
function parsePlaceholder$1(segment) {
|
|
2194
|
+
if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
|
|
2195
|
+
const inner = segment.slice(1, -1);
|
|
2196
|
+
if (inner.endsWith(":?")) return {
|
|
2197
|
+
name: inner.slice(0, -2),
|
|
2198
|
+
optional: true
|
|
2199
|
+
};
|
|
2200
|
+
return {
|
|
2201
|
+
name: inner,
|
|
2202
|
+
optional: false
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Counts the dynamic (`{param}` / `{param:?}` / `:param`) segments in a route
|
|
2207
|
+
* pattern — fewer dynamic segments rank as more specific. The optional `{lang:?}`
|
|
2208
|
+
* segment is excluded so locale-prefixing does not affect priority (identical to
|
|
2209
|
+
* the build-time compiler's count, which sourced this logic).
|
|
2210
|
+
*
|
|
2211
|
+
* @param pattern - The route pattern string.
|
|
2212
|
+
* @returns The number of dynamic (non-lang) segments.
|
|
2213
|
+
* @example
|
|
2214
|
+
* ```ts
|
|
2215
|
+
* dynamicSegmentCount("/blog/{slug}/"); // 1
|
|
2216
|
+
* dynamicSegmentCount("/{lang:?}/{slug}/"); // 1
|
|
2217
|
+
* ```
|
|
2218
|
+
*/
|
|
2219
|
+
function dynamicSegmentCount(pattern) {
|
|
2220
|
+
let count = 0;
|
|
2221
|
+
for (const segment of pattern.split("/")) {
|
|
2222
|
+
const placeholder = parsePlaceholder$1(segment);
|
|
2223
|
+
const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
|
|
2224
|
+
const isColonDynamic = !placeholder && segment.startsWith(":");
|
|
2225
|
+
if (isBraceDynamic || isColonDynamic) count += 1;
|
|
2226
|
+
}
|
|
2227
|
+
return count;
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* Comparator that orders two routes most-specific-first (fewest dynamic segments
|
|
2231
|
+
* first). Equal specificity yields `0` so a stable sort preserves declaration
|
|
2232
|
+
* order — the exact ordering the compiled matcher table uses, guaranteeing
|
|
2233
|
+
* build-time and client-time route resolution can never diverge.
|
|
2234
|
+
*
|
|
2235
|
+
* @param a - First route (carries its `pattern` string).
|
|
2236
|
+
* @param a.pattern - First route's pattern string.
|
|
2237
|
+
* @param b - Second route (carries its `pattern` string).
|
|
2238
|
+
* @param b.pattern - Second route's pattern string.
|
|
2239
|
+
* @returns Negative if `a` is more specific, positive if `b` is, `0` on a tie.
|
|
2240
|
+
* @example
|
|
2241
|
+
* ```ts
|
|
2242
|
+
* routes.toSorted(bySpecificity);
|
|
2243
|
+
* ```
|
|
2244
|
+
*/
|
|
2245
|
+
function bySpecificity(a, b) {
|
|
2246
|
+
return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
|
|
2247
|
+
}
|
|
2248
|
+
//#endregion
|
|
2217
2249
|
//#region src/plugins/router/builders/compile.ts
|
|
2250
|
+
/**
|
|
2251
|
+
* @file router plugin — compilation + validation domain.
|
|
2252
|
+
*
|
|
2253
|
+
* Pure functions invoked from `onInit`: validate the route map, then compile each
|
|
2254
|
+
* route into URLPattern matchers + URL/file builders, count dynamic segments,
|
|
2255
|
+
* sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
|
|
2256
|
+
* only (`CompileInput`) — never the plugin ctx.
|
|
2257
|
+
*/
|
|
2218
2258
|
/** Shared `[web]` error prefix for router validation failures. */
|
|
2219
2259
|
const ERROR_PREFIX$8 = "[web] router";
|
|
2220
2260
|
/**
|
|
@@ -2329,27 +2369,6 @@ function buildFilePath(pattern, params) {
|
|
|
2329
2369
|
return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
|
|
2330
2370
|
}
|
|
2331
2371
|
/**
|
|
2332
|
-
* Count dynamic segments in a pattern (lower = more specific). The optional
|
|
2333
|
-
* `{lang:?}` segment is excluded so locale-prefixing does not affect priority.
|
|
2334
|
-
*
|
|
2335
|
-
* @param pattern - The route pattern.
|
|
2336
|
-
* @returns The number of dynamic (non-lang) segments.
|
|
2337
|
-
* @example
|
|
2338
|
-
* ```ts
|
|
2339
|
-
* countDynamicSegments("/{lang:?}/{slug}/"); // 1
|
|
2340
|
-
* ```
|
|
2341
|
-
*/
|
|
2342
|
-
function countDynamicSegments(pattern) {
|
|
2343
|
-
let count = 0;
|
|
2344
|
-
for (const segment of pattern.split("/")) {
|
|
2345
|
-
const placeholder = parsePlaceholder(segment);
|
|
2346
|
-
const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
|
|
2347
|
-
const isColonDynamic = !placeholder && segment.startsWith(":");
|
|
2348
|
-
if (isBraceDynamic || isColonDynamic) count += 1;
|
|
2349
|
-
}
|
|
2350
|
-
return count;
|
|
2351
|
-
}
|
|
2352
|
-
/**
|
|
2353
2372
|
* Compile a single route definition into its `CompiledRoute` entry.
|
|
2354
2373
|
*
|
|
2355
2374
|
* @param name - The route name key.
|
|
@@ -2371,7 +2390,7 @@ function compileRoute(name, definition, input) {
|
|
|
2371
2390
|
return {
|
|
2372
2391
|
name,
|
|
2373
2392
|
pattern,
|
|
2374
|
-
dynamicSegmentCount:
|
|
2393
|
+
dynamicSegmentCount: dynamicSegmentCount(pattern),
|
|
2375
2394
|
matchers,
|
|
2376
2395
|
matchFn: createMatchFunction(matchers, input.defaultLocale),
|
|
2377
2396
|
/**
|
|
@@ -2428,10 +2447,7 @@ function compileRoutes(input) {
|
|
|
2428
2447
|
byName.set(name, entry);
|
|
2429
2448
|
}
|
|
2430
2449
|
return {
|
|
2431
|
-
compiled: declarationOrder.
|
|
2432
|
-
entry,
|
|
2433
|
-
index
|
|
2434
|
-
})).toSorted((a, b) => a.entry.dynamicSegmentCount === b.entry.dynamicSegmentCount ? a.index - b.index : a.entry.dynamicSegmentCount - b.entry.dynamicSegmentCount).map((wrapped) => wrapped.entry),
|
|
2450
|
+
compiled: declarationOrder.toSorted(bySpecificity),
|
|
2435
2451
|
byName
|
|
2436
2452
|
};
|
|
2437
2453
|
}
|
|
@@ -2544,6 +2560,21 @@ function route(pattern) {
|
|
|
2544
2560
|
return set("render", handler);
|
|
2545
2561
|
},
|
|
2546
2562
|
/**
|
|
2563
|
+
* Attach the client-side validation gate (raw `unknown` → this route's data
|
|
2564
|
+
* type). Runs at the trust boundary before `render` on the client; throw to
|
|
2565
|
+
* reject malformed data (spa falls back to HTML-over-fetch).
|
|
2566
|
+
*
|
|
2567
|
+
* @param handler - The validator/parser.
|
|
2568
|
+
* @returns The same builder for chaining.
|
|
2569
|
+
* @example
|
|
2570
|
+
* ```ts
|
|
2571
|
+
* route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
|
|
2572
|
+
* ```
|
|
2573
|
+
*/
|
|
2574
|
+
parse(handler) {
|
|
2575
|
+
return set("parse", handler);
|
|
2576
|
+
},
|
|
2577
|
+
/**
|
|
2547
2578
|
* Attach the head/SEO handler.
|
|
2548
2579
|
*
|
|
2549
2580
|
* @param handler - The head handler.
|
|
@@ -2570,9 +2601,11 @@ function route(pattern) {
|
|
|
2570
2601
|
return set("generate", handler);
|
|
2571
2602
|
},
|
|
2572
2603
|
/**
|
|
2573
|
-
* Merge an arbitrary metadata bag into the route's `_meta`.
|
|
2604
|
+
* Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
|
|
2605
|
+
* JSON-serializable — it is projected verbatim into `clientManifest()` and
|
|
2606
|
+
* shipped to the browser, so functions/symbols/class instances are unsupported.
|
|
2574
2607
|
*
|
|
2575
|
-
* @param meta -
|
|
2608
|
+
* @param meta - JSON-serializable metadata to merge.
|
|
2576
2609
|
* @returns The same builder for chaining.
|
|
2577
2610
|
* @example
|
|
2578
2611
|
* ```ts
|
|
@@ -2642,7 +2675,10 @@ function defineRoutes(routes) {
|
|
|
2642
2675
|
* ```
|
|
2643
2676
|
*/
|
|
2644
2677
|
function createState$4(_ctx) {
|
|
2645
|
-
return {
|
|
2678
|
+
return {
|
|
2679
|
+
table: null,
|
|
2680
|
+
mode: _ctx.config.mode ?? "hybrid"
|
|
2681
|
+
};
|
|
2646
2682
|
}
|
|
2647
2683
|
/**
|
|
2648
2684
|
* Router plugin — typed, named route definitions with locale-aware URL generation
|
|
@@ -3260,6 +3296,29 @@ function resolveEntrypoints(candidates) {
|
|
|
3260
3296
|
return [];
|
|
3261
3297
|
}
|
|
3262
3298
|
/**
|
|
3299
|
+
* Resolve the authoritative JS client entrypoint (#8): when `config.clientEntry` is
|
|
3300
|
+
* set, use it directly (the authoritative override); otherwise fall back to the
|
|
3301
|
+
* conventional candidate scan. When neither yields an entry, `ctx.log.warn` (no
|
|
3302
|
+
* client bundle is produced) and an empty list is returned.
|
|
3303
|
+
*
|
|
3304
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
3305
|
+
* @returns The resolved JS entrypoint list (possibly empty).
|
|
3306
|
+
* @example
|
|
3307
|
+
* ```ts
|
|
3308
|
+
* resolveJsEntrypoints(ctx);
|
|
3309
|
+
* ```
|
|
3310
|
+
*/
|
|
3311
|
+
function resolveJsEntrypoints(ctx) {
|
|
3312
|
+
const { clientEntry } = ctx.config;
|
|
3313
|
+
if (typeof clientEntry === "string" && clientEntry.length > 0) return [clientEntry];
|
|
3314
|
+
const scanned = resolveEntrypoints(JS_ENTRY_CANDIDATES);
|
|
3315
|
+
if (scanned.length === 0) ctx.log.warn("build:bundle", {
|
|
3316
|
+
clientEntry: "none",
|
|
3317
|
+
scanned: JS_ENTRY_CANDIDATES
|
|
3318
|
+
});
|
|
3319
|
+
return scanned;
|
|
3320
|
+
}
|
|
3321
|
+
/**
|
|
3263
3322
|
* Run one bundler pass for a single asset kind and record the hashed output
|
|
3264
3323
|
* paths under `state.buildCache` keyed by the original entry basename.
|
|
3265
3324
|
*
|
|
@@ -3307,7 +3366,7 @@ async function bundle(ctx, options = {}) {
|
|
|
3307
3366
|
const runner = options.runner ?? defaultRunner;
|
|
3308
3367
|
const { minify, outDir } = ctx.config;
|
|
3309
3368
|
const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
|
|
3310
|
-
const jsEntrypoints = options.jsEntrypoints ??
|
|
3369
|
+
const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
|
|
3311
3370
|
await runOne(ctx, runner, "css", cssEntrypoints, node_path$1.default.join(outDir, "assets"), minify);
|
|
3312
3371
|
await runOne(ctx, runner, "js", jsEntrypoints, node_path$1.default.join(outDir, "assets"), minify);
|
|
3313
3372
|
}
|
|
@@ -3474,119 +3533,370 @@ async function processImages(ctx, options = {}) {
|
|
|
3474
3533
|
return copied;
|
|
3475
3534
|
}
|
|
3476
3535
|
//#endregion
|
|
3477
|
-
//#region src/plugins/build/phases/
|
|
3536
|
+
//#region src/plugins/build/phases/locale-redirects.ts
|
|
3478
3537
|
/**
|
|
3479
|
-
* @file build phase
|
|
3480
|
-
*
|
|
3481
|
-
*
|
|
3482
|
-
*
|
|
3538
|
+
* @file build phase — locale-redirects. For each non-prefixed route path, emits a
|
|
3539
|
+
* redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
|
|
3540
|
+
* bare path that points at the default-locale-prefixed URL. Deliberately does NOT
|
|
3541
|
+
* emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
|
|
3542
|
+
* `config.localeRedirects` (false/unset disables).
|
|
3483
3543
|
*/
|
|
3484
|
-
/** Default OG image dimensions when `size` is omitted. */
|
|
3485
|
-
const DEFAULT_SIZE = {
|
|
3486
|
-
width: 1200,
|
|
3487
|
-
height: 630
|
|
3488
|
-
};
|
|
3489
|
-
/** Recognized font file extensions. */
|
|
3490
|
-
const FONT_EXTENSIONS$1 = [
|
|
3491
|
-
".ttf",
|
|
3492
|
-
".otf",
|
|
3493
|
-
".woff"
|
|
3494
|
-
];
|
|
3495
3544
|
/**
|
|
3496
|
-
*
|
|
3545
|
+
* Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
|
|
3497
3546
|
*
|
|
3498
|
-
* @param
|
|
3499
|
-
* @
|
|
3500
|
-
* @param size - The output dimensions.
|
|
3501
|
-
* @returns The hex-encoded SHA-256 digest.
|
|
3547
|
+
* @param target - The default-locale-prefixed URL to redirect to.
|
|
3548
|
+
* @returns The complete redirect HTML document string.
|
|
3502
3549
|
* @example
|
|
3503
3550
|
* ```ts
|
|
3504
|
-
*
|
|
3551
|
+
* redirectHtml("/en/about/");
|
|
3505
3552
|
* ```
|
|
3506
3553
|
*/
|
|
3507
|
-
function
|
|
3508
|
-
return
|
|
3554
|
+
function redirectHtml(target) {
|
|
3555
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${target}"><link rel="canonical" href="${target}"></head><body><a href="${target}">Redirecting…</a></body></html>`;
|
|
3509
3556
|
}
|
|
3510
3557
|
/**
|
|
3511
|
-
*
|
|
3558
|
+
* Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
|
|
3559
|
+
* shared stable key); routes without a compiled entry are skipped.
|
|
3512
3560
|
*
|
|
3513
|
-
* @param
|
|
3514
|
-
* @returns
|
|
3561
|
+
* @param router - The router API exposing `manifest` + `entries`.
|
|
3562
|
+
* @returns Pairs of `[definition, entry]` for every correlated route.
|
|
3515
3563
|
* @example
|
|
3516
3564
|
* ```ts
|
|
3517
|
-
*
|
|
3565
|
+
* pairRoutes(router);
|
|
3518
3566
|
* ```
|
|
3519
3567
|
*/
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
const
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
}
|
|
3568
|
+
function pairRoutes(router) {
|
|
3569
|
+
const byPattern = /* @__PURE__ */ new Map();
|
|
3570
|
+
for (const entry of router.entries()) byPattern.set(entry.pattern, entry);
|
|
3571
|
+
const pairs = [];
|
|
3572
|
+
for (const definition of router.manifest()) {
|
|
3573
|
+
const entry = byPattern.get(definition.pattern);
|
|
3574
|
+
if (entry) pairs.push([definition, entry]);
|
|
3575
|
+
}
|
|
3576
|
+
return pairs;
|
|
3528
3577
|
}
|
|
3529
3578
|
/**
|
|
3530
|
-
*
|
|
3579
|
+
* Expand one route into bare→default redirect jobs for the default locale. Uses
|
|
3580
|
+
* `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
|
|
3581
|
+
* only when the bare file path differs from the default-locale URL (i.e. the route
|
|
3582
|
+
* is locale-prefixed) — otherwise no redirect is needed.
|
|
3531
3583
|
*
|
|
3532
|
-
* @param
|
|
3533
|
-
* @param
|
|
3534
|
-
* @
|
|
3584
|
+
* @param definition - The route definition (carries `generate`).
|
|
3585
|
+
* @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
|
|
3586
|
+
* @param defaultLocale - The default locale to redirect bare paths to.
|
|
3587
|
+
* @returns Redirect jobs of `{ file, target }` for this route.
|
|
3535
3588
|
* @example
|
|
3536
3589
|
* ```ts
|
|
3537
|
-
*
|
|
3590
|
+
* await expandRedirects(def, entry, "en");
|
|
3538
3591
|
* ```
|
|
3539
3592
|
*/
|
|
3540
|
-
function
|
|
3541
|
-
const
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
children: title
|
|
3557
|
-
}), {
|
|
3558
|
-
width,
|
|
3559
|
-
height,
|
|
3560
|
-
fonts: [{
|
|
3561
|
-
name: font.name,
|
|
3562
|
-
data: font.data,
|
|
3563
|
-
weight: 400,
|
|
3564
|
-
style: "normal"
|
|
3565
|
-
}]
|
|
3566
|
-
})).render().asPng();
|
|
3567
|
-
};
|
|
3593
|
+
async function expandRedirects(definition, entry, defaultLocale) {
|
|
3594
|
+
const parameterSets = definition._handlers.generate ? await definition._handlers.generate(defaultLocale) : [{}];
|
|
3595
|
+
const jobs = [];
|
|
3596
|
+
for (const raw of parameterSets) {
|
|
3597
|
+
const params = raw ?? {};
|
|
3598
|
+
const file = entry.toFile(params);
|
|
3599
|
+
const target = entry.toUrl({
|
|
3600
|
+
...params,
|
|
3601
|
+
lang: defaultLocale
|
|
3602
|
+
});
|
|
3603
|
+
if (target !== entry.toUrl(params)) jobs.push({
|
|
3604
|
+
file,
|
|
3605
|
+
target
|
|
3606
|
+
});
|
|
3607
|
+
}
|
|
3608
|
+
return jobs;
|
|
3568
3609
|
}
|
|
3569
3610
|
/**
|
|
3570
|
-
*
|
|
3611
|
+
* Emits one bare-path redirect HTML page per locale-prefixed route path, each a
|
|
3612
|
+
* `0;url` refresh + canonical link to the default-locale URL. Never writes a
|
|
3613
|
+
* Cloudflare `_redirects` file. No-op (returns `null`) when `localeRedirects` is
|
|
3614
|
+
* false/unset.
|
|
3571
3615
|
*
|
|
3572
|
-
* @param
|
|
3573
|
-
* @returns The
|
|
3616
|
+
* @param ctx - Plugin context (provides `require`, `config`, `log`).
|
|
3617
|
+
* @returns The count of redirect pages written, or `null` when disabled.
|
|
3574
3618
|
* @example
|
|
3575
3619
|
* ```ts
|
|
3576
|
-
*
|
|
3620
|
+
* const result = await generateLocaleRedirects(ctx);
|
|
3577
3621
|
* ```
|
|
3578
3622
|
*/
|
|
3579
|
-
function
|
|
3623
|
+
async function generateLocaleRedirects(ctx) {
|
|
3624
|
+
if (!ctx.config.localeRedirects) {
|
|
3625
|
+
ctx.log.debug("build:locale-redirects", { skipped: true });
|
|
3626
|
+
return null;
|
|
3627
|
+
}
|
|
3628
|
+
const router = ctx.require(routerPlugin);
|
|
3629
|
+
const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
|
|
3630
|
+
const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale)))).flat();
|
|
3631
|
+
await Promise.all(jobs.map(async ({ file, target }) => {
|
|
3632
|
+
const filePath = node_path$1.default.join(ctx.config.outDir, file);
|
|
3633
|
+
await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(filePath), { recursive: true });
|
|
3634
|
+
await (0, node_fs_promises.writeFile)(filePath, redirectHtml(target), "utf8");
|
|
3635
|
+
}));
|
|
3636
|
+
ctx.log.debug("build:locale-redirects", { written: jobs.length });
|
|
3637
|
+
return { written: jobs.length };
|
|
3638
|
+
}
|
|
3639
|
+
//#endregion
|
|
3640
|
+
//#region src/plugins/build/phases/not-found.ts
|
|
3641
|
+
/**
|
|
3642
|
+
* @file build phase — not-found. Emits `outDir/404.html` from configured route
|
|
3643
|
+
* content or a built-in default. Gated by `config.notFound` (false/unset disables).
|
|
3644
|
+
*/
|
|
3645
|
+
/** The built-in default 404 page body when no custom route content is supplied. */
|
|
3646
|
+
const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
|
|
3647
|
+
/**
|
|
3648
|
+
* Wrap a body fragment in a minimal HTML document for the 404 page.
|
|
3649
|
+
*
|
|
3650
|
+
* @param body - The inner body HTML (default or configured).
|
|
3651
|
+
* @returns The complete HTML document string.
|
|
3652
|
+
* @example
|
|
3653
|
+
* ```ts
|
|
3654
|
+
* wrap("<h1>404</h1>");
|
|
3655
|
+
* ```
|
|
3656
|
+
*/
|
|
3657
|
+
function wrap(body) {
|
|
3658
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
|
|
3662
|
+
* default page; when it is `{ route }`, writes the supplied route content verbatim
|
|
3663
|
+
* inside the document shell. No-op (returns `null`) when `notFound` is false/unset.
|
|
3664
|
+
*
|
|
3665
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
3666
|
+
* @returns The written file path, or `null` when disabled.
|
|
3667
|
+
* @example
|
|
3668
|
+
* ```ts
|
|
3669
|
+
* const result = await generateNotFound(ctx);
|
|
3670
|
+
* ```
|
|
3671
|
+
*/
|
|
3672
|
+
async function generateNotFound(ctx) {
|
|
3673
|
+
const { notFound, outDir } = ctx.config;
|
|
3674
|
+
if (!notFound) {
|
|
3675
|
+
ctx.log.debug("build:not-found", { skipped: true });
|
|
3676
|
+
return null;
|
|
3677
|
+
}
|
|
3678
|
+
const body = typeof notFound === "object" && notFound.route ? notFound.route : DEFAULT_BODY;
|
|
3679
|
+
await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
|
|
3680
|
+
const file = node_path$1.default.join(outDir, "404.html");
|
|
3681
|
+
await (0, node_fs_promises.writeFile)(file, wrap(body), "utf8");
|
|
3682
|
+
ctx.log.debug("build:not-found", { path: file });
|
|
3683
|
+
return { path: file };
|
|
3684
|
+
}
|
|
3685
|
+
//#endregion
|
|
3686
|
+
//#region src/plugins/build/phases/og-images.tsx
|
|
3687
|
+
/**
|
|
3688
|
+
* @file build phase 4 — og-images. Renders one OG image per published article via
|
|
3689
|
+
* Satori → SVG → resvg → PNG, bounded by `p-limit(4)`, with a persisted
|
|
3690
|
+
* content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
|
|
3691
|
+
* Gated by config.ogImage (object enables; false disables).
|
|
3692
|
+
*/
|
|
3693
|
+
/** Default OG image dimensions when `size` is omitted. */
|
|
3694
|
+
const DEFAULT_SIZE = {
|
|
3695
|
+
width: 1200,
|
|
3696
|
+
height: 630
|
|
3697
|
+
};
|
|
3698
|
+
/** Recognized font file extensions. */
|
|
3699
|
+
const FONT_EXTENSIONS$1 = [
|
|
3700
|
+
".ttf",
|
|
3701
|
+
".otf",
|
|
3702
|
+
".woff"
|
|
3703
|
+
];
|
|
3704
|
+
/**
|
|
3705
|
+
* Compute a stable cache key for the `fonts` configuration so a font change
|
|
3706
|
+
* invalidates cached PNGs. Hashes the name/path/weight/style of each entry (order
|
|
3707
|
+
* preserved); an empty/omitted list yields a fixed sentinel.
|
|
3708
|
+
*
|
|
3709
|
+
* @param fonts - The configured OG fonts (optional).
|
|
3710
|
+
* @returns A short stable key derived from the fonts list.
|
|
3711
|
+
* @example
|
|
3712
|
+
* ```ts
|
|
3713
|
+
* fontsKey([{ name: "Inter", path: "./Inter.ttf" }]);
|
|
3714
|
+
* ```
|
|
3715
|
+
*/
|
|
3716
|
+
function fontsKey(fonts) {
|
|
3717
|
+
if (!fonts || fonts.length === 0) return "default-font";
|
|
3718
|
+
const parts = fonts.map((font) => `${font.name}:${font.path}:${font.weight ?? 400}:${font.style ?? "normal"}`);
|
|
3719
|
+
return (0, node_crypto.createHash)("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Compute the content-hash cache key for an article OG image. Covers the FULL
|
|
3723
|
+
* {@link RichOgInput} (title/description/date/tags/author/locale/siteName/size),
|
|
3724
|
+
* the resolved `template`, and a {@link fontsKey} of the fonts list — so changing
|
|
3725
|
+
* any input field OR the fonts invalidates the cached PNG.
|
|
3726
|
+
*
|
|
3727
|
+
* @param input - The full rich OG input for the card.
|
|
3728
|
+
* @param template - The resolved OG template identifier.
|
|
3729
|
+
* @param fontsHash - The {@link fontsKey} of the configured fonts.
|
|
3730
|
+
* @returns The hex-encoded SHA-256 digest.
|
|
3731
|
+
* @example
|
|
3732
|
+
* ```ts
|
|
3733
|
+
* ogHash(input, "default", fontsKey());
|
|
3734
|
+
* ```
|
|
3735
|
+
*/
|
|
3736
|
+
function ogHash(input, template, fontsHash) {
|
|
3737
|
+
const payload = [
|
|
3738
|
+
input.title,
|
|
3739
|
+
input.description,
|
|
3740
|
+
input.date,
|
|
3741
|
+
input.tags.join(","),
|
|
3742
|
+
input.author ?? "",
|
|
3743
|
+
input.locale,
|
|
3744
|
+
input.siteName,
|
|
3745
|
+
`${input.size.width}x${input.size.height}`,
|
|
3746
|
+
template,
|
|
3747
|
+
fontsHash
|
|
3748
|
+
].join("|");
|
|
3749
|
+
return (0, node_crypto.createHash)("sha256").update(payload).digest("hex");
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
|
|
3753
|
+
* `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
|
|
3754
|
+
* font entry; otherwise the first font file found in `fontDir` is used as a single
|
|
3755
|
+
* 400/normal fallback.
|
|
3756
|
+
*
|
|
3757
|
+
* @param og - The font directory + optional explicit fonts list.
|
|
3758
|
+
* @param og.fontDir - Directory scanned for a fallback font when `fonts` is unset.
|
|
3759
|
+
* @param og.fonts - Explicit named fonts (each loaded once).
|
|
3760
|
+
* @returns The loaded fonts (empty when no font is available).
|
|
3761
|
+
* @example
|
|
3762
|
+
* ```ts
|
|
3763
|
+
* await loadFonts({ fontDir: "./fonts" });
|
|
3764
|
+
* ```
|
|
3765
|
+
*/
|
|
3766
|
+
async function loadFonts(og) {
|
|
3767
|
+
if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
|
|
3768
|
+
name: font.name,
|
|
3769
|
+
data: await (0, node_fs_promises.readFile)(font.path),
|
|
3770
|
+
weight: font.weight ?? 400,
|
|
3771
|
+
style: font.style ?? "normal"
|
|
3772
|
+
})));
|
|
3773
|
+
if (!(0, node_fs.existsSync)(og.fontDir)) return [];
|
|
3774
|
+
const file = (await (0, node_fs_promises.readdir)(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
|
|
3775
|
+
if (!file) return [];
|
|
3776
|
+
return [{
|
|
3777
|
+
name: "OG",
|
|
3778
|
+
data: await (0, node_fs_promises.readFile)(node_path.default.join(og.fontDir, file)),
|
|
3779
|
+
weight: 400,
|
|
3780
|
+
style: "normal"
|
|
3781
|
+
}];
|
|
3782
|
+
}
|
|
3783
|
+
/**
|
|
3784
|
+
* The built-in default OG card — a centered title on a dark background. Used when
|
|
3785
|
+
* no custom `ogImage.render` hook is configured. (`@jsxImportSource preact`.)
|
|
3786
|
+
*
|
|
3787
|
+
* @param input - The rich OG input (only `title` is used by the default card).
|
|
3788
|
+
* @returns The Preact `VNode` for the default card.
|
|
3789
|
+
* @example
|
|
3790
|
+
* ```ts
|
|
3791
|
+
* defaultCard(input);
|
|
3792
|
+
* ```
|
|
3793
|
+
*/
|
|
3794
|
+
function defaultCard(input) {
|
|
3795
|
+
return (0, preact.h)("div", { style: {
|
|
3796
|
+
display: "flex",
|
|
3797
|
+
width: "100%",
|
|
3798
|
+
height: "100%",
|
|
3799
|
+
alignItems: "center",
|
|
3800
|
+
justifyContent: "center",
|
|
3801
|
+
fontSize: 64,
|
|
3802
|
+
background: "#0b0b0c",
|
|
3803
|
+
color: "#ffffff"
|
|
3804
|
+
} }, input.title);
|
|
3805
|
+
}
|
|
3806
|
+
/**
|
|
3807
|
+
* The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
|
|
3808
|
+
* card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
|
|
3809
|
+
* deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
|
|
3810
|
+
* at this single framework boundary only.
|
|
3811
|
+
*
|
|
3812
|
+
* @param ctx - The renderer wiring (preloaded fonts + optional custom card).
|
|
3813
|
+
* @param ctx.fonts - Fonts loaded once for the whole render pass.
|
|
3814
|
+
* @param ctx.render - Optional custom card renderer; defaults to {@link defaultCard}.
|
|
3815
|
+
* @returns An {@link OgPngRenderer} bound to the loaded fonts + renderer.
|
|
3816
|
+
* @example
|
|
3817
|
+
* ```ts
|
|
3818
|
+
* const render = makeDefaultRenderer({ fonts, render: undefined });
|
|
3819
|
+
* ```
|
|
3820
|
+
*/
|
|
3821
|
+
function makeDefaultRenderer(ctx) {
|
|
3822
|
+
return async (input) => {
|
|
3823
|
+
if (ctx.fonts.length === 0) throw new Error("[web] build.ogImage no font available for rendering");
|
|
3824
|
+
const { default: satori } = await import("satori");
|
|
3825
|
+
const { Resvg } = await import("@resvg/resvg-js");
|
|
3826
|
+
return new Resvg(await satori((ctx.render ?? defaultCard)(input), {
|
|
3827
|
+
width: input.size.width,
|
|
3828
|
+
height: input.size.height,
|
|
3829
|
+
fonts: ctx.fonts
|
|
3830
|
+
})).render().asPng();
|
|
3831
|
+
};
|
|
3832
|
+
}
|
|
3833
|
+
/**
|
|
3834
|
+
* Select the published articles to render OG images for (default-locale set).
|
|
3835
|
+
*
|
|
3836
|
+
* @param byLocale - The cached locale-keyed article map.
|
|
3837
|
+
* @returns The published articles across the first cached locale.
|
|
3838
|
+
* @example
|
|
3839
|
+
* ```ts
|
|
3840
|
+
* selectArticles(byLocale);
|
|
3841
|
+
* ```
|
|
3842
|
+
*/
|
|
3843
|
+
function selectArticles(byLocale) {
|
|
3580
3844
|
return ([...byLocale.values()][0] ?? []).filter((article) => article.computed.status === "published");
|
|
3581
3845
|
}
|
|
3582
3846
|
/**
|
|
3847
|
+
* Build the {@link RichOgInput} for one article from its frontmatter/computed
|
|
3848
|
+
* fields plus the resolved size and site name.
|
|
3849
|
+
*
|
|
3850
|
+
* @param article - The published article to render a card for.
|
|
3851
|
+
* @param size - The resolved OG output dimensions.
|
|
3852
|
+
* @param siteName - The site name (from the site plugin, or `""` when unavailable).
|
|
3853
|
+
* @returns The fully-populated rich OG input.
|
|
3854
|
+
* @example
|
|
3855
|
+
* ```ts
|
|
3856
|
+
* buildInput(article, { width: 1200, height: 630 }, "Blog");
|
|
3857
|
+
* ```
|
|
3858
|
+
*/
|
|
3859
|
+
function buildInput(article, size, siteName) {
|
|
3860
|
+
const input = {
|
|
3861
|
+
title: article.frontmatter.title,
|
|
3862
|
+
description: article.frontmatter.description,
|
|
3863
|
+
date: article.frontmatter.date,
|
|
3864
|
+
tags: [...article.frontmatter.tags],
|
|
3865
|
+
locale: article.locale,
|
|
3866
|
+
siteName,
|
|
3867
|
+
size
|
|
3868
|
+
};
|
|
3869
|
+
if (article.frontmatter.author !== void 0) input.author = article.frontmatter.author;
|
|
3870
|
+
return input;
|
|
3871
|
+
}
|
|
3872
|
+
/**
|
|
3873
|
+
* Resolve the site name via `ctx.require(sitePlugin)`, falling back to `""` when the
|
|
3874
|
+
* site API is unavailable (e.g. unit mocks that omit it).
|
|
3875
|
+
*
|
|
3876
|
+
* @param ctx - Plugin context (provides `require`).
|
|
3877
|
+
* @returns The site name, or `""` when the site plugin is not wired.
|
|
3878
|
+
* @example
|
|
3879
|
+
* ```ts
|
|
3880
|
+
* resolveSiteName(ctx);
|
|
3881
|
+
* ```
|
|
3882
|
+
*/
|
|
3883
|
+
function resolveSiteName(ctx) {
|
|
3884
|
+
try {
|
|
3885
|
+
return ctx.require(sitePlugin).name();
|
|
3886
|
+
} catch {
|
|
3887
|
+
return "";
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
/**
|
|
3583
3891
|
* Renders OG images for published articles with a `p-limit(4)` concurrency pool.
|
|
3584
|
-
* Computes
|
|
3585
|
-
* the hash matches `state.ogImageHashCache`; writes the
|
|
3586
|
-
* `<outDir>/.cache/og-images.json`.
|
|
3892
|
+
* Computes {@link ogHash} (full {@link RichOgInput} + template + fonts) per article
|
|
3893
|
+
* and skips regeneration when the hash matches `state.ogImageHashCache`; writes the
|
|
3894
|
+
* cache back to `<outDir>/.cache/og-images.json`. The configured `ogImage.render`
|
|
3895
|
+
* hook (when present) builds each card; otherwise the built-in card is used. Fonts
|
|
3896
|
+
* are loaded ONCE for the whole pass. No-op when `config.ogImage` is false.
|
|
3587
3897
|
*
|
|
3588
|
-
* @param ctx - Plugin context (provides `state`, `config`, `log`).
|
|
3589
|
-
* @param options - Optional dependency-injection seam (PNG
|
|
3898
|
+
* @param ctx - Plugin context (provides `require`, `state`, `config`, `log`).
|
|
3899
|
+
* @param options - Optional dependency-injection seam (PNG rasterizer).
|
|
3590
3900
|
* @returns The render/skip counts + peak concurrency, or `null` when disabled.
|
|
3591
3901
|
* @example
|
|
3592
3902
|
* ```ts
|
|
@@ -3600,9 +3910,17 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3600
3910
|
return null;
|
|
3601
3911
|
}
|
|
3602
3912
|
const { default: pLimit } = await import("p-limit");
|
|
3603
|
-
const
|
|
3604
|
-
const
|
|
3605
|
-
const
|
|
3913
|
+
const config = og;
|
|
3914
|
+
const size = config.size ?? DEFAULT_SIZE;
|
|
3915
|
+
const template = config.template ?? "default";
|
|
3916
|
+
const fontsHash = fontsKey(config.fonts);
|
|
3917
|
+
const fonts = options.renderPng ? [] : await loadFonts(config);
|
|
3918
|
+
const renderHook = config.render ? { render: config.render } : {};
|
|
3919
|
+
const renderPng = options.renderPng ?? makeDefaultRenderer({
|
|
3920
|
+
fonts,
|
|
3921
|
+
...renderHook
|
|
3922
|
+
});
|
|
3923
|
+
const siteName = resolveSiteName(ctx);
|
|
3606
3924
|
const articles = selectArticles(readCachedContent(ctx));
|
|
3607
3925
|
const cache = ctx.state.ogImageHashCache;
|
|
3608
3926
|
await loadDiskCache(ctx.config.outDir, cache);
|
|
@@ -3614,7 +3932,8 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3614
3932
|
const outDir = node_path.default.join(ctx.config.outDir, "og");
|
|
3615
3933
|
await Promise.all(articles.map((article) => limit(async () => {
|
|
3616
3934
|
const key = article.computed.contentId;
|
|
3617
|
-
const
|
|
3935
|
+
const input = buildInput(article, size, siteName);
|
|
3936
|
+
const hash = ogHash(input, template, fontsHash);
|
|
3618
3937
|
if (cache.get(key) === hash) {
|
|
3619
3938
|
skipped += 1;
|
|
3620
3939
|
return;
|
|
@@ -3622,10 +3941,7 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3622
3941
|
active += 1;
|
|
3623
3942
|
peakConcurrency = Math.max(peakConcurrency, active);
|
|
3624
3943
|
try {
|
|
3625
|
-
const png = await renderPng(
|
|
3626
|
-
title: article.frontmatter.title,
|
|
3627
|
-
...size
|
|
3628
|
-
});
|
|
3944
|
+
const png = await renderPng(input);
|
|
3629
3945
|
await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
|
|
3630
3946
|
await (0, node_fs_promises.writeFile)(node_path.default.join(outDir, `${key}.png`), png);
|
|
3631
3947
|
cache.set(key, hash);
|
|
@@ -3680,28 +3996,329 @@ async function persistDiskCache(outDir, cache) {
|
|
|
3680
3996
|
await (0, node_fs_promises.writeFile)(node_path.default.join(dir, "og-images.json"), JSON.stringify(Object.fromEntries(cache)), "utf8");
|
|
3681
3997
|
}
|
|
3682
3998
|
//#endregion
|
|
3999
|
+
//#region src/plugins/data/load-json.ts
|
|
4000
|
+
/**
|
|
4001
|
+
* @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
|
|
4002
|
+
* SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
|
|
4003
|
+
* `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
|
|
4004
|
+
*
|
|
4005
|
+
* A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
|
|
4006
|
+
* on the client (browser) it fetches the same data over HTTP. `loadJson` is the
|
|
4007
|
+
* single point where those two worlds differ — everything above it (the route's
|
|
4008
|
+
* `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
|
|
4009
|
+
*
|
|
4010
|
+
* The browser path uses the `fetch` global. The Node path lazy-imports
|
|
4011
|
+
* `node:fs/promises` via `await import(...)`, so a browser bundle that includes
|
|
4012
|
+
* `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
|
|
4013
|
+
* into its own chunk that the browser never loads).
|
|
4014
|
+
*/
|
|
4015
|
+
/**
|
|
4016
|
+
* Read + parse a JSON resource, isomorphically. In a browser (`document`
|
|
4017
|
+
* defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
|
|
4018
|
+
* on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
|
|
4019
|
+
* can decide whether to fall back.
|
|
4020
|
+
*
|
|
4021
|
+
* @template T - The expected shape of the parsed JSON.
|
|
4022
|
+
* @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
|
|
4023
|
+
* @returns The parsed JSON, typed as `T`.
|
|
4024
|
+
* @throws {Error} If the browser fetch is not OK, or the Node file read fails.
|
|
4025
|
+
* @example
|
|
4026
|
+
* ```ts
|
|
4027
|
+
* // Browser: fetch("/_data/en/articles.json")
|
|
4028
|
+
* // Node: read "dist/_data/en/articles.json"
|
|
4029
|
+
* const articles = await loadJson<Article[]>("/_data/en/articles.json");
|
|
4030
|
+
* ```
|
|
4031
|
+
*/
|
|
4032
|
+
async function loadJson(pathOrUrl) {
|
|
4033
|
+
if (typeof document === "undefined") {
|
|
4034
|
+
const { readFile } = await import("node:fs/promises");
|
|
4035
|
+
return JSON.parse(await readFile(pathOrUrl, "utf8"));
|
|
4036
|
+
}
|
|
4037
|
+
const response = await fetch(pathOrUrl);
|
|
4038
|
+
if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
|
|
4039
|
+
return response.json();
|
|
4040
|
+
}
|
|
4041
|
+
//#endregion
|
|
4042
|
+
//#region src/plugins/data/api.ts
|
|
4043
|
+
/**
|
|
4044
|
+
* @file data plugin — API factory (the agnostic data provider surface).
|
|
4045
|
+
*
|
|
4046
|
+
* Node-free by construction: this module statically imports only types + the pure
|
|
4047
|
+
* convention. The Node write side (`write()`) reaches its `node:fs` writer through
|
|
4048
|
+
* a lazy `await import("./writer")` at call time, so a browser bundle that composes
|
|
4049
|
+
* `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
|
|
4050
|
+
* the isomorphic `loadJson` (whose Node branch is itself lazy).
|
|
4051
|
+
*/
|
|
4052
|
+
/**
|
|
4053
|
+
* Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
|
|
4054
|
+
*
|
|
4055
|
+
* @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
|
|
4056
|
+
* @returns The dir without a trailing slash.
|
|
4057
|
+
* @example
|
|
4058
|
+
* ```ts
|
|
4059
|
+
* trimTrailingSlash("_data/"); // "_data"
|
|
4060
|
+
* ```
|
|
4061
|
+
*/
|
|
4062
|
+
function trimTrailingSlash(dir) {
|
|
4063
|
+
return dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
4066
|
+
* Builds the data provider — the agnostic bridge. `write()` is the Node persist
|
|
4067
|
+
* side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
|
|
4068
|
+
* convention. No `onStart`/`onStop` (holds no long-lived resource).
|
|
4069
|
+
*
|
|
4070
|
+
* @param ctx - The data plugin context.
|
|
4071
|
+
* @returns The {@link DataProvider} mounted at `app.data`.
|
|
4072
|
+
* @example
|
|
4073
|
+
* ```ts
|
|
4074
|
+
* const api = dataApi(ctx);
|
|
4075
|
+
* await api.write([{ path: "/en/hello/", data: article }]); // Node build
|
|
4076
|
+
* await api.at("/en/hello/"); // browser
|
|
4077
|
+
* ```
|
|
4078
|
+
*/
|
|
4079
|
+
function dataApi(ctx) {
|
|
4080
|
+
return {
|
|
4081
|
+
/**
|
|
4082
|
+
* READ (browser) — fetch (and cache) the persisted data for a page path.
|
|
4083
|
+
* Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
|
|
4084
|
+
* or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
|
|
4085
|
+
*
|
|
4086
|
+
* @param path - The page URL path (e.g. `/en/hello/`).
|
|
4087
|
+
* @returns The page's raw data, or `null` on failure.
|
|
4088
|
+
* @example
|
|
4089
|
+
* ```ts
|
|
4090
|
+
* const raw = await api.at("/en/hello/");
|
|
4091
|
+
* ```
|
|
4092
|
+
*/
|
|
4093
|
+
async at(path) {
|
|
4094
|
+
if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
|
|
4095
|
+
try {
|
|
4096
|
+
const data = await loadJson(`${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`);
|
|
4097
|
+
ctx.state.cache.set(path, data);
|
|
4098
|
+
return data;
|
|
4099
|
+
} catch {
|
|
4100
|
+
return null;
|
|
4101
|
+
}
|
|
4102
|
+
},
|
|
4103
|
+
/**
|
|
4104
|
+
* WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
|
|
4105
|
+
* `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
|
|
4106
|
+
* browser bundle node-free).
|
|
4107
|
+
*
|
|
4108
|
+
* @param entries - The per-page data to persist.
|
|
4109
|
+
* @param options - Optional `{ outDir }` override (defaults to `./dist`).
|
|
4110
|
+
* @param options.outDir - Build output directory the write happens under.
|
|
4111
|
+
* @returns A summary of the written files.
|
|
4112
|
+
* @example
|
|
4113
|
+
* ```ts
|
|
4114
|
+
* await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
|
|
4115
|
+
* ```
|
|
4116
|
+
*/
|
|
4117
|
+
async write(entries, options) {
|
|
4118
|
+
const { writeData } = await Promise.resolve().then(() => require("./writer-DAF0pM25.cjs"));
|
|
4119
|
+
return writeData(ctx, entries, options);
|
|
4120
|
+
},
|
|
4121
|
+
/**
|
|
4122
|
+
* PURE — the browser fetch URL for a page path.
|
|
4123
|
+
*
|
|
4124
|
+
* @param path - The page URL path.
|
|
4125
|
+
* @returns The site-root-relative data URL.
|
|
4126
|
+
* @example
|
|
4127
|
+
* ```ts
|
|
4128
|
+
* api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
|
|
4129
|
+
* ```
|
|
4130
|
+
*/
|
|
4131
|
+
urlFor(path) {
|
|
4132
|
+
return `${ctx.config.baseUrl}${require_convention.dataSuffix(path)}`;
|
|
4133
|
+
},
|
|
4134
|
+
/**
|
|
4135
|
+
* PURE — the `outDir`-relative file path for a page path.
|
|
4136
|
+
*
|
|
4137
|
+
* @param path - The page URL path.
|
|
4138
|
+
* @returns The output-relative file path.
|
|
4139
|
+
* @example
|
|
4140
|
+
* ```ts
|
|
4141
|
+
* api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
|
|
4142
|
+
* ```
|
|
4143
|
+
*/
|
|
4144
|
+
fileFor(path) {
|
|
4145
|
+
return `${trimTrailingSlash(ctx.config.outputDir)}/${require_convention.dataSuffix(path)}`;
|
|
4146
|
+
}
|
|
4147
|
+
};
|
|
4148
|
+
}
|
|
4149
|
+
//#endregion
|
|
4150
|
+
//#region src/plugins/data/config.ts
|
|
4151
|
+
/**
|
|
4152
|
+
* Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
|
|
4153
|
+
* (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
|
|
4154
|
+
* (site-root-relative) the browser fetches from — the defaults agree
|
|
4155
|
+
* (`"_data"` ↔ `"/_data/"`).
|
|
4156
|
+
*
|
|
4157
|
+
* @example
|
|
4158
|
+
* ```ts
|
|
4159
|
+
* createPlugin("data", { config: defaultDataConfig });
|
|
4160
|
+
* ```
|
|
4161
|
+
*/
|
|
4162
|
+
const defaultDataConfig = {
|
|
4163
|
+
outputDir: "_data",
|
|
4164
|
+
baseUrl: "/_data/"
|
|
4165
|
+
};
|
|
4166
|
+
//#endregion
|
|
4167
|
+
//#region src/plugins/data/state.ts
|
|
4168
|
+
/**
|
|
4169
|
+
* Creates initial data state: a null `lastWrite` slot (populated by the Node
|
|
4170
|
+
* `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
|
|
4171
|
+
* side on first fetch).
|
|
4172
|
+
*
|
|
4173
|
+
* @param _ctx - Minimal context with global and config.
|
|
4174
|
+
* @param _ctx.global - Global framework configuration.
|
|
4175
|
+
* @param _ctx.config - Resolved plugin configuration.
|
|
4176
|
+
* @returns Fresh data state with no recorded write and an empty per-path cache.
|
|
4177
|
+
* @example
|
|
4178
|
+
* ```ts
|
|
4179
|
+
* const state = createDataState({ global: {}, config });
|
|
4180
|
+
* ```
|
|
4181
|
+
*/
|
|
4182
|
+
function createDataState(_ctx) {
|
|
4183
|
+
return {
|
|
4184
|
+
lastWrite: null,
|
|
4185
|
+
cache: /* @__PURE__ */ new Map()
|
|
4186
|
+
};
|
|
4187
|
+
}
|
|
4188
|
+
//#endregion
|
|
4189
|
+
//#region src/plugins/data/validate.ts
|
|
4190
|
+
/**
|
|
4191
|
+
* Validates the resolved data config: the browser `baseUrl` must be a non-empty,
|
|
4192
|
+
* site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
|
|
4193
|
+
*
|
|
4194
|
+
* @param config - The resolved plugin configuration.
|
|
4195
|
+
* @throws {Error} If `baseUrl` is empty or not a rooted URL path.
|
|
4196
|
+
* @example
|
|
4197
|
+
* ```ts
|
|
4198
|
+
* validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
|
|
4199
|
+
* ```
|
|
4200
|
+
*/
|
|
4201
|
+
function validateDataConfig(config) {
|
|
4202
|
+
if (typeof config.baseUrl !== "string" || !config.baseUrl.startsWith("/")) throw new Error(`[web] data.baseUrl: must be a site-root-relative URL path starting with "/" (e.g. "/_data/").`);
|
|
4203
|
+
}
|
|
4204
|
+
//#endregion
|
|
4205
|
+
//#region src/plugins/data/index.ts
|
|
4206
|
+
/**
|
|
4207
|
+
* @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
|
|
4208
|
+
* the SSG→DATA→SPA pattern.
|
|
4209
|
+
*
|
|
4210
|
+
* Owns ONE contract — `page path → persisted JSON file` — and nothing about what
|
|
4211
|
+
* the data is: `write(entries)` persists per-page JSON on Node (build supplies the
|
|
4212
|
+
* entries it already expanded); `at(path)` fetches + caches it in the browser as
|
|
4213
|
+
* `unknown`, which the route's `parse` validates before `render`. NOT a framework
|
|
4214
|
+
* default — the consumer composes it where needed (Node build AND/OR browser app).
|
|
4215
|
+
*
|
|
4216
|
+
* **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
|
|
4217
|
+
* a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
|
|
4218
|
+
* writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
|
|
4219
|
+
* No `onStart`/`onStop`.
|
|
4220
|
+
* @see README.md
|
|
4221
|
+
*/
|
|
4222
|
+
/**
|
|
4223
|
+
* Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
|
|
4224
|
+
* `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
|
|
4225
|
+
*
|
|
4226
|
+
* @example
|
|
4227
|
+
* ```ts
|
|
4228
|
+
* // Node build: `build` calls app.data.write(...) during its pages phase when
|
|
4229
|
+
* // router.mode !== "ssg". Just compose the plugin:
|
|
4230
|
+
* const app = createApp({
|
|
4231
|
+
* plugins: [dataPlugin, contentPlugin, buildPlugin],
|
|
4232
|
+
* pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
|
|
4233
|
+
* });
|
|
4234
|
+
* await app.start();
|
|
4235
|
+
* await app.build.run(); // writes HTML + per-page data sidecars
|
|
4236
|
+
*
|
|
4237
|
+
* // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
|
|
4238
|
+
* ```
|
|
4239
|
+
*/
|
|
4240
|
+
const dataPlugin = createPlugin$1("data", {
|
|
4241
|
+
config: defaultDataConfig,
|
|
4242
|
+
createState: createDataState,
|
|
4243
|
+
onInit: (ctx) => validateDataConfig(ctx.config),
|
|
4244
|
+
api: dataApi
|
|
4245
|
+
});
|
|
4246
|
+
//#endregion
|
|
3683
4247
|
//#region src/plugins/build/phases/pages.tsx
|
|
3684
4248
|
/**
|
|
3685
4249
|
* @file build phase 3 — pages. Pulls `router.manifest()` + `head.render(route, data)`
|
|
3686
4250
|
* and SSR-renders each route to static HTML (preact-render-to-string). Appends the
|
|
3687
4251
|
* build-id meta tag after `head.render()` returns. Does NOT compose `<head>` itself.
|
|
3688
4252
|
*/
|
|
4253
|
+
/** Template placeholder for the composed `<head>` inner HTML. */
|
|
4254
|
+
const HEAD_PLACEHOLDER = "<!--moku:head-->";
|
|
4255
|
+
/** Template placeholder for the SSR-rendered body HTML. */
|
|
4256
|
+
const BODY_PLACEHOLDER = "<!--moku:body-->";
|
|
4257
|
+
/** Template placeholder for the injected asset `<link>`/`<script>` tags. */
|
|
4258
|
+
const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
|
|
3689
4259
|
/**
|
|
3690
|
-
*
|
|
3691
|
-
*
|
|
4260
|
+
* Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
|
|
4261
|
+
* as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
|
|
3692
4262
|
*
|
|
3693
|
-
* @param
|
|
3694
|
-
* @param
|
|
3695
|
-
* @
|
|
3696
|
-
* @
|
|
4263
|
+
* @param ctx - Plugin context (provides `state`).
|
|
4264
|
+
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
4265
|
+
* @returns The hashed-path manifest entry, or an empty object when absent.
|
|
4266
|
+
* @example
|
|
4267
|
+
* ```ts
|
|
4268
|
+
* readManifest(ctx, "css");
|
|
4269
|
+
* ```
|
|
4270
|
+
*/
|
|
4271
|
+
function readManifest(ctx, kind) {
|
|
4272
|
+
const entry = ctx.state.buildCache.get(kind);
|
|
4273
|
+
return entry && typeof entry === "object" ? entry : {};
|
|
4274
|
+
}
|
|
4275
|
+
/**
|
|
4276
|
+
* Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
|
|
4277
|
+
* an empty string when `config.injectAssets === false`. Asset paths are emitted as
|
|
4278
|
+
* absolute (`/`-rooted) URLs.
|
|
4279
|
+
*
|
|
4280
|
+
* @param ctx - Plugin context (provides `state`, `config`).
|
|
4281
|
+
* @returns The injected asset tags, or `""` when injection is disabled.
|
|
4282
|
+
* @example
|
|
4283
|
+
* ```ts
|
|
4284
|
+
* buildAssetTags(ctx);
|
|
4285
|
+
* ```
|
|
4286
|
+
*/
|
|
4287
|
+
function buildAssetTags(ctx) {
|
|
4288
|
+
if (ctx.config.injectAssets === false) return "";
|
|
4289
|
+
const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
|
|
4290
|
+
const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
|
|
4291
|
+
return [...css, ...js].join("");
|
|
4292
|
+
}
|
|
4293
|
+
/**
|
|
4294
|
+
* Compose the full static HTML document with the in-code shell, injecting the
|
|
4295
|
+
* build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
|
|
4296
|
+
* metadata, not content) and the asset tags at the end of `<head>`.
|
|
4297
|
+
*
|
|
4298
|
+
* @param parts - The composed head/body/assets/locale pieces.
|
|
3697
4299
|
* @returns The complete HTML document string.
|
|
3698
4300
|
* @example
|
|
3699
4301
|
* ```ts
|
|
3700
|
-
* renderDocument("<title>Hi</title>", "<h1>Hi</h1>", "
|
|
4302
|
+
* renderDocument({ head: "<title>Hi</title>", body: "<h1>Hi</h1>", assets: "", locale: "en" });
|
|
4303
|
+
* ```
|
|
4304
|
+
*/
|
|
4305
|
+
function renderDocument(parts) {
|
|
4306
|
+
return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
|
|
4307
|
+
}
|
|
4308
|
+
/**
|
|
4309
|
+
* Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
|
|
4310
|
+
* `<!--moku:assets-->` placeholders deterministically at build time.
|
|
4311
|
+
*
|
|
4312
|
+
* @param template - The raw shell template HTML.
|
|
4313
|
+
* @param parts - The composed head/body/assets pieces.
|
|
4314
|
+
* @returns The filled document string.
|
|
4315
|
+
* @example
|
|
4316
|
+
* ```ts
|
|
4317
|
+
* fillTemplate(shell, { head, body, assets, locale: "en" });
|
|
3701
4318
|
* ```
|
|
3702
4319
|
*/
|
|
3703
|
-
function
|
|
3704
|
-
return
|
|
4320
|
+
function fillTemplate(template, parts) {
|
|
4321
|
+
return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
|
|
3705
4322
|
}
|
|
3706
4323
|
/**
|
|
3707
4324
|
* Expand one route definition into its concrete page instances across all
|
|
@@ -3778,18 +4395,24 @@ function adaptHeadConfig(config) {
|
|
|
3778
4395
|
return adapted;
|
|
3779
4396
|
}
|
|
3780
4397
|
/**
|
|
3781
|
-
* Render one page instance to its static HTML document and write it to disk.
|
|
4398
|
+
* Render one page instance to its static HTML document and write it to disk. Uses
|
|
4399
|
+
* the configured shell `template` (filled at build time) when supplied, otherwise
|
|
4400
|
+
* the in-code shell; injects the precomputed asset tags + build-id meta.
|
|
3782
4401
|
*
|
|
3783
4402
|
* @param ctx - Plugin context (provides `require`, `state`, `config`).
|
|
3784
4403
|
* @param instance - The concrete page instance to render.
|
|
4404
|
+
* @param shell - Wiring shared across instances (asset tags + optional template).
|
|
4405
|
+
* @param shell.assets - The injected asset `<link>`/`<script>` tags.
|
|
4406
|
+
* @param shell.template - The shell template HTML, or `null` for the in-code shell.
|
|
3785
4407
|
* @returns The instance's URL and rendered HTML (HTML reused for the root page).
|
|
3786
4408
|
* @example
|
|
3787
4409
|
* ```ts
|
|
3788
|
-
* await renderInstance(ctx, instance);
|
|
4410
|
+
* await renderInstance(ctx, instance, { assets: "", template: null });
|
|
3789
4411
|
* ```
|
|
3790
4412
|
*/
|
|
3791
|
-
async function renderInstance(ctx, instance) {
|
|
4413
|
+
async function renderInstance(ctx, instance, shell) {
|
|
3792
4414
|
const { definition, entry, params, locale, name } = instance;
|
|
4415
|
+
const hasData = definition._handlers.load !== void 0;
|
|
3793
4416
|
const data = definition._handlers.load ? await definition._handlers.load(params, locale) : void 0;
|
|
3794
4417
|
const routeContext = {
|
|
3795
4418
|
params,
|
|
@@ -3806,14 +4429,24 @@ async function renderInstance(ctx, instance) {
|
|
|
3806
4429
|
};
|
|
3807
4430
|
if (headConfig) resolved.head = adaptHeadConfig(headConfig);
|
|
3808
4431
|
const headHtml = ctx.require(headPlugin).render(resolved, data);
|
|
4432
|
+
const buildIdMeta = `<meta name="build-id" content="${ctx.state.runId ?? ""}">`;
|
|
3809
4433
|
const vnode = definition._handlers.render?.(routeContext);
|
|
3810
|
-
const
|
|
4434
|
+
const bodyHtml = vnode ? (0, preact_render_to_string.renderToString)(vnode) : "";
|
|
4435
|
+
const parts = {
|
|
4436
|
+
head: `${headHtml}${buildIdMeta}`,
|
|
4437
|
+
body: bodyHtml,
|
|
4438
|
+
assets: shell.assets,
|
|
4439
|
+
locale
|
|
4440
|
+
};
|
|
4441
|
+
const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
|
|
3811
4442
|
const filePath = (0, node_path.join)(ctx.config.outDir, entry.toFile(params));
|
|
3812
4443
|
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(filePath), { recursive: true });
|
|
3813
4444
|
await (0, node_fs_promises.writeFile)(filePath, html, "utf8");
|
|
3814
4445
|
return {
|
|
3815
4446
|
url,
|
|
3816
|
-
html
|
|
4447
|
+
html,
|
|
4448
|
+
data,
|
|
4449
|
+
hasData
|
|
3817
4450
|
};
|
|
3818
4451
|
}
|
|
3819
4452
|
/**
|
|
@@ -3831,14 +4464,56 @@ async function renderInstance(ctx, instance) {
|
|
|
3831
4464
|
* const { pageCount, rootHtml } = await renderPages(ctx);
|
|
3832
4465
|
* ```
|
|
3833
4466
|
*/
|
|
4467
|
+
/**
|
|
4468
|
+
* Enforce the data-validation contract: in `hybrid`/`spa` mode, any route that
|
|
4469
|
+
* has BOTH a `render` and a `load` (so it will be client-data-navigated) MUST
|
|
4470
|
+
* declare a `.parse()` validator — otherwise fetched JSON would reach `render`
|
|
4471
|
+
* unvalidated. Converts a runtime safety hole into a build error.
|
|
4472
|
+
*
|
|
4473
|
+
* @param manifest - The route definitions from `router.manifest()`.
|
|
4474
|
+
* @param mode - The resolved render mode.
|
|
4475
|
+
* @throws {Error} If a data-navigable route is missing `.parse()` in hybrid/spa mode.
|
|
4476
|
+
* @example
|
|
4477
|
+
* ```ts
|
|
4478
|
+
* assertDataValidators(router.manifest(), router.mode());
|
|
4479
|
+
* ```
|
|
4480
|
+
*/
|
|
4481
|
+
function assertDataValidators(manifest, mode) {
|
|
4482
|
+
if (mode === "ssg") return;
|
|
4483
|
+
for (const definition of manifest) {
|
|
4484
|
+
const { render, load, parse } = definition._handlers;
|
|
4485
|
+
if (render && load && !parse) throw new Error(`[web] build: route "${definition.pattern}" enables client data navigation (router mode "${mode}") but has no .parse() validator. Add .parse(raw => /* validate → data */) so fetched JSON is validated before render, or set router mode "ssg" to disable data navigation.`);
|
|
4486
|
+
}
|
|
4487
|
+
}
|
|
3834
4488
|
async function renderPages(ctx) {
|
|
3835
4489
|
const router = ctx.require(routerPlugin);
|
|
3836
4490
|
const manifest = router.manifest();
|
|
3837
4491
|
ctx.state.manifest = [...manifest];
|
|
4492
|
+
const mode = router.mode();
|
|
4493
|
+
assertDataValidators(manifest, mode);
|
|
3838
4494
|
const byPattern = makeEntryMap(router);
|
|
3839
4495
|
const locales = ctx.require(i18nPlugin).locales();
|
|
4496
|
+
const templatePath = ctx.config.template;
|
|
4497
|
+
const template = typeof templatePath === "string" && (0, node_fs.existsSync)(templatePath) ? await (0, node_fs_promises.readFile)(templatePath, "utf8") : null;
|
|
4498
|
+
const shell = {
|
|
4499
|
+
assets: buildAssetTags(ctx),
|
|
4500
|
+
template
|
|
4501
|
+
};
|
|
3840
4502
|
const instances = (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern)))).flat();
|
|
3841
|
-
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance)));
|
|
4503
|
+
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
|
|
4504
|
+
if (mode !== "ssg" && ctx.has("data")) {
|
|
4505
|
+
const entries = rendered.filter((page) => page.hasData).map((page) => ({
|
|
4506
|
+
path: page.url,
|
|
4507
|
+
data: page.data
|
|
4508
|
+
}));
|
|
4509
|
+
if (entries.length > 0) {
|
|
4510
|
+
const summary = await ctx.require(dataPlugin).write(entries, { outDir: ctx.config.outDir });
|
|
4511
|
+
ctx.log.debug("build:data", {
|
|
4512
|
+
files: summary.fileCount,
|
|
4513
|
+
bytes: summary.bytes
|
|
4514
|
+
});
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
3842
4517
|
const root = rendered.find((page) => page.url === "/" || page.url === "");
|
|
3843
4518
|
ctx.log.debug("build:pages", { count: rendered.length });
|
|
3844
4519
|
return {
|
|
@@ -3846,6 +4521,37 @@ async function renderPages(ctx) {
|
|
|
3846
4521
|
rootHtml: root?.html ?? null
|
|
3847
4522
|
};
|
|
3848
4523
|
}
|
|
4524
|
+
/**
|
|
4525
|
+
* Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
|
|
4526
|
+
* preserving the nested directory structure. Skips silently (returns `null`) when
|
|
4527
|
+
* the source directory does not exist.
|
|
4528
|
+
*
|
|
4529
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
4530
|
+
* @returns The copy result, or `null` when the public directory is absent.
|
|
4531
|
+
* @example
|
|
4532
|
+
* ```ts
|
|
4533
|
+
* const result = await copyPublic(ctx);
|
|
4534
|
+
* ```
|
|
4535
|
+
*/
|
|
4536
|
+
async function copyPublic(ctx) {
|
|
4537
|
+
const from = ctx.config.publicDir ?? "public";
|
|
4538
|
+
if (!(0, node_fs.existsSync)(from)) {
|
|
4539
|
+
ctx.log.debug("build:public", {
|
|
4540
|
+
skipped: true,
|
|
4541
|
+
from
|
|
4542
|
+
});
|
|
4543
|
+
return null;
|
|
4544
|
+
}
|
|
4545
|
+
await (0, node_fs_promises.cp)(from, ctx.config.outDir, { recursive: true });
|
|
4546
|
+
ctx.log.debug("build:public", {
|
|
4547
|
+
from,
|
|
4548
|
+
dest: ctx.config.outDir
|
|
4549
|
+
});
|
|
4550
|
+
return {
|
|
4551
|
+
from: node_path$1.default.normalize(from),
|
|
4552
|
+
copied: 1
|
|
4553
|
+
};
|
|
4554
|
+
}
|
|
3849
4555
|
//#endregion
|
|
3850
4556
|
//#region src/plugins/build/phases/sitemap.ts
|
|
3851
4557
|
/**
|
|
@@ -3949,6 +4655,9 @@ const PHASE_ORDER = [
|
|
|
3949
4655
|
"feeds",
|
|
3950
4656
|
"sitemap",
|
|
3951
4657
|
"og-images",
|
|
4658
|
+
"public",
|
|
4659
|
+
"not-found",
|
|
4660
|
+
"locale-redirects",
|
|
3952
4661
|
"root-index"
|
|
3953
4662
|
];
|
|
3954
4663
|
/**
|
|
@@ -3992,10 +4701,11 @@ function resetRun(ctx) {
|
|
|
3992
4701
|
ctx.state.runId = `${Date.now()}-${(0, node_crypto.randomUUID)()}`;
|
|
3993
4702
|
}
|
|
3994
4703
|
/**
|
|
3995
|
-
* Phase 4 — run feeds / sitemap / og-images
|
|
3996
|
-
*
|
|
3997
|
-
*
|
|
3998
|
-
*
|
|
4704
|
+
* Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
|
|
4705
|
+
* concurrently, each gated by its config flag (or, for `public`, the presence of the
|
|
4706
|
+
* source dir), isolated with `Promise.allSettled` so one failure does not lose the
|
|
4707
|
+
* others. A disabled output is skipped entirely — it emits NO `build:phase` boundary
|
|
4708
|
+
* (the `withPhase` wrapper is gated on the config flag, not just the phase body).
|
|
3999
4709
|
*
|
|
4000
4710
|
* @param ctx - The phase context.
|
|
4001
4711
|
* @example
|
|
@@ -4008,6 +4718,9 @@ async function runOutputs(ctx) {
|
|
|
4008
4718
|
if (ctx.config.feeds) tasks.push(withPhase(ctx, "feeds", () => generateFeeds(ctx)));
|
|
4009
4719
|
if (ctx.config.sitemap) tasks.push(withPhase(ctx, "sitemap", () => generateSitemap(ctx)));
|
|
4010
4720
|
if (ctx.config.ogImage) tasks.push(withPhase(ctx, "og-images", () => generateOgImages(ctx)));
|
|
4721
|
+
if ((0, node_fs.existsSync)(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
|
|
4722
|
+
if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
|
|
4723
|
+
if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
|
|
4011
4724
|
const settled = await Promise.allSettled(tasks);
|
|
4012
4725
|
for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
|
|
4013
4726
|
}
|
|
@@ -4150,6 +4863,9 @@ function validateFonts(og) {
|
|
|
4150
4863
|
*/
|
|
4151
4864
|
function validateConfig$1(config) {
|
|
4152
4865
|
if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$5}.outDir: must be a non-empty string.`);
|
|
4866
|
+
if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$5}.publicDir: must be a string when set.`);
|
|
4867
|
+
if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$5}.template: must be a string path when set.`);
|
|
4868
|
+
if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$5}.clientEntry: must be a string path when set.`);
|
|
4153
4869
|
if (config.ogImage) validateFonts(config.ogImage);
|
|
4154
4870
|
}
|
|
4155
4871
|
//#endregion
|
|
@@ -5117,7 +5833,7 @@ function spaEvents(register) {
|
|
|
5117
5833
|
}
|
|
5118
5834
|
//#endregion
|
|
5119
5835
|
//#region src/plugins/spa/types.ts
|
|
5120
|
-
var types_exports$
|
|
5836
|
+
var types_exports$8 = /* @__PURE__ */ require_convention.__exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
|
|
5121
5837
|
/** Allowed hook names — single source of truth for fail-fast validation. */
|
|
5122
5838
|
const COMPONENT_HOOK_NAMES = [
|
|
5123
5839
|
"onCreate",
|
|
@@ -5587,11 +6303,12 @@ function resolveClickTarget(event) {
|
|
|
5587
6303
|
* Navigation API is unavailable).
|
|
5588
6304
|
*
|
|
5589
6305
|
* @param handlers - The navigation lifecycle callbacks.
|
|
6306
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5590
6307
|
* @returns A teardown that removes the attached listeners.
|
|
5591
6308
|
* @example
|
|
5592
6309
|
* const dispose = attachHistoryFallback(handlers);
|
|
5593
6310
|
*/
|
|
5594
|
-
function attachHistoryFallback(handlers) {
|
|
6311
|
+
function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
5595
6312
|
/**
|
|
5596
6313
|
* Intercept an internal-link click and run a History-API navigation.
|
|
5597
6314
|
*
|
|
@@ -5612,7 +6329,7 @@ function attachHistoryFallback(handlers) {
|
|
|
5612
6329
|
}
|
|
5613
6330
|
saveScrollPosition(location.pathname);
|
|
5614
6331
|
history.pushState({ scrollY: 0 }, "", url.pathname);
|
|
5615
|
-
|
|
6332
|
+
navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
|
|
5616
6333
|
};
|
|
5617
6334
|
/**
|
|
5618
6335
|
* Re-run navigation on back/forward, restoring the saved scroll position.
|
|
@@ -5621,7 +6338,7 @@ function attachHistoryFallback(handlers) {
|
|
|
5621
6338
|
* globalThis.addEventListener("popstate", onPopState);
|
|
5622
6339
|
*/
|
|
5623
6340
|
const onPopState = () => {
|
|
5624
|
-
|
|
6341
|
+
navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
|
|
5625
6342
|
};
|
|
5626
6343
|
document.addEventListener("click", onClick);
|
|
5627
6344
|
globalThis.addEventListener("popstate", onPopState);
|
|
@@ -5635,11 +6352,12 @@ function attachHistoryFallback(handlers) {
|
|
|
5635
6352
|
*
|
|
5636
6353
|
* @param navigation - The Navigation API object to attach the listener to.
|
|
5637
6354
|
* @param handlers - The navigation lifecycle callbacks.
|
|
6355
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5638
6356
|
* @returns A teardown that removes the `navigate` listener.
|
|
5639
6357
|
* @example
|
|
5640
6358
|
* const dispose = attachNavigationApi(navigation, handlers);
|
|
5641
6359
|
*/
|
|
5642
|
-
function attachNavigationApi(navigation, handlers) {
|
|
6360
|
+
function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
5643
6361
|
/**
|
|
5644
6362
|
* Handle a `navigate` event: classify, then intercept with fetch-and-swap.
|
|
5645
6363
|
*
|
|
@@ -5664,7 +6382,7 @@ function attachNavigationApi(navigation, handlers) {
|
|
|
5664
6382
|
navEvent.intercept({
|
|
5665
6383
|
scroll: "manual",
|
|
5666
6384
|
handler: async () => {
|
|
5667
|
-
await
|
|
6385
|
+
await navigate(url.pathname);
|
|
5668
6386
|
if (navEvent.navigationType === "traverse") navEvent.scroll();
|
|
5669
6387
|
else window.scrollTo(0, 0);
|
|
5670
6388
|
}
|
|
@@ -5678,13 +6396,14 @@ function attachNavigationApi(navigation, handlers) {
|
|
|
5678
6396
|
* fallback. Returns a teardown removing every listener it attached.
|
|
5679
6397
|
*
|
|
5680
6398
|
* @param handlers - The navigation lifecycle callbacks the kernel supplies.
|
|
6399
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5681
6400
|
* @returns A teardown removing all attached listeners.
|
|
5682
6401
|
* @example
|
|
5683
|
-
* const dispose = attachRouter(handlers);
|
|
6402
|
+
* const dispose = attachRouter(handlers, navigate);
|
|
5684
6403
|
*/
|
|
5685
|
-
function attachRouter(handlers) {
|
|
6404
|
+
function attachRouter(handlers, navigate) {
|
|
5686
6405
|
const navigation = getNavigation();
|
|
5687
|
-
return navigation ? attachNavigationApi(navigation, handlers) : attachHistoryFallback(handlers);
|
|
6406
|
+
return navigation ? attachNavigationApi(navigation, handlers, navigate) : attachHistoryFallback(handlers, navigate);
|
|
5688
6407
|
}
|
|
5689
6408
|
//#endregion
|
|
5690
6409
|
//#region src/plugins/spa/state.ts
|
|
@@ -5803,6 +6522,20 @@ function currentLocationUrl() {
|
|
|
5803
6522
|
return location.pathname + location.search;
|
|
5804
6523
|
}
|
|
5805
6524
|
/**
|
|
6525
|
+
* Apply the matched route's `head` config to the live document (minimal client
|
|
6526
|
+
* head-sync for the DATA path: title only — the full meta sync runs on the
|
|
6527
|
+
* HTML-over-fetch path from the fetched `<head>`).
|
|
6528
|
+
*
|
|
6529
|
+
* @param route - The matched route definition.
|
|
6530
|
+
* @param routeContext - The render context (params/data/locale).
|
|
6531
|
+
* @example
|
|
6532
|
+
* syncDataHead(hit.route, { params, data, locale });
|
|
6533
|
+
*/
|
|
6534
|
+
function syncDataHead(route, routeContext) {
|
|
6535
|
+
const title = route._handlers.head?.(routeContext)?.title;
|
|
6536
|
+
if (title !== void 0 && title !== "") document.title = title;
|
|
6537
|
+
}
|
|
6538
|
+
/**
|
|
5806
6539
|
* Builds the single shared SPA kernel — a pure factory over state/config/emit.
|
|
5807
6540
|
* Unit-testable with a mock state object and a spy emit; no Moku ctx involved.
|
|
5808
6541
|
*
|
|
@@ -5866,6 +6599,71 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5866
6599
|
onEnd: handleEnd,
|
|
5867
6600
|
onError: handleError
|
|
5868
6601
|
};
|
|
6602
|
+
/**
|
|
6603
|
+
* The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
|
|
6604
|
+
* `data` reader, VALIDATE it through the route's `parse` gate, then run the
|
|
6605
|
+
* route's OWN `render` (the same component the build used for SSG) and
|
|
6606
|
+
* Preact-render the VNode into the swap region. Returns `false` (touching nothing
|
|
6607
|
+
* the fallback cares about) on no-match / no-render / no-data / fetch-miss /
|
|
6608
|
+
* parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
|
|
6609
|
+
* run on the client — the build already persisted its output.
|
|
6610
|
+
*
|
|
6611
|
+
* @param pathname - The destination pathname (search stripped for matching).
|
|
6612
|
+
* @returns `true` if the route was rendered from validated data, else `false`.
|
|
6613
|
+
* @example
|
|
6614
|
+
* if (await tryDataRender("/en/world/")) return;
|
|
6615
|
+
*/
|
|
6616
|
+
const tryDataRender = async (pathname) => {
|
|
6617
|
+
if (!deps.dataAt) return false;
|
|
6618
|
+
const matchPath = pathname.split("?")[0] ?? pathname;
|
|
6619
|
+
const hit = deps.router.match(matchPath);
|
|
6620
|
+
if (!hit?.route._handlers.render) return false;
|
|
6621
|
+
try {
|
|
6622
|
+
const raw = await deps.dataAt(pathname);
|
|
6623
|
+
if (raw === null) return false;
|
|
6624
|
+
const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
|
|
6625
|
+
const locale = hit.params.lang ?? document.documentElement.lang ?? "";
|
|
6626
|
+
const routeContext = {
|
|
6627
|
+
params: hit.params,
|
|
6628
|
+
data,
|
|
6629
|
+
locale
|
|
6630
|
+
};
|
|
6631
|
+
const vnode = hit.route._handlers.render(routeContext);
|
|
6632
|
+
const region = document.querySelector(resolved.swapSelector);
|
|
6633
|
+
if (!region) return false;
|
|
6634
|
+
handleStart(pathname);
|
|
6635
|
+
const { renderVNode } = await Promise.resolve().then(() => require("./render-BSTM0Akv.cjs"));
|
|
6636
|
+
syncDataHead(hit.route, routeContext);
|
|
6637
|
+
unmountPageSpecific(state, emit);
|
|
6638
|
+
runSwap(() => {
|
|
6639
|
+
region.replaceChildren();
|
|
6640
|
+
renderVNode(vnode, region);
|
|
6641
|
+
scanAndMount(state, emit, resolved.swapSelector);
|
|
6642
|
+
notifyNavEnd(state);
|
|
6643
|
+
}, resolved.viewTransitions);
|
|
6644
|
+
state.currentUrl = pathname;
|
|
6645
|
+
progress?.done();
|
|
6646
|
+
emit("spa:navigated", { url: pathname });
|
|
6647
|
+
return true;
|
|
6648
|
+
} catch {
|
|
6649
|
+
return false;
|
|
6650
|
+
}
|
|
6651
|
+
};
|
|
6652
|
+
/**
|
|
6653
|
+
* Unified navigation: try the client DATA path first (only when the `data`
|
|
6654
|
+
* plugin is composed), then fall back to HTML-over-fetch (which itself falls
|
|
6655
|
+
* back to a full `location.href` reload). Injected into the router so every
|
|
6656
|
+
* navigation entry point (Navigation API, History, programmatic) goes through it.
|
|
6657
|
+
*
|
|
6658
|
+
* @param pathname - The destination pathname.
|
|
6659
|
+
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
6660
|
+
* @example
|
|
6661
|
+
* await navigate("/en/world/");
|
|
6662
|
+
*/
|
|
6663
|
+
const navigate = async (pathname) => {
|
|
6664
|
+
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
|
|
6665
|
+
await performNavigation(pathname, handlers);
|
|
6666
|
+
};
|
|
5869
6667
|
return {
|
|
5870
6668
|
/**
|
|
5871
6669
|
* Register config components and seed currentUrl from the document.
|
|
@@ -5888,7 +6686,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5888
6686
|
if (state.started) throw new Error(`${ERROR_PREFIX} spa kernel already started.\n Call app.stop() before booting again (single boot per app).`);
|
|
5889
6687
|
progress = createProgressBar(resolved.progressBar);
|
|
5890
6688
|
state.currentUrl = currentLocationUrl();
|
|
5891
|
-
state.destroyRouter = attachRouter(handlers);
|
|
6689
|
+
state.destroyRouter = attachRouter(handlers, navigate);
|
|
5892
6690
|
scanAndMount(state, emit, resolved.swapSelector);
|
|
5893
6691
|
state.started = true;
|
|
5894
6692
|
},
|
|
@@ -5911,7 +6709,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5911
6709
|
*/
|
|
5912
6710
|
processNav(path) {
|
|
5913
6711
|
if (typeof document === "undefined") return;
|
|
5914
|
-
|
|
6712
|
+
navigate(path).catch(() => {});
|
|
5915
6713
|
},
|
|
5916
6714
|
/**
|
|
5917
6715
|
* Scan the swap region and mount components for matching elements.
|
|
@@ -5938,20 +6736,41 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5938
6736
|
};
|
|
5939
6737
|
}
|
|
5940
6738
|
/**
|
|
6739
|
+
* Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
|
|
6740
|
+
* a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
|
|
6741
|
+
* WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
|
|
6742
|
+
* its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
|
|
6743
|
+
*/
|
|
6744
|
+
const dataPluginHandle = {
|
|
6745
|
+
name: "data",
|
|
6746
|
+
spec: void 0,
|
|
6747
|
+
_phantom: {
|
|
6748
|
+
config: void 0,
|
|
6749
|
+
state: void 0,
|
|
6750
|
+
api: void 0,
|
|
6751
|
+
events: {}
|
|
6752
|
+
}
|
|
6753
|
+
};
|
|
6754
|
+
/**
|
|
5941
6755
|
* Builds the shared kernel from the plugin context, stores it on `ctx.state`
|
|
5942
6756
|
* and `kernelRef`, and runs its init step (validate config, register
|
|
5943
|
-
* config.components, seed currentUrl).
|
|
5944
|
-
*
|
|
6757
|
+
* config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
|
|
6758
|
+
* the `data` plugin is composed (enabling client DATA navigation).
|
|
5945
6759
|
*
|
|
5946
|
-
* @param ctx - The plugin context (state/config/emit/require/log).
|
|
6760
|
+
* @param ctx - The plugin context (state/config/emit/require/has/log).
|
|
5947
6761
|
* @example
|
|
5948
6762
|
* initSpa(ctx);
|
|
5949
6763
|
*/
|
|
5950
6764
|
function initSpa(ctx) {
|
|
5951
|
-
const
|
|
6765
|
+
const deps = {
|
|
5952
6766
|
router: ctx.require(routerPlugin),
|
|
5953
6767
|
head: ctx.require(headPlugin)
|
|
5954
|
-
}
|
|
6768
|
+
};
|
|
6769
|
+
if (ctx.has("data")) {
|
|
6770
|
+
const reader = ctx.require(dataPluginHandle);
|
|
6771
|
+
deps.dataAt = (path) => reader.at(path);
|
|
6772
|
+
}
|
|
6773
|
+
const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
|
|
5955
6774
|
ctx.state.kernel = kernel;
|
|
5956
6775
|
kernelRef.current = kernel;
|
|
5957
6776
|
kernel.init();
|
|
@@ -6048,29 +6867,182 @@ const spaPlugin = createPlugin$1("spa", {
|
|
|
6048
6867
|
});
|
|
6049
6868
|
//#endregion
|
|
6050
6869
|
//#region src/plugins/build/types.ts
|
|
6051
|
-
var types_exports = /* @__PURE__ */ __exportAll({});
|
|
6870
|
+
var types_exports = /* @__PURE__ */ require_convention.__exportAll({});
|
|
6052
6871
|
//#endregion
|
|
6053
6872
|
//#region src/plugins/content/types.ts
|
|
6054
|
-
var types_exports$1 = /* @__PURE__ */ __exportAll({});
|
|
6873
|
+
var types_exports$1 = /* @__PURE__ */ require_convention.__exportAll({});
|
|
6874
|
+
//#endregion
|
|
6875
|
+
//#region src/plugins/data/types.ts
|
|
6876
|
+
var types_exports$2 = /* @__PURE__ */ require_convention.__exportAll({});
|
|
6055
6877
|
//#endregion
|
|
6056
6878
|
//#region src/plugins/deploy/types.ts
|
|
6057
|
-
var types_exports$
|
|
6879
|
+
var types_exports$3 = /* @__PURE__ */ require_convention.__exportAll({});
|
|
6058
6880
|
//#endregion
|
|
6059
6881
|
//#region src/plugins/env/types.ts
|
|
6060
|
-
var types_exports$
|
|
6882
|
+
var types_exports$4 = /* @__PURE__ */ require_convention.__exportAll({});
|
|
6061
6883
|
//#endregion
|
|
6062
6884
|
//#region src/plugins/head/types.ts
|
|
6063
|
-
var types_exports$
|
|
6885
|
+
var types_exports$5 = /* @__PURE__ */ require_convention.__exportAll({});
|
|
6064
6886
|
//#endregion
|
|
6065
6887
|
//#region src/plugins/log/types.ts
|
|
6066
|
-
var types_exports$
|
|
6888
|
+
var types_exports$6 = /* @__PURE__ */ require_convention.__exportAll({});
|
|
6067
6889
|
//#endregion
|
|
6068
6890
|
//#region src/plugins/router/types.ts
|
|
6069
|
-
var types_exports$
|
|
6891
|
+
var types_exports$7 = /* @__PURE__ */ require_convention.__exportAll({});
|
|
6892
|
+
//#endregion
|
|
6893
|
+
//#region src/plugins/env/providers.ts
|
|
6894
|
+
/**
|
|
6895
|
+
* @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
|
|
6896
|
+
*/
|
|
6897
|
+
/** Default dotenv file path: optional local overrides. */
|
|
6898
|
+
const DEFAULT_DOTENV_PATH = ".env.local";
|
|
6899
|
+
/** Property on `globalThis` that the consumer sets per Cloudflare request. */
|
|
6900
|
+
const CLOUDFLARE_GLOBAL = "__CLOUDFLARE_ENV__";
|
|
6901
|
+
/**
|
|
6902
|
+
* Strips a single matching pair of surrounding double or single quotes from a
|
|
6903
|
+
* value. Leaves unquoted values (and trailing inline comments) untouched.
|
|
6904
|
+
*
|
|
6905
|
+
* @param value - The already-trimmed raw value.
|
|
6906
|
+
* @returns The value with one outer quote pair removed, if present.
|
|
6907
|
+
* @example
|
|
6908
|
+
* ```ts
|
|
6909
|
+
* stripQuotes('"a"'); // "a"
|
|
6910
|
+
* stripQuotes("plain # c"); // "plain # c"
|
|
6911
|
+
* ```
|
|
6912
|
+
*/
|
|
6913
|
+
function stripQuotes(value) {
|
|
6914
|
+
if (value.length >= 2) {
|
|
6915
|
+
const first = value[0];
|
|
6916
|
+
const last = value.at(-1);
|
|
6917
|
+
if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
|
|
6918
|
+
}
|
|
6919
|
+
return value;
|
|
6920
|
+
}
|
|
6921
|
+
/**
|
|
6922
|
+
* Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
|
|
6923
|
+
* full-line `#` comments, first-`=` splitting, key/value trimming, and a single
|
|
6924
|
+
* outer quote pair. Does not strip trailing inline comments on unquoted values.
|
|
6925
|
+
*
|
|
6926
|
+
* @param text - The raw file contents.
|
|
6927
|
+
* @returns A flat record of parsed key/value pairs.
|
|
6928
|
+
* @example
|
|
6929
|
+
* ```ts
|
|
6930
|
+
* parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
|
|
6931
|
+
* ```
|
|
6932
|
+
*/
|
|
6933
|
+
function parseDotenv(text) {
|
|
6934
|
+
const out = {};
|
|
6935
|
+
for (const line of text.split(/\r?\n/)) {
|
|
6936
|
+
const trimmed = line.trim();
|
|
6937
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
6938
|
+
const eq = trimmed.indexOf("=");
|
|
6939
|
+
if (eq === -1) continue;
|
|
6940
|
+
const key = trimmed.slice(0, eq).trim();
|
|
6941
|
+
out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
|
|
6942
|
+
}
|
|
6943
|
+
return out;
|
|
6944
|
+
}
|
|
6945
|
+
/**
|
|
6946
|
+
* A zero-dependency `.env`-style provider that re-reads and re-parses the file
|
|
6947
|
+
* from disk on every `load()`. Missing file resolves to `{}` (optional
|
|
6948
|
+
* overrides). Strips a single outer quote pair; does not strip trailing inline
|
|
6949
|
+
* comments on unquoted values.
|
|
6950
|
+
*
|
|
6951
|
+
* @param path - Path to the dotenv file. Defaults to `.env.local`.
|
|
6952
|
+
* @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
|
|
6953
|
+
* @example
|
|
6954
|
+
* ```ts
|
|
6955
|
+
* const provider = dotenv(".env.local");
|
|
6956
|
+
* provider.load(); // { PUBLIC_API_URL: "/api", ... }
|
|
6957
|
+
* ```
|
|
6958
|
+
*/
|
|
6959
|
+
function dotenv(path = DEFAULT_DOTENV_PATH) {
|
|
6960
|
+
return {
|
|
6961
|
+
name: `dotenv:${path}`,
|
|
6962
|
+
/**
|
|
6963
|
+
* Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
|
|
6964
|
+
*
|
|
6965
|
+
* @returns The parsed environment record, or `{}` when the file is absent.
|
|
6966
|
+
* @example
|
|
6967
|
+
* ```ts
|
|
6968
|
+
* dotenv(".env.local").load();
|
|
6969
|
+
* ```
|
|
6970
|
+
*/
|
|
6971
|
+
load() {
|
|
6972
|
+
if (!(0, node_fs.existsSync)(path)) return {};
|
|
6973
|
+
return parseDotenv((0, node_fs.readFileSync)(path, "utf8"));
|
|
6974
|
+
}
|
|
6975
|
+
};
|
|
6976
|
+
}
|
|
6977
|
+
/**
|
|
6978
|
+
* A provider that returns a shallow copy of `process.env` at `load()` time.
|
|
6979
|
+
*
|
|
6980
|
+
* @returns An {@link EnvProvider} named `process-env`.
|
|
6981
|
+
* @example
|
|
6982
|
+
* ```ts
|
|
6983
|
+
* const provider = processEnv();
|
|
6984
|
+
* provider.load().HOME; // current process value
|
|
6985
|
+
* ```
|
|
6986
|
+
*/
|
|
6987
|
+
function processEnv() {
|
|
6988
|
+
return {
|
|
6989
|
+
name: "process-env",
|
|
6990
|
+
/**
|
|
6991
|
+
* Returns a shallow copy of `process.env` at call time.
|
|
6992
|
+
*
|
|
6993
|
+
* @returns A fresh shallow copy of `process.env`.
|
|
6994
|
+
* @example
|
|
6995
|
+
* ```ts
|
|
6996
|
+
* processEnv().load();
|
|
6997
|
+
* ```
|
|
6998
|
+
*/
|
|
6999
|
+
load() {
|
|
7000
|
+
return { ...process.env };
|
|
7001
|
+
}
|
|
7002
|
+
};
|
|
7003
|
+
}
|
|
7004
|
+
/**
|
|
7005
|
+
* A provider that reads live, per-request Cloudflare bindings from
|
|
7006
|
+
* `globalThis.__CLOUDFLARE_ENV__` at `load()` time (`?? {}` when absent). Never
|
|
7007
|
+
* caches the binding object; the consumer owns the global's request lifecycle.
|
|
7008
|
+
*
|
|
7009
|
+
* @returns An {@link EnvProvider} named `cloudflare`.
|
|
7010
|
+
* @example
|
|
7011
|
+
* ```ts
|
|
7012
|
+
* globalThis.__CLOUDFLARE_ENV__ = env; // set by the request handler
|
|
7013
|
+
* const provider = cloudflareBindings();
|
|
7014
|
+
* provider.load(); // reads the current request's bindings
|
|
7015
|
+
* ```
|
|
7016
|
+
*/
|
|
7017
|
+
function cloudflareBindings() {
|
|
7018
|
+
return {
|
|
7019
|
+
name: "cloudflare",
|
|
7020
|
+
/**
|
|
7021
|
+
* Reads `globalThis.__CLOUDFLARE_ENV__` fresh, never caching the bindings.
|
|
7022
|
+
*
|
|
7023
|
+
* @returns The current Cloudflare bindings, or `{}` when the global is unset.
|
|
7024
|
+
* @example
|
|
7025
|
+
* ```ts
|
|
7026
|
+
* cloudflareBindings().load();
|
|
7027
|
+
* ```
|
|
7028
|
+
*/
|
|
7029
|
+
load() {
|
|
7030
|
+
return globalThis[CLOUDFLARE_GLOBAL] ?? {};
|
|
7031
|
+
}
|
|
7032
|
+
};
|
|
7033
|
+
}
|
|
6070
7034
|
//#endregion
|
|
6071
7035
|
//#region src/index.ts
|
|
6072
7036
|
/**
|
|
6073
7037
|
* @file `@moku-labs/web` — a Moku Layer-2 content static-site + SPA framework.
|
|
7038
|
+
*
|
|
7039
|
+
* `createApp`'s defaults are the **isomorphic** plugins that run unchanged on both
|
|
7040
|
+
* Node and the browser (`site`, `i18n`, `router`, `head`, `spa`, plus the
|
|
7041
|
+
* `log`/`env` core). The Node-only plugins (`content`, `build`, `deploy`,
|
|
7042
|
+
* `data`) are exported for Layer-3 composition: add them with
|
|
7043
|
+
* `createApp({ plugins: [...] })` in a Node build; omit them in a browser app.
|
|
7044
|
+
* The framework never hard-blocks either runtime — the consumer composes the
|
|
7045
|
+
* variant it needs and supplies the matching `env` provider.
|
|
6074
7046
|
* @see README.md
|
|
6075
7047
|
*/
|
|
6076
7048
|
const framework = createCore(coreConfig, {
|
|
@@ -6078,11 +7050,8 @@ const framework = createCore(coreConfig, {
|
|
|
6078
7050
|
sitePlugin,
|
|
6079
7051
|
i18nPlugin,
|
|
6080
7052
|
routerPlugin,
|
|
6081
|
-
contentPlugin,
|
|
6082
7053
|
headPlugin,
|
|
6083
|
-
|
|
6084
|
-
spaPlugin,
|
|
6085
|
-
deployPlugin
|
|
7054
|
+
spaPlugin
|
|
6086
7055
|
],
|
|
6087
7056
|
pluginConfigs: {}
|
|
6088
7057
|
});
|
|
@@ -6091,22 +7060,28 @@ const framework = createCore(coreConfig, {
|
|
|
6091
7060
|
* Your overrides are merged over the framework defaults through the 4-level config
|
|
6092
7061
|
* cascade, every plugin's lifecycle runs, and a fully-typed, frozen app is returned.
|
|
6093
7062
|
*
|
|
7063
|
+
* The defaults are the isomorphic plugin set (`site`, `i18n`, `router`, `head`,
|
|
7064
|
+
* `spa` + `log`/`env` core). Add the Node-only plugins for an SSG build:
|
|
7065
|
+
* `createApp({ plugins: [contentPlugin, buildPlugin, deployPlugin] })`.
|
|
7066
|
+
*
|
|
6094
7067
|
* @param options - Optional configuration:
|
|
6095
|
-
* - `pluginConfigs` — per-plugin overrides, keyed by plugin name
|
|
6096
|
-
* (`site`, `i18n`, `router`, `content`, `head`, `build`, `spa`, `deploy`, `env`).
|
|
7068
|
+
* - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
|
|
6097
7069
|
* - `config` — global framework config (e.g. `{ mode: "development" }`).
|
|
6098
|
-
* - `plugins` — extra
|
|
7070
|
+
* - `plugins` — extra plugins (Node-only built-ins or your own) merged into the app and its type.
|
|
6099
7071
|
* - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
|
|
6100
7072
|
* @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
|
|
6101
7073
|
* @example
|
|
6102
7074
|
* ```ts
|
|
7075
|
+
* // Node SSG build — add the node-only plugins:
|
|
6103
7076
|
* const app = createApp({
|
|
7077
|
+
* plugins: [contentPlugin, buildPlugin, deployPlugin],
|
|
6104
7078
|
* pluginConfigs: {
|
|
6105
7079
|
* site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes" },
|
|
6106
7080
|
* router: { routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
|
|
6107
7081
|
* }
|
|
6108
7082
|
* });
|
|
6109
7083
|
* await app.start();
|
|
7084
|
+
* await app.build.run();
|
|
6110
7085
|
* ```
|
|
6111
7086
|
*/
|
|
6112
7087
|
const createApp = framework.createApp;
|
|
@@ -6139,50 +7114,60 @@ Object.defineProperty(exports, "Content", {
|
|
|
6139
7114
|
return types_exports$1;
|
|
6140
7115
|
}
|
|
6141
7116
|
});
|
|
6142
|
-
Object.defineProperty(exports, "
|
|
7117
|
+
Object.defineProperty(exports, "Data", {
|
|
6143
7118
|
enumerable: true,
|
|
6144
7119
|
get: function() {
|
|
6145
7120
|
return types_exports$2;
|
|
6146
7121
|
}
|
|
6147
7122
|
});
|
|
6148
|
-
Object.defineProperty(exports, "
|
|
7123
|
+
Object.defineProperty(exports, "Deploy", {
|
|
6149
7124
|
enumerable: true,
|
|
6150
7125
|
get: function() {
|
|
6151
7126
|
return types_exports$3;
|
|
6152
7127
|
}
|
|
6153
7128
|
});
|
|
6154
|
-
Object.defineProperty(exports, "
|
|
7129
|
+
Object.defineProperty(exports, "Env", {
|
|
6155
7130
|
enumerable: true,
|
|
6156
7131
|
get: function() {
|
|
6157
7132
|
return types_exports$4;
|
|
6158
7133
|
}
|
|
6159
7134
|
});
|
|
6160
|
-
Object.defineProperty(exports, "
|
|
7135
|
+
Object.defineProperty(exports, "Head", {
|
|
6161
7136
|
enumerable: true,
|
|
6162
7137
|
get: function() {
|
|
6163
7138
|
return types_exports$5;
|
|
6164
7139
|
}
|
|
6165
7140
|
});
|
|
6166
|
-
Object.defineProperty(exports, "
|
|
7141
|
+
Object.defineProperty(exports, "Log", {
|
|
6167
7142
|
enumerable: true,
|
|
6168
7143
|
get: function() {
|
|
6169
7144
|
return types_exports$6;
|
|
6170
7145
|
}
|
|
6171
7146
|
});
|
|
6172
|
-
Object.defineProperty(exports, "
|
|
7147
|
+
Object.defineProperty(exports, "Router", {
|
|
6173
7148
|
enumerable: true,
|
|
6174
7149
|
get: function() {
|
|
6175
7150
|
return types_exports$7;
|
|
6176
7151
|
}
|
|
6177
7152
|
});
|
|
7153
|
+
Object.defineProperty(exports, "Spa", {
|
|
7154
|
+
enumerable: true,
|
|
7155
|
+
get: function() {
|
|
7156
|
+
return types_exports$8;
|
|
7157
|
+
}
|
|
7158
|
+
});
|
|
7159
|
+
exports.browserEnv = browserEnv;
|
|
6178
7160
|
exports.buildArticleHead = buildArticleHead;
|
|
6179
7161
|
exports.buildPlugin = buildPlugin;
|
|
6180
7162
|
exports.canonical = canonical;
|
|
7163
|
+
exports.cloudflareBindings = cloudflareBindings;
|
|
6181
7164
|
exports.contentPlugin = contentPlugin;
|
|
6182
7165
|
exports.createApp = createApp;
|
|
6183
7166
|
exports.createPlugin = createPlugin;
|
|
7167
|
+
exports.dataPlugin = dataPlugin;
|
|
6184
7168
|
exports.defineRoutes = defineRoutes;
|
|
6185
7169
|
exports.deployPlugin = deployPlugin;
|
|
7170
|
+
exports.dotenv = dotenv;
|
|
6186
7171
|
exports.envPlugin = envPlugin;
|
|
6187
7172
|
exports.feedLink = feedLink;
|
|
6188
7173
|
exports.headPlugin = headPlugin;
|
|
@@ -6192,6 +7177,7 @@ exports.jsonLd = jsonLd;
|
|
|
6192
7177
|
exports.logPlugin = logPlugin;
|
|
6193
7178
|
exports.meta = meta;
|
|
6194
7179
|
exports.og = og;
|
|
7180
|
+
exports.processEnv = processEnv;
|
|
6195
7181
|
exports.route = route;
|
|
6196
7182
|
exports.routerPlugin = routerPlugin;
|
|
6197
7183
|
exports.sitePlugin = sitePlugin;
|