@moku-labs/web 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -12
- package/dist/convention-Dr8jxG70.cjs +81 -0
- package/dist/convention-X3zLTlJ8.mjs +33 -0
- package/dist/index.cjs +1319 -321
- package/dist/index.d.cts +966 -626
- package/dist/index.d.mts +965 -625
- package/dist/index.mjs +1290 -271
- 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.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
|
|
2
|
+
import { t as dataSuffix } from "./convention-X3zLTlJ8.mjs";
|
|
2
3
|
import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
|
|
3
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import path, { dirname, join } from "node:path";
|
|
6
6
|
import matter from "gray-matter";
|
|
@@ -17,11 +17,10 @@ import remarkRehype from "remark-rehype";
|
|
|
17
17
|
import { visit } from "unist-util-visit";
|
|
18
18
|
import { defaultSchema } from "hast-util-sanitize";
|
|
19
19
|
import readingTime from "reading-time";
|
|
20
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
20
21
|
import { createHash, randomUUID } from "node:crypto";
|
|
21
22
|
import { Feed } from "feed";
|
|
22
|
-
import {
|
|
23
|
-
import satori from "satori";
|
|
24
|
-
import { jsx } from "preact/jsx-runtime";
|
|
23
|
+
import { h } from "preact";
|
|
25
24
|
import { renderToString } from "preact-render-to-string";
|
|
26
25
|
//#region src/plugins/env/api.ts
|
|
27
26
|
/** Error prefix for all env API failures. */
|
|
@@ -250,112 +249,45 @@ function validateSchema(ctx) {
|
|
|
250
249
|
freezeMap(state.publicMap);
|
|
251
250
|
}
|
|
252
251
|
//#endregion
|
|
253
|
-
//#region src/plugins/env/providers.ts
|
|
254
|
-
/**
|
|
255
|
-
|
|
256
|
-
*/
|
|
257
|
-
/** Default dotenv file path: optional local overrides. */
|
|
258
|
-
const DEFAULT_DOTENV_PATH = ".env.local";
|
|
259
|
-
/**
|
|
260
|
-
* Strips a single matching pair of surrounding double or single quotes from a
|
|
261
|
-
* value. Leaves unquoted values (and trailing inline comments) untouched.
|
|
262
|
-
*
|
|
263
|
-
* @param value - The already-trimmed raw value.
|
|
264
|
-
* @returns The value with one outer quote pair removed, if present.
|
|
265
|
-
* @example
|
|
266
|
-
* ```ts
|
|
267
|
-
* stripQuotes('"a"'); // "a"
|
|
268
|
-
* stripQuotes("plain # c"); // "plain # c"
|
|
269
|
-
* ```
|
|
270
|
-
*/
|
|
271
|
-
function stripQuotes(value) {
|
|
272
|
-
if (value.length >= 2) {
|
|
273
|
-
const first = value[0];
|
|
274
|
-
const last = value.at(-1);
|
|
275
|
-
if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
|
|
276
|
-
}
|
|
277
|
-
return value;
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
|
|
281
|
-
* full-line `#` comments, first-`=` splitting, key/value trimming, and a single
|
|
282
|
-
* outer quote pair. Does not strip trailing inline comments on unquoted values.
|
|
283
|
-
*
|
|
284
|
-
* @param text - The raw file contents.
|
|
285
|
-
* @returns A flat record of parsed key/value pairs.
|
|
286
|
-
* @example
|
|
287
|
-
* ```ts
|
|
288
|
-
* parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
|
|
289
|
-
* ```
|
|
290
|
-
*/
|
|
291
|
-
function parseDotenv(text) {
|
|
292
|
-
const out = {};
|
|
293
|
-
for (const line of text.split(/\r?\n/)) {
|
|
294
|
-
const trimmed = line.trim();
|
|
295
|
-
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
296
|
-
const eq = trimmed.indexOf("=");
|
|
297
|
-
if (eq === -1) continue;
|
|
298
|
-
const key = trimmed.slice(0, eq).trim();
|
|
299
|
-
out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
|
|
300
|
-
}
|
|
301
|
-
return out;
|
|
302
|
-
}
|
|
252
|
+
//#region src/plugins/env/providers.browser.ts
|
|
253
|
+
/** Default `globalThis` property holding a runtime-injected public-env snapshot. */
|
|
254
|
+
const DEFAULT_GLOBAL_KEY = "__ENV__";
|
|
303
255
|
/**
|
|
304
|
-
* A
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
256
|
+
* A browser-safe {@link EnvProvider} that reads `import.meta.env` and an optional
|
|
257
|
+
* `globalThis[globalKey]` snapshot, merging them with the runtime global winning.
|
|
258
|
+
* Contains zero `node:*` imports, so it is safe to include in the client bundle.
|
|
259
|
+
* Never throws on missing sources — each absent source resolves to `{}`.
|
|
308
260
|
*
|
|
309
|
-
* @param
|
|
310
|
-
* @
|
|
261
|
+
* @param options - Optional settings.
|
|
262
|
+
* @param options.globalKey - `globalThis` key to read a public-env snapshot from. Defaults to `"__ENV__"`.
|
|
263
|
+
* @returns An {@link EnvProvider} named `browser-env`.
|
|
311
264
|
* @example
|
|
312
265
|
* ```ts
|
|
313
|
-
* const provider =
|
|
266
|
+
* const provider = browserEnv();
|
|
314
267
|
* provider.load(); // { PUBLIC_API_URL: "/api", ... }
|
|
315
268
|
* ```
|
|
316
269
|
*/
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
name: `dotenv:${path}`,
|
|
320
|
-
/**
|
|
321
|
-
* Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
|
|
322
|
-
*
|
|
323
|
-
* @returns The parsed environment record, or `{}` when the file is absent.
|
|
324
|
-
* @example
|
|
325
|
-
* ```ts
|
|
326
|
-
* dotenv(".env.local").load();
|
|
327
|
-
* ```
|
|
328
|
-
*/
|
|
329
|
-
load() {
|
|
330
|
-
if (!existsSync(path)) return {};
|
|
331
|
-
return parseDotenv(readFileSync(path, "utf8"));
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* A provider that returns a shallow copy of `process.env` at `load()` time.
|
|
337
|
-
*
|
|
338
|
-
* @returns An {@link EnvProvider} named `process-env`.
|
|
339
|
-
* @example
|
|
340
|
-
* ```ts
|
|
341
|
-
* const provider = processEnv();
|
|
342
|
-
* provider.load().HOME; // current process value
|
|
343
|
-
* ```
|
|
344
|
-
*/
|
|
345
|
-
function processEnv() {
|
|
270
|
+
function browserEnv(options) {
|
|
271
|
+
const globalKey = options?.globalKey ?? DEFAULT_GLOBAL_KEY;
|
|
346
272
|
return {
|
|
347
|
-
name: "
|
|
273
|
+
name: "browser-env",
|
|
348
274
|
/**
|
|
349
|
-
*
|
|
275
|
+
* Merges `import.meta.env` with `globalThis[globalKey]`, the runtime global
|
|
276
|
+
* winning. Each absent source resolves to `{}`; never throws.
|
|
350
277
|
*
|
|
351
|
-
* @returns
|
|
278
|
+
* @returns The merged environment record.
|
|
352
279
|
* @example
|
|
353
280
|
* ```ts
|
|
354
|
-
*
|
|
281
|
+
* browserEnv().load();
|
|
355
282
|
* ```
|
|
356
283
|
*/
|
|
357
284
|
load() {
|
|
358
|
-
|
|
285
|
+
const importEnv = import.meta.env ?? {};
|
|
286
|
+
const globalObject = globalThis[globalKey] ?? {};
|
|
287
|
+
return {
|
|
288
|
+
...importEnv,
|
|
289
|
+
...globalObject
|
|
290
|
+
};
|
|
359
291
|
}
|
|
360
292
|
};
|
|
361
293
|
}
|
|
@@ -801,10 +733,7 @@ const logPlugin = createCorePlugin("log", {
|
|
|
801
733
|
const coreConfig = createCoreConfig("web", {
|
|
802
734
|
config: { mode: "production" },
|
|
803
735
|
plugins: [logPlugin, envPlugin],
|
|
804
|
-
pluginConfigs: {
|
|
805
|
-
log: { mode: "production" },
|
|
806
|
-
env: { providers: [dotenv(), processEnv()] }
|
|
807
|
-
}
|
|
736
|
+
pluginConfigs: { log: { mode: "production" } }
|
|
808
737
|
});
|
|
809
738
|
/**
|
|
810
739
|
* Create a custom plugin bound to this framework's `Config`/`Events` and the core
|
|
@@ -1373,6 +1302,23 @@ function articleToUrl(locale, slug) {
|
|
|
1373
1302
|
return `/${locale}/${slug}/`;
|
|
1374
1303
|
}
|
|
1375
1304
|
/**
|
|
1305
|
+
* Build the canonical "article not found" error for {@link createContentApi.load}.
|
|
1306
|
+
* Centralised so the null-resolve path and the production draft-suppression path
|
|
1307
|
+
* throw an IDENTICAL message — drafts must be indistinguishable from missing
|
|
1308
|
+
* articles in production (no new error shape).
|
|
1309
|
+
*
|
|
1310
|
+
* @param slug - Article directory name.
|
|
1311
|
+
* @param locale - Requested locale code.
|
|
1312
|
+
* @returns The not-found Error to throw.
|
|
1313
|
+
* @example
|
|
1314
|
+
* ```ts
|
|
1315
|
+
* throw articleNotFound("intro", "uk");
|
|
1316
|
+
* ```
|
|
1317
|
+
*/
|
|
1318
|
+
function articleNotFound(slug, locale) {
|
|
1319
|
+
return /* @__PURE__ */ new Error(`[web] content article "${slug}" not found for locale "${locale}".\n Looked for ${slug}/${locale}.md and the default-locale fallback.`);
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1376
1322
|
* Plugin `api` factory: assembles the kernel-free {@link ContentApiContext} from
|
|
1377
1323
|
* the plugin context (resolving i18n via `ctx.require`) and delegates to
|
|
1378
1324
|
* {@link createContentApi}. Referenced directly as the plugin's `api` so
|
|
@@ -1606,11 +1552,16 @@ function createContentApi(ctx) {
|
|
|
1606
1552
|
/**
|
|
1607
1553
|
* Resolve and render a single article for one locale with locale fallback.
|
|
1608
1554
|
* Throws a `[web] content` error when neither the requested nor the
|
|
1609
|
-
* default-locale file exists.
|
|
1555
|
+
* default-locale file exists. In production a `draft` article is suppressed
|
|
1556
|
+
* and throws the SAME not-found error (drafts must be indistinguishable from
|
|
1557
|
+
* missing articles so unpublished content is never disclosed); in
|
|
1558
|
+
* development drafts load normally.
|
|
1610
1559
|
*
|
|
1611
1560
|
* @param slug - Article directory name.
|
|
1612
1561
|
* @param locale - Requested locale code.
|
|
1613
1562
|
* @returns The resolved Article.
|
|
1563
|
+
* @throws {Error} `[web] content` not-found when no file matches, or when the
|
|
1564
|
+
* resolved article is a draft and `global.mode === "production"`.
|
|
1614
1565
|
* @example
|
|
1615
1566
|
* ```ts
|
|
1616
1567
|
* const article = await api.load("intro", "uk");
|
|
@@ -1618,7 +1569,8 @@ function createContentApi(ctx) {
|
|
|
1618
1569
|
*/
|
|
1619
1570
|
async load(slug, locale) {
|
|
1620
1571
|
const article = await resolveArticle(ctx, slug, locale);
|
|
1621
|
-
if (article === null) throw
|
|
1572
|
+
if (article === null) throw articleNotFound(slug, locale);
|
|
1573
|
+
if (ctx.global.mode === "production" && article.computed.status === "draft") throw articleNotFound(slug, locale);
|
|
1622
1574
|
const cache = ctx.state.articles.get(locale) ?? /* @__PURE__ */ new Map();
|
|
1623
1575
|
cache.set(slug, article);
|
|
1624
1576
|
ctx.state.articles.set(locale, cache);
|
|
@@ -2096,6 +2048,24 @@ function toTypedRoute(entry) {
|
|
|
2096
2048
|
};
|
|
2097
2049
|
}
|
|
2098
2050
|
/**
|
|
2051
|
+
* Project a compiled route into the serializable {@link ClientRoute} view: only
|
|
2052
|
+
* `pattern` / `name` / `meta`, with a fresh `meta` copy and NO `_handlers` closures.
|
|
2053
|
+
*
|
|
2054
|
+
* @param entry - The compiled route entry.
|
|
2055
|
+
* @returns A `ClientRoute` carrying only JSON-serializable fields.
|
|
2056
|
+
* @example
|
|
2057
|
+
* ```ts
|
|
2058
|
+
* toClientRoute(compiledEntry); // { pattern, name, meta }
|
|
2059
|
+
* ```
|
|
2060
|
+
*/
|
|
2061
|
+
function toClientRoute(entry) {
|
|
2062
|
+
return {
|
|
2063
|
+
pattern: entry.pattern,
|
|
2064
|
+
name: entry.name,
|
|
2065
|
+
meta: { ...entry.meta }
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2099
2069
|
* Creates the router plugin API surface. Every closure reads the compiled table
|
|
2100
2070
|
* from `ctx.state` and returns values/fresh copies — never the raw state arrays.
|
|
2101
2071
|
*
|
|
@@ -2165,11 +2135,113 @@ function createApi$4(ctx) {
|
|
|
2165
2135
|
*/
|
|
2166
2136
|
manifest() {
|
|
2167
2137
|
return [...readTable(state).byName.values()].map((entry) => entry.definition);
|
|
2138
|
+
},
|
|
2139
|
+
/**
|
|
2140
|
+
* Serializable, specificity-sorted projection of the route table for client
|
|
2141
|
+
* shipping — `{ pattern, name, meta }` entries with NO `_handlers` closures.
|
|
2142
|
+
*
|
|
2143
|
+
* @returns A fresh, frozen, specificity-sorted read-only array of client routes.
|
|
2144
|
+
* @example
|
|
2145
|
+
* ```ts
|
|
2146
|
+
* const json = JSON.stringify(api.clientManifest());
|
|
2147
|
+
* ```
|
|
2148
|
+
*/
|
|
2149
|
+
clientManifest() {
|
|
2150
|
+
return Object.freeze(readTable(state).compiled.map((entry) => toClientRoute(entry)));
|
|
2151
|
+
},
|
|
2152
|
+
/**
|
|
2153
|
+
* The resolved render mode (single source of truth for static/hybrid/spa).
|
|
2154
|
+
*
|
|
2155
|
+
* @returns `"ssg" | "spa" | "hybrid"`.
|
|
2156
|
+
* @example
|
|
2157
|
+
* ```ts
|
|
2158
|
+
* if (api.mode() !== "ssg") emitClientData();
|
|
2159
|
+
* ```
|
|
2160
|
+
*/
|
|
2161
|
+
mode() {
|
|
2162
|
+
return state.mode;
|
|
2168
2163
|
}
|
|
2169
2164
|
};
|
|
2170
2165
|
}
|
|
2171
2166
|
//#endregion
|
|
2167
|
+
//#region src/plugins/router/iso-match.ts
|
|
2168
|
+
/**
|
|
2169
|
+
* Parse a single path segment into its `{…}` placeholder, or `false` for a static
|
|
2170
|
+
* segment. Plain loop over the brace delimiters (no backtracking regex). Shared by
|
|
2171
|
+
* the build-time compiler and this isomorphic matcher so the two never diverge.
|
|
2172
|
+
*
|
|
2173
|
+
* @param segment - One `/`-delimited segment, e.g. `{slug}` or `about`.
|
|
2174
|
+
* @returns The parsed placeholder, or `false` when the segment is static.
|
|
2175
|
+
* @example
|
|
2176
|
+
* ```ts
|
|
2177
|
+
* parsePlaceholder("{slug:?}"); // { name: "slug", optional: true }
|
|
2178
|
+
* ```
|
|
2179
|
+
*/
|
|
2180
|
+
function parsePlaceholder$1(segment) {
|
|
2181
|
+
if (!segment.startsWith("{") || !segment.endsWith("}")) return false;
|
|
2182
|
+
const inner = segment.slice(1, -1);
|
|
2183
|
+
if (inner.endsWith(":?")) return {
|
|
2184
|
+
name: inner.slice(0, -2),
|
|
2185
|
+
optional: true
|
|
2186
|
+
};
|
|
2187
|
+
return {
|
|
2188
|
+
name: inner,
|
|
2189
|
+
optional: false
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Counts the dynamic (`{param}` / `{param:?}` / `:param`) segments in a route
|
|
2194
|
+
* pattern — fewer dynamic segments rank as more specific. The optional `{lang:?}`
|
|
2195
|
+
* segment is excluded so locale-prefixing does not affect priority (identical to
|
|
2196
|
+
* the build-time compiler's count, which sourced this logic).
|
|
2197
|
+
*
|
|
2198
|
+
* @param pattern - The route pattern string.
|
|
2199
|
+
* @returns The number of dynamic (non-lang) segments.
|
|
2200
|
+
* @example
|
|
2201
|
+
* ```ts
|
|
2202
|
+
* dynamicSegmentCount("/blog/{slug}/"); // 1
|
|
2203
|
+
* dynamicSegmentCount("/{lang:?}/{slug}/"); // 1
|
|
2204
|
+
* ```
|
|
2205
|
+
*/
|
|
2206
|
+
function dynamicSegmentCount(pattern) {
|
|
2207
|
+
let count = 0;
|
|
2208
|
+
for (const segment of pattern.split("/")) {
|
|
2209
|
+
const placeholder = parsePlaceholder$1(segment);
|
|
2210
|
+
const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
|
|
2211
|
+
const isColonDynamic = !placeholder && segment.startsWith(":");
|
|
2212
|
+
if (isBraceDynamic || isColonDynamic) count += 1;
|
|
2213
|
+
}
|
|
2214
|
+
return count;
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Comparator that orders two routes most-specific-first (fewest dynamic segments
|
|
2218
|
+
* first). Equal specificity yields `0` so a stable sort preserves declaration
|
|
2219
|
+
* order — the exact ordering the compiled matcher table uses, guaranteeing
|
|
2220
|
+
* build-time and client-time route resolution can never diverge.
|
|
2221
|
+
*
|
|
2222
|
+
* @param a - First route (carries its `pattern` string).
|
|
2223
|
+
* @param a.pattern - First route's pattern string.
|
|
2224
|
+
* @param b - Second route (carries its `pattern` string).
|
|
2225
|
+
* @param b.pattern - Second route's pattern string.
|
|
2226
|
+
* @returns Negative if `a` is more specific, positive if `b` is, `0` on a tie.
|
|
2227
|
+
* @example
|
|
2228
|
+
* ```ts
|
|
2229
|
+
* routes.toSorted(bySpecificity);
|
|
2230
|
+
* ```
|
|
2231
|
+
*/
|
|
2232
|
+
function bySpecificity(a, b) {
|
|
2233
|
+
return dynamicSegmentCount(a.pattern) - dynamicSegmentCount(b.pattern);
|
|
2234
|
+
}
|
|
2235
|
+
//#endregion
|
|
2172
2236
|
//#region src/plugins/router/builders/compile.ts
|
|
2237
|
+
/**
|
|
2238
|
+
* @file router plugin — compilation + validation domain.
|
|
2239
|
+
*
|
|
2240
|
+
* Pure functions invoked from `onInit`: validate the route map, then compile each
|
|
2241
|
+
* route into URLPattern matchers + URL/file builders, count dynamic segments,
|
|
2242
|
+
* sort by specificity, and assemble the immutable `MatcherTable`. Receives DATA
|
|
2243
|
+
* only (`CompileInput`) — never the plugin ctx.
|
|
2244
|
+
*/
|
|
2173
2245
|
/** Shared `[web]` error prefix for router validation failures. */
|
|
2174
2246
|
const ERROR_PREFIX$8 = "[web] router";
|
|
2175
2247
|
/**
|
|
@@ -2284,27 +2356,6 @@ function buildFilePath(pattern, params) {
|
|
|
2284
2356
|
return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
|
|
2285
2357
|
}
|
|
2286
2358
|
/**
|
|
2287
|
-
* Count dynamic segments in a pattern (lower = more specific). The optional
|
|
2288
|
-
* `{lang:?}` segment is excluded so locale-prefixing does not affect priority.
|
|
2289
|
-
*
|
|
2290
|
-
* @param pattern - The route pattern.
|
|
2291
|
-
* @returns The number of dynamic (non-lang) segments.
|
|
2292
|
-
* @example
|
|
2293
|
-
* ```ts
|
|
2294
|
-
* countDynamicSegments("/{lang:?}/{slug}/"); // 1
|
|
2295
|
-
* ```
|
|
2296
|
-
*/
|
|
2297
|
-
function countDynamicSegments(pattern) {
|
|
2298
|
-
let count = 0;
|
|
2299
|
-
for (const segment of pattern.split("/")) {
|
|
2300
|
-
const placeholder = parsePlaceholder(segment);
|
|
2301
|
-
const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
|
|
2302
|
-
const isColonDynamic = !placeholder && segment.startsWith(":");
|
|
2303
|
-
if (isBraceDynamic || isColonDynamic) count += 1;
|
|
2304
|
-
}
|
|
2305
|
-
return count;
|
|
2306
|
-
}
|
|
2307
|
-
/**
|
|
2308
2359
|
* Compile a single route definition into its `CompiledRoute` entry.
|
|
2309
2360
|
*
|
|
2310
2361
|
* @param name - The route name key.
|
|
@@ -2326,7 +2377,7 @@ function compileRoute(name, definition, input) {
|
|
|
2326
2377
|
return {
|
|
2327
2378
|
name,
|
|
2328
2379
|
pattern,
|
|
2329
|
-
dynamicSegmentCount:
|
|
2380
|
+
dynamicSegmentCount: dynamicSegmentCount(pattern),
|
|
2330
2381
|
matchers,
|
|
2331
2382
|
matchFn: createMatchFunction(matchers, input.defaultLocale),
|
|
2332
2383
|
/**
|
|
@@ -2383,10 +2434,7 @@ function compileRoutes(input) {
|
|
|
2383
2434
|
byName.set(name, entry);
|
|
2384
2435
|
}
|
|
2385
2436
|
return {
|
|
2386
|
-
compiled: declarationOrder.
|
|
2387
|
-
entry,
|
|
2388
|
-
index
|
|
2389
|
-
})).toSorted((a, b) => a.entry.dynamicSegmentCount === b.entry.dynamicSegmentCount ? a.index - b.index : a.entry.dynamicSegmentCount - b.entry.dynamicSegmentCount).map((wrapped) => wrapped.entry),
|
|
2437
|
+
compiled: declarationOrder.toSorted(bySpecificity),
|
|
2390
2438
|
byName
|
|
2391
2439
|
};
|
|
2392
2440
|
}
|
|
@@ -2473,13 +2521,20 @@ function route(pattern) {
|
|
|
2473
2521
|
return set("load", loader);
|
|
2474
2522
|
},
|
|
2475
2523
|
/**
|
|
2476
|
-
* Attach a layout wrapper
|
|
2524
|
+
* Attach a ctx-aware layout wrapper that frames the page in persistent chrome.
|
|
2525
|
+
* The wrapper receives the route's `LayoutContext` (render context + `.meta()`
|
|
2526
|
+
* bag) and the page children. Applied in the SSG render path only — client
|
|
2527
|
+
* navigation keeps the chrome and swaps just the inner region.
|
|
2477
2528
|
*
|
|
2478
|
-
* @param component - The layout component
|
|
2529
|
+
* @param component - The layout component `(ctx, children) => VNode`.
|
|
2479
2530
|
* @returns The same builder for chaining.
|
|
2480
2531
|
* @example
|
|
2481
2532
|
* ```ts
|
|
2482
|
-
* route("/")
|
|
2533
|
+
* route("/")
|
|
2534
|
+
* .meta({ activeTab: "home" })
|
|
2535
|
+
* .layout((ctx, children) => (
|
|
2536
|
+
* <Shell locale={ctx.locale} active={ctx.meta.activeTab}>{children}</Shell>
|
|
2537
|
+
* ));
|
|
2483
2538
|
* ```
|
|
2484
2539
|
*/
|
|
2485
2540
|
layout(component) {
|
|
@@ -2499,6 +2554,21 @@ function route(pattern) {
|
|
|
2499
2554
|
return set("render", handler);
|
|
2500
2555
|
},
|
|
2501
2556
|
/**
|
|
2557
|
+
* Attach the client-side validation gate (raw `unknown` → this route's data
|
|
2558
|
+
* type). Runs at the trust boundary before `render` on the client; throw to
|
|
2559
|
+
* reject malformed data (spa falls back to HTML-over-fetch).
|
|
2560
|
+
*
|
|
2561
|
+
* @param handler - The validator/parser.
|
|
2562
|
+
* @returns The same builder for chaining.
|
|
2563
|
+
* @example
|
|
2564
|
+
* ```ts
|
|
2565
|
+
* route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
|
|
2566
|
+
* ```
|
|
2567
|
+
*/
|
|
2568
|
+
parse(handler) {
|
|
2569
|
+
return set("parse", handler);
|
|
2570
|
+
},
|
|
2571
|
+
/**
|
|
2502
2572
|
* Attach the head/SEO handler.
|
|
2503
2573
|
*
|
|
2504
2574
|
* @param handler - The head handler.
|
|
@@ -2525,9 +2595,11 @@ function route(pattern) {
|
|
|
2525
2595
|
return set("generate", handler);
|
|
2526
2596
|
},
|
|
2527
2597
|
/**
|
|
2528
|
-
* Merge an arbitrary metadata bag into the route's `_meta`.
|
|
2598
|
+
* Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
|
|
2599
|
+
* JSON-serializable — it is projected verbatim into `clientManifest()` and
|
|
2600
|
+
* shipped to the browser, so functions/symbols/class instances are unsupported.
|
|
2529
2601
|
*
|
|
2530
|
-
* @param meta -
|
|
2602
|
+
* @param meta - JSON-serializable metadata to merge.
|
|
2531
2603
|
* @returns The same builder for chaining.
|
|
2532
2604
|
* @example
|
|
2533
2605
|
* ```ts
|
|
@@ -2597,7 +2669,10 @@ function defineRoutes(routes) {
|
|
|
2597
2669
|
* ```
|
|
2598
2670
|
*/
|
|
2599
2671
|
function createState$4(_ctx) {
|
|
2600
|
-
return {
|
|
2672
|
+
return {
|
|
2673
|
+
table: null,
|
|
2674
|
+
mode: _ctx.config.mode ?? "hybrid"
|
|
2675
|
+
};
|
|
2601
2676
|
}
|
|
2602
2677
|
/**
|
|
2603
2678
|
* Router plugin — typed, named route definitions with locale-aware URL generation
|
|
@@ -3215,6 +3290,29 @@ function resolveEntrypoints(candidates) {
|
|
|
3215
3290
|
return [];
|
|
3216
3291
|
}
|
|
3217
3292
|
/**
|
|
3293
|
+
* Resolve the authoritative JS client entrypoint (#8): when `config.clientEntry` is
|
|
3294
|
+
* set, use it directly (the authoritative override); otherwise fall back to the
|
|
3295
|
+
* conventional candidate scan. When neither yields an entry, `ctx.log.warn` (no
|
|
3296
|
+
* client bundle is produced) and an empty list is returned.
|
|
3297
|
+
*
|
|
3298
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
3299
|
+
* @returns The resolved JS entrypoint list (possibly empty).
|
|
3300
|
+
* @example
|
|
3301
|
+
* ```ts
|
|
3302
|
+
* resolveJsEntrypoints(ctx);
|
|
3303
|
+
* ```
|
|
3304
|
+
*/
|
|
3305
|
+
function resolveJsEntrypoints(ctx) {
|
|
3306
|
+
const { clientEntry } = ctx.config;
|
|
3307
|
+
if (typeof clientEntry === "string" && clientEntry.length > 0) return [clientEntry];
|
|
3308
|
+
const scanned = resolveEntrypoints(JS_ENTRY_CANDIDATES);
|
|
3309
|
+
if (scanned.length === 0) ctx.log.warn("build:bundle", {
|
|
3310
|
+
clientEntry: "none",
|
|
3311
|
+
scanned: JS_ENTRY_CANDIDATES
|
|
3312
|
+
});
|
|
3313
|
+
return scanned;
|
|
3314
|
+
}
|
|
3315
|
+
/**
|
|
3218
3316
|
* Run one bundler pass for a single asset kind and record the hashed output
|
|
3219
3317
|
* paths under `state.buildCache` keyed by the original entry basename.
|
|
3220
3318
|
*
|
|
@@ -3262,7 +3360,7 @@ async function bundle(ctx, options = {}) {
|
|
|
3262
3360
|
const runner = options.runner ?? defaultRunner;
|
|
3263
3361
|
const { minify, outDir } = ctx.config;
|
|
3264
3362
|
const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
|
|
3265
|
-
const jsEntrypoints = options.jsEntrypoints ??
|
|
3363
|
+
const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
|
|
3266
3364
|
await runOne(ctx, runner, "css", cssEntrypoints, path.join(outDir, "assets"), minify);
|
|
3267
3365
|
await runOne(ctx, runner, "js", jsEntrypoints, path.join(outDir, "assets"), minify);
|
|
3268
3366
|
}
|
|
@@ -3429,119 +3527,370 @@ async function processImages(ctx, options = {}) {
|
|
|
3429
3527
|
return copied;
|
|
3430
3528
|
}
|
|
3431
3529
|
//#endregion
|
|
3432
|
-
//#region src/plugins/build/phases/
|
|
3530
|
+
//#region src/plugins/build/phases/locale-redirects.ts
|
|
3433
3531
|
/**
|
|
3434
|
-
* @file build phase
|
|
3435
|
-
*
|
|
3436
|
-
*
|
|
3437
|
-
*
|
|
3532
|
+
* @file build phase — locale-redirects. For each non-prefixed route path, emits a
|
|
3533
|
+
* redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
|
|
3534
|
+
* bare path that points at the default-locale-prefixed URL. Deliberately does NOT
|
|
3535
|
+
* emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
|
|
3536
|
+
* `config.localeRedirects` (false/unset disables).
|
|
3438
3537
|
*/
|
|
3439
|
-
/** Default OG image dimensions when `size` is omitted. */
|
|
3440
|
-
const DEFAULT_SIZE = {
|
|
3441
|
-
width: 1200,
|
|
3442
|
-
height: 630
|
|
3443
|
-
};
|
|
3444
|
-
/** Recognized font file extensions. */
|
|
3445
|
-
const FONT_EXTENSIONS$1 = [
|
|
3446
|
-
".ttf",
|
|
3447
|
-
".otf",
|
|
3448
|
-
".woff"
|
|
3449
|
-
];
|
|
3450
3538
|
/**
|
|
3451
|
-
*
|
|
3539
|
+
* Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
|
|
3452
3540
|
*
|
|
3453
|
-
* @param
|
|
3454
|
-
* @
|
|
3455
|
-
* @param size - The output dimensions.
|
|
3456
|
-
* @returns The hex-encoded SHA-256 digest.
|
|
3541
|
+
* @param target - The default-locale-prefixed URL to redirect to.
|
|
3542
|
+
* @returns The complete redirect HTML document string.
|
|
3457
3543
|
* @example
|
|
3458
3544
|
* ```ts
|
|
3459
|
-
*
|
|
3545
|
+
* redirectHtml("/en/about/");
|
|
3460
3546
|
* ```
|
|
3461
3547
|
*/
|
|
3462
|
-
function
|
|
3463
|
-
return
|
|
3548
|
+
function redirectHtml(target) {
|
|
3549
|
+
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>`;
|
|
3464
3550
|
}
|
|
3465
3551
|
/**
|
|
3466
|
-
*
|
|
3552
|
+
* Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
|
|
3553
|
+
* shared stable key); routes without a compiled entry are skipped.
|
|
3467
3554
|
*
|
|
3468
|
-
* @param
|
|
3469
|
-
* @returns
|
|
3555
|
+
* @param router - The router API exposing `manifest` + `entries`.
|
|
3556
|
+
* @returns Pairs of `[definition, entry]` for every correlated route.
|
|
3470
3557
|
* @example
|
|
3471
3558
|
* ```ts
|
|
3472
|
-
*
|
|
3559
|
+
* pairRoutes(router);
|
|
3473
3560
|
* ```
|
|
3474
3561
|
*/
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
const
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
}
|
|
3562
|
+
function pairRoutes(router) {
|
|
3563
|
+
const byPattern = /* @__PURE__ */ new Map();
|
|
3564
|
+
for (const entry of router.entries()) byPattern.set(entry.pattern, entry);
|
|
3565
|
+
const pairs = [];
|
|
3566
|
+
for (const definition of router.manifest()) {
|
|
3567
|
+
const entry = byPattern.get(definition.pattern);
|
|
3568
|
+
if (entry) pairs.push([definition, entry]);
|
|
3569
|
+
}
|
|
3570
|
+
return pairs;
|
|
3483
3571
|
}
|
|
3484
3572
|
/**
|
|
3485
|
-
*
|
|
3573
|
+
* Expand one route into bare→default redirect jobs for the default locale. Uses
|
|
3574
|
+
* `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
|
|
3575
|
+
* only when the bare file path differs from the default-locale URL (i.e. the route
|
|
3576
|
+
* is locale-prefixed) — otherwise no redirect is needed.
|
|
3486
3577
|
*
|
|
3487
|
-
* @param
|
|
3488
|
-
* @param
|
|
3489
|
-
* @
|
|
3578
|
+
* @param definition - The route definition (carries `generate`).
|
|
3579
|
+
* @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
|
|
3580
|
+
* @param defaultLocale - The default locale to redirect bare paths to.
|
|
3581
|
+
* @returns Redirect jobs of `{ file, target }` for this route.
|
|
3490
3582
|
* @example
|
|
3491
3583
|
* ```ts
|
|
3492
|
-
*
|
|
3584
|
+
* await expandRedirects(def, entry, "en");
|
|
3493
3585
|
* ```
|
|
3494
3586
|
*/
|
|
3495
|
-
function
|
|
3496
|
-
const
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
children: title
|
|
3512
|
-
}), {
|
|
3513
|
-
width,
|
|
3514
|
-
height,
|
|
3515
|
-
fonts: [{
|
|
3516
|
-
name: font.name,
|
|
3517
|
-
data: font.data,
|
|
3518
|
-
weight: 400,
|
|
3519
|
-
style: "normal"
|
|
3520
|
-
}]
|
|
3521
|
-
})).render().asPng();
|
|
3522
|
-
};
|
|
3587
|
+
async function expandRedirects(definition, entry, defaultLocale) {
|
|
3588
|
+
const parameterSets = definition._handlers.generate ? await definition._handlers.generate(defaultLocale) : [{}];
|
|
3589
|
+
const jobs = [];
|
|
3590
|
+
for (const raw of parameterSets) {
|
|
3591
|
+
const params = raw ?? {};
|
|
3592
|
+
const file = entry.toFile(params);
|
|
3593
|
+
const target = entry.toUrl({
|
|
3594
|
+
...params,
|
|
3595
|
+
lang: defaultLocale
|
|
3596
|
+
});
|
|
3597
|
+
if (target !== entry.toUrl(params)) jobs.push({
|
|
3598
|
+
file,
|
|
3599
|
+
target
|
|
3600
|
+
});
|
|
3601
|
+
}
|
|
3602
|
+
return jobs;
|
|
3523
3603
|
}
|
|
3524
3604
|
/**
|
|
3525
|
-
*
|
|
3605
|
+
* Emits one bare-path redirect HTML page per locale-prefixed route path, each a
|
|
3606
|
+
* `0;url` refresh + canonical link to the default-locale URL. Never writes a
|
|
3607
|
+
* Cloudflare `_redirects` file. No-op (returns `null`) when `localeRedirects` is
|
|
3608
|
+
* false/unset.
|
|
3526
3609
|
*
|
|
3527
|
-
* @param
|
|
3528
|
-
* @returns The
|
|
3610
|
+
* @param ctx - Plugin context (provides `require`, `config`, `log`).
|
|
3611
|
+
* @returns The count of redirect pages written, or `null` when disabled.
|
|
3529
3612
|
* @example
|
|
3530
3613
|
* ```ts
|
|
3531
|
-
*
|
|
3614
|
+
* const result = await generateLocaleRedirects(ctx);
|
|
3532
3615
|
* ```
|
|
3533
3616
|
*/
|
|
3534
|
-
function
|
|
3535
|
-
|
|
3617
|
+
async function generateLocaleRedirects(ctx) {
|
|
3618
|
+
if (!ctx.config.localeRedirects) {
|
|
3619
|
+
ctx.log.debug("build:locale-redirects", { skipped: true });
|
|
3620
|
+
return null;
|
|
3621
|
+
}
|
|
3622
|
+
const router = ctx.require(routerPlugin);
|
|
3623
|
+
const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
|
|
3624
|
+
const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale)))).flat();
|
|
3625
|
+
await Promise.all(jobs.map(async ({ file, target }) => {
|
|
3626
|
+
const filePath = path.join(ctx.config.outDir, file);
|
|
3627
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
3628
|
+
await writeFile(filePath, redirectHtml(target), "utf8");
|
|
3629
|
+
}));
|
|
3630
|
+
ctx.log.debug("build:locale-redirects", { written: jobs.length });
|
|
3631
|
+
return { written: jobs.length };
|
|
3536
3632
|
}
|
|
3633
|
+
//#endregion
|
|
3634
|
+
//#region src/plugins/build/phases/not-found.ts
|
|
3537
3635
|
/**
|
|
3538
|
-
*
|
|
3539
|
-
*
|
|
3540
|
-
|
|
3541
|
-
|
|
3636
|
+
* @file build phase — not-found. Emits `outDir/404.html` from configured route
|
|
3637
|
+
* content or a built-in default. Gated by `config.notFound` (false/unset disables).
|
|
3638
|
+
*/
|
|
3639
|
+
/** The built-in default 404 page body when no custom route content is supplied. */
|
|
3640
|
+
const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
|
|
3641
|
+
/**
|
|
3642
|
+
* Wrap a body fragment in a minimal HTML document for the 404 page.
|
|
3542
3643
|
*
|
|
3543
|
-
* @param
|
|
3544
|
-
* @
|
|
3644
|
+
* @param body - The inner body HTML (default or configured).
|
|
3645
|
+
* @returns The complete HTML document string.
|
|
3646
|
+
* @example
|
|
3647
|
+
* ```ts
|
|
3648
|
+
* wrap("<h1>404</h1>");
|
|
3649
|
+
* ```
|
|
3650
|
+
*/
|
|
3651
|
+
function wrap(body) {
|
|
3652
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
|
|
3653
|
+
}
|
|
3654
|
+
/**
|
|
3655
|
+
* Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
|
|
3656
|
+
* default page; when it is `{ route }`, writes the supplied route content verbatim
|
|
3657
|
+
* inside the document shell. No-op (returns `null`) when `notFound` is false/unset.
|
|
3658
|
+
*
|
|
3659
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
3660
|
+
* @returns The written file path, or `null` when disabled.
|
|
3661
|
+
* @example
|
|
3662
|
+
* ```ts
|
|
3663
|
+
* const result = await generateNotFound(ctx);
|
|
3664
|
+
* ```
|
|
3665
|
+
*/
|
|
3666
|
+
async function generateNotFound(ctx) {
|
|
3667
|
+
const { notFound, outDir } = ctx.config;
|
|
3668
|
+
if (!notFound) {
|
|
3669
|
+
ctx.log.debug("build:not-found", { skipped: true });
|
|
3670
|
+
return null;
|
|
3671
|
+
}
|
|
3672
|
+
const body = typeof notFound === "object" && notFound.route ? notFound.route : DEFAULT_BODY;
|
|
3673
|
+
await mkdir(outDir, { recursive: true });
|
|
3674
|
+
const file = path.join(outDir, "404.html");
|
|
3675
|
+
await writeFile(file, wrap(body), "utf8");
|
|
3676
|
+
ctx.log.debug("build:not-found", { path: file });
|
|
3677
|
+
return { path: file };
|
|
3678
|
+
}
|
|
3679
|
+
//#endregion
|
|
3680
|
+
//#region src/plugins/build/phases/og-images.tsx
|
|
3681
|
+
/**
|
|
3682
|
+
* @file build phase 4 — og-images. Renders one OG image per published article via
|
|
3683
|
+
* Satori → SVG → resvg → PNG, bounded by `p-limit(4)`, with a persisted
|
|
3684
|
+
* content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
|
|
3685
|
+
* Gated by config.ogImage (object enables; false disables).
|
|
3686
|
+
*/
|
|
3687
|
+
/** Default OG image dimensions when `size` is omitted. */
|
|
3688
|
+
const DEFAULT_SIZE = {
|
|
3689
|
+
width: 1200,
|
|
3690
|
+
height: 630
|
|
3691
|
+
};
|
|
3692
|
+
/** Recognized font file extensions. */
|
|
3693
|
+
const FONT_EXTENSIONS$1 = [
|
|
3694
|
+
".ttf",
|
|
3695
|
+
".otf",
|
|
3696
|
+
".woff"
|
|
3697
|
+
];
|
|
3698
|
+
/**
|
|
3699
|
+
* Compute a stable cache key for the `fonts` configuration so a font change
|
|
3700
|
+
* invalidates cached PNGs. Hashes the name/path/weight/style of each entry (order
|
|
3701
|
+
* preserved); an empty/omitted list yields a fixed sentinel.
|
|
3702
|
+
*
|
|
3703
|
+
* @param fonts - The configured OG fonts (optional).
|
|
3704
|
+
* @returns A short stable key derived from the fonts list.
|
|
3705
|
+
* @example
|
|
3706
|
+
* ```ts
|
|
3707
|
+
* fontsKey([{ name: "Inter", path: "./Inter.ttf" }]);
|
|
3708
|
+
* ```
|
|
3709
|
+
*/
|
|
3710
|
+
function fontsKey(fonts) {
|
|
3711
|
+
if (!fonts || fonts.length === 0) return "default-font";
|
|
3712
|
+
const parts = fonts.map((font) => `${font.name}:${font.path}:${font.weight ?? 400}:${font.style ?? "normal"}`);
|
|
3713
|
+
return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* Compute the content-hash cache key for an article OG image. Covers the FULL
|
|
3717
|
+
* {@link RichOgInput} (title/description/date/tags/author/locale/siteName/size),
|
|
3718
|
+
* the resolved `template`, and a {@link fontsKey} of the fonts list — so changing
|
|
3719
|
+
* any input field OR the fonts invalidates the cached PNG.
|
|
3720
|
+
*
|
|
3721
|
+
* @param input - The full rich OG input for the card.
|
|
3722
|
+
* @param template - The resolved OG template identifier.
|
|
3723
|
+
* @param fontsHash - The {@link fontsKey} of the configured fonts.
|
|
3724
|
+
* @returns The hex-encoded SHA-256 digest.
|
|
3725
|
+
* @example
|
|
3726
|
+
* ```ts
|
|
3727
|
+
* ogHash(input, "default", fontsKey());
|
|
3728
|
+
* ```
|
|
3729
|
+
*/
|
|
3730
|
+
function ogHash(input, template, fontsHash) {
|
|
3731
|
+
const payload = [
|
|
3732
|
+
input.title,
|
|
3733
|
+
input.description,
|
|
3734
|
+
input.date,
|
|
3735
|
+
input.tags.join(","),
|
|
3736
|
+
input.author ?? "",
|
|
3737
|
+
input.locale,
|
|
3738
|
+
input.siteName,
|
|
3739
|
+
`${input.size.width}x${input.size.height}`,
|
|
3740
|
+
template,
|
|
3741
|
+
fontsHash
|
|
3742
|
+
].join("|");
|
|
3743
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
3744
|
+
}
|
|
3745
|
+
/**
|
|
3746
|
+
* Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
|
|
3747
|
+
* `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
|
|
3748
|
+
* font entry; otherwise the first font file found in `fontDir` is used as a single
|
|
3749
|
+
* 400/normal fallback.
|
|
3750
|
+
*
|
|
3751
|
+
* @param og - The font directory + optional explicit fonts list.
|
|
3752
|
+
* @param og.fontDir - Directory scanned for a fallback font when `fonts` is unset.
|
|
3753
|
+
* @param og.fonts - Explicit named fonts (each loaded once).
|
|
3754
|
+
* @returns The loaded fonts (empty when no font is available).
|
|
3755
|
+
* @example
|
|
3756
|
+
* ```ts
|
|
3757
|
+
* await loadFonts({ fontDir: "./fonts" });
|
|
3758
|
+
* ```
|
|
3759
|
+
*/
|
|
3760
|
+
async function loadFonts(og) {
|
|
3761
|
+
if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
|
|
3762
|
+
name: font.name,
|
|
3763
|
+
data: await readFile(font.path),
|
|
3764
|
+
weight: font.weight ?? 400,
|
|
3765
|
+
style: font.style ?? "normal"
|
|
3766
|
+
})));
|
|
3767
|
+
if (!existsSync(og.fontDir)) return [];
|
|
3768
|
+
const file = (await readdir(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
|
|
3769
|
+
if (!file) return [];
|
|
3770
|
+
return [{
|
|
3771
|
+
name: "OG",
|
|
3772
|
+
data: await readFile(path.join(og.fontDir, file)),
|
|
3773
|
+
weight: 400,
|
|
3774
|
+
style: "normal"
|
|
3775
|
+
}];
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* The built-in default OG card — a centered title on a dark background. Used when
|
|
3779
|
+
* no custom `ogImage.render` hook is configured. (`@jsxImportSource preact`.)
|
|
3780
|
+
*
|
|
3781
|
+
* @param input - The rich OG input (only `title` is used by the default card).
|
|
3782
|
+
* @returns The Preact `VNode` for the default card.
|
|
3783
|
+
* @example
|
|
3784
|
+
* ```ts
|
|
3785
|
+
* defaultCard(input);
|
|
3786
|
+
* ```
|
|
3787
|
+
*/
|
|
3788
|
+
function defaultCard(input) {
|
|
3789
|
+
return h("div", { style: {
|
|
3790
|
+
display: "flex",
|
|
3791
|
+
width: "100%",
|
|
3792
|
+
height: "100%",
|
|
3793
|
+
alignItems: "center",
|
|
3794
|
+
justifyContent: "center",
|
|
3795
|
+
fontSize: 64,
|
|
3796
|
+
background: "#0b0b0c",
|
|
3797
|
+
color: "#ffffff"
|
|
3798
|
+
} }, input.title);
|
|
3799
|
+
}
|
|
3800
|
+
/**
|
|
3801
|
+
* The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
|
|
3802
|
+
* card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
|
|
3803
|
+
* deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
|
|
3804
|
+
* at this single framework boundary only.
|
|
3805
|
+
*
|
|
3806
|
+
* @param ctx - The renderer wiring (preloaded fonts + optional custom card).
|
|
3807
|
+
* @param ctx.fonts - Fonts loaded once for the whole render pass.
|
|
3808
|
+
* @param ctx.render - Optional custom card renderer; defaults to {@link defaultCard}.
|
|
3809
|
+
* @returns An {@link OgPngRenderer} bound to the loaded fonts + renderer.
|
|
3810
|
+
* @example
|
|
3811
|
+
* ```ts
|
|
3812
|
+
* const render = makeDefaultRenderer({ fonts, render: undefined });
|
|
3813
|
+
* ```
|
|
3814
|
+
*/
|
|
3815
|
+
function makeDefaultRenderer(ctx) {
|
|
3816
|
+
return async (input) => {
|
|
3817
|
+
if (ctx.fonts.length === 0) throw new Error("[web] build.ogImage no font available for rendering");
|
|
3818
|
+
const { default: satori } = await import("satori");
|
|
3819
|
+
const { Resvg } = await import("@resvg/resvg-js");
|
|
3820
|
+
return new Resvg(await satori((ctx.render ?? defaultCard)(input), {
|
|
3821
|
+
width: input.size.width,
|
|
3822
|
+
height: input.size.height,
|
|
3823
|
+
fonts: ctx.fonts
|
|
3824
|
+
})).render().asPng();
|
|
3825
|
+
};
|
|
3826
|
+
}
|
|
3827
|
+
/**
|
|
3828
|
+
* Select the published articles to render OG images for (default-locale set).
|
|
3829
|
+
*
|
|
3830
|
+
* @param byLocale - The cached locale-keyed article map.
|
|
3831
|
+
* @returns The published articles across the first cached locale.
|
|
3832
|
+
* @example
|
|
3833
|
+
* ```ts
|
|
3834
|
+
* selectArticles(byLocale);
|
|
3835
|
+
* ```
|
|
3836
|
+
*/
|
|
3837
|
+
function selectArticles(byLocale) {
|
|
3838
|
+
return ([...byLocale.values()][0] ?? []).filter((article) => article.computed.status === "published");
|
|
3839
|
+
}
|
|
3840
|
+
/**
|
|
3841
|
+
* Build the {@link RichOgInput} for one article from its frontmatter/computed
|
|
3842
|
+
* fields plus the resolved size and site name.
|
|
3843
|
+
*
|
|
3844
|
+
* @param article - The published article to render a card for.
|
|
3845
|
+
* @param size - The resolved OG output dimensions.
|
|
3846
|
+
* @param siteName - The site name (from the site plugin, or `""` when unavailable).
|
|
3847
|
+
* @returns The fully-populated rich OG input.
|
|
3848
|
+
* @example
|
|
3849
|
+
* ```ts
|
|
3850
|
+
* buildInput(article, { width: 1200, height: 630 }, "Blog");
|
|
3851
|
+
* ```
|
|
3852
|
+
*/
|
|
3853
|
+
function buildInput(article, size, siteName) {
|
|
3854
|
+
const input = {
|
|
3855
|
+
title: article.frontmatter.title,
|
|
3856
|
+
description: article.frontmatter.description,
|
|
3857
|
+
date: article.frontmatter.date,
|
|
3858
|
+
tags: [...article.frontmatter.tags],
|
|
3859
|
+
locale: article.locale,
|
|
3860
|
+
siteName,
|
|
3861
|
+
size
|
|
3862
|
+
};
|
|
3863
|
+
if (article.frontmatter.author !== void 0) input.author = article.frontmatter.author;
|
|
3864
|
+
return input;
|
|
3865
|
+
}
|
|
3866
|
+
/**
|
|
3867
|
+
* Resolve the site name via `ctx.require(sitePlugin)`, falling back to `""` when the
|
|
3868
|
+
* site API is unavailable (e.g. unit mocks that omit it).
|
|
3869
|
+
*
|
|
3870
|
+
* @param ctx - Plugin context (provides `require`).
|
|
3871
|
+
* @returns The site name, or `""` when the site plugin is not wired.
|
|
3872
|
+
* @example
|
|
3873
|
+
* ```ts
|
|
3874
|
+
* resolveSiteName(ctx);
|
|
3875
|
+
* ```
|
|
3876
|
+
*/
|
|
3877
|
+
function resolveSiteName(ctx) {
|
|
3878
|
+
try {
|
|
3879
|
+
return ctx.require(sitePlugin).name();
|
|
3880
|
+
} catch {
|
|
3881
|
+
return "";
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
/**
|
|
3885
|
+
* Renders OG images for published articles with a `p-limit(4)` concurrency pool.
|
|
3886
|
+
* Computes {@link ogHash} (full {@link RichOgInput} + template + fonts) per article
|
|
3887
|
+
* and skips regeneration when the hash matches `state.ogImageHashCache`; writes the
|
|
3888
|
+
* cache back to `<outDir>/.cache/og-images.json`. The configured `ogImage.render`
|
|
3889
|
+
* hook (when present) builds each card; otherwise the built-in card is used. Fonts
|
|
3890
|
+
* are loaded ONCE for the whole pass. No-op when `config.ogImage` is false.
|
|
3891
|
+
*
|
|
3892
|
+
* @param ctx - Plugin context (provides `require`, `state`, `config`, `log`).
|
|
3893
|
+
* @param options - Optional dependency-injection seam (PNG rasterizer).
|
|
3545
3894
|
* @returns The render/skip counts + peak concurrency, or `null` when disabled.
|
|
3546
3895
|
* @example
|
|
3547
3896
|
* ```ts
|
|
@@ -3555,9 +3904,17 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3555
3904
|
return null;
|
|
3556
3905
|
}
|
|
3557
3906
|
const { default: pLimit } = await import("p-limit");
|
|
3558
|
-
const
|
|
3559
|
-
const
|
|
3560
|
-
const
|
|
3907
|
+
const config = og;
|
|
3908
|
+
const size = config.size ?? DEFAULT_SIZE;
|
|
3909
|
+
const template = config.template ?? "default";
|
|
3910
|
+
const fontsHash = fontsKey(config.fonts);
|
|
3911
|
+
const fonts = options.renderPng ? [] : await loadFonts(config);
|
|
3912
|
+
const renderHook = config.render ? { render: config.render } : {};
|
|
3913
|
+
const renderPng = options.renderPng ?? makeDefaultRenderer({
|
|
3914
|
+
fonts,
|
|
3915
|
+
...renderHook
|
|
3916
|
+
});
|
|
3917
|
+
const siteName = resolveSiteName(ctx);
|
|
3561
3918
|
const articles = selectArticles(readCachedContent(ctx));
|
|
3562
3919
|
const cache = ctx.state.ogImageHashCache;
|
|
3563
3920
|
await loadDiskCache(ctx.config.outDir, cache);
|
|
@@ -3569,7 +3926,8 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3569
3926
|
const outDir = path.join(ctx.config.outDir, "og");
|
|
3570
3927
|
await Promise.all(articles.map((article) => limit(async () => {
|
|
3571
3928
|
const key = article.computed.contentId;
|
|
3572
|
-
const
|
|
3929
|
+
const input = buildInput(article, size, siteName);
|
|
3930
|
+
const hash = ogHash(input, template, fontsHash);
|
|
3573
3931
|
if (cache.get(key) === hash) {
|
|
3574
3932
|
skipped += 1;
|
|
3575
3933
|
return;
|
|
@@ -3577,10 +3935,7 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3577
3935
|
active += 1;
|
|
3578
3936
|
peakConcurrency = Math.max(peakConcurrency, active);
|
|
3579
3937
|
try {
|
|
3580
|
-
const png = await renderPng(
|
|
3581
|
-
title: article.frontmatter.title,
|
|
3582
|
-
...size
|
|
3583
|
-
});
|
|
3938
|
+
const png = await renderPng(input);
|
|
3584
3939
|
await mkdir(outDir, { recursive: true });
|
|
3585
3940
|
await writeFile(path.join(outDir, `${key}.png`), png);
|
|
3586
3941
|
cache.set(key, hash);
|
|
@@ -3635,28 +3990,329 @@ async function persistDiskCache(outDir, cache) {
|
|
|
3635
3990
|
await writeFile(path.join(dir, "og-images.json"), JSON.stringify(Object.fromEntries(cache)), "utf8");
|
|
3636
3991
|
}
|
|
3637
3992
|
//#endregion
|
|
3993
|
+
//#region src/plugins/data/load-json.ts
|
|
3994
|
+
/**
|
|
3995
|
+
* @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
|
|
3996
|
+
* SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
|
|
3997
|
+
* `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
|
|
3998
|
+
*
|
|
3999
|
+
* A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
|
|
4000
|
+
* on the client (browser) it fetches the same data over HTTP. `loadJson` is the
|
|
4001
|
+
* single point where those two worlds differ — everything above it (the route's
|
|
4002
|
+
* `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
|
|
4003
|
+
*
|
|
4004
|
+
* The browser path uses the `fetch` global. The Node path lazy-imports
|
|
4005
|
+
* `node:fs/promises` via `await import(...)`, so a browser bundle that includes
|
|
4006
|
+
* `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
|
|
4007
|
+
* into its own chunk that the browser never loads).
|
|
4008
|
+
*/
|
|
4009
|
+
/**
|
|
4010
|
+
* Read + parse a JSON resource, isomorphically. In a browser (`document`
|
|
4011
|
+
* defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
|
|
4012
|
+
* on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
|
|
4013
|
+
* can decide whether to fall back.
|
|
4014
|
+
*
|
|
4015
|
+
* @template T - The expected shape of the parsed JSON.
|
|
4016
|
+
* @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
|
|
4017
|
+
* @returns The parsed JSON, typed as `T`.
|
|
4018
|
+
* @throws {Error} If the browser fetch is not OK, or the Node file read fails.
|
|
4019
|
+
* @example
|
|
4020
|
+
* ```ts
|
|
4021
|
+
* // Browser: fetch("/_data/en/articles.json")
|
|
4022
|
+
* // Node: read "dist/_data/en/articles.json"
|
|
4023
|
+
* const articles = await loadJson<Article[]>("/_data/en/articles.json");
|
|
4024
|
+
* ```
|
|
4025
|
+
*/
|
|
4026
|
+
async function loadJson(pathOrUrl) {
|
|
4027
|
+
if (typeof document === "undefined") {
|
|
4028
|
+
const { readFile } = await import("node:fs/promises");
|
|
4029
|
+
return JSON.parse(await readFile(pathOrUrl, "utf8"));
|
|
4030
|
+
}
|
|
4031
|
+
const response = await fetch(pathOrUrl);
|
|
4032
|
+
if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
|
|
4033
|
+
return response.json();
|
|
4034
|
+
}
|
|
4035
|
+
//#endregion
|
|
4036
|
+
//#region src/plugins/data/api.ts
|
|
4037
|
+
/**
|
|
4038
|
+
* @file data plugin — API factory (the agnostic data provider surface).
|
|
4039
|
+
*
|
|
4040
|
+
* Node-free by construction: this module statically imports only types + the pure
|
|
4041
|
+
* convention. The Node write side (`write()`) reaches its `node:fs` writer through
|
|
4042
|
+
* a lazy `await import("./writer")` at call time, so a browser bundle that composes
|
|
4043
|
+
* `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
|
|
4044
|
+
* the isomorphic `loadJson` (whose Node branch is itself lazy).
|
|
4045
|
+
*/
|
|
4046
|
+
/**
|
|
4047
|
+
* Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
|
|
4048
|
+
*
|
|
4049
|
+
* @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
|
|
4050
|
+
* @returns The dir without a trailing slash.
|
|
4051
|
+
* @example
|
|
4052
|
+
* ```ts
|
|
4053
|
+
* trimTrailingSlash("_data/"); // "_data"
|
|
4054
|
+
* ```
|
|
4055
|
+
*/
|
|
4056
|
+
function trimTrailingSlash(dir) {
|
|
4057
|
+
return dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
4058
|
+
}
|
|
4059
|
+
/**
|
|
4060
|
+
* Builds the data provider — the agnostic bridge. `write()` is the Node persist
|
|
4061
|
+
* side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
|
|
4062
|
+
* convention. No `onStart`/`onStop` (holds no long-lived resource).
|
|
4063
|
+
*
|
|
4064
|
+
* @param ctx - The data plugin context.
|
|
4065
|
+
* @returns The {@link DataProvider} mounted at `app.data`.
|
|
4066
|
+
* @example
|
|
4067
|
+
* ```ts
|
|
4068
|
+
* const api = dataApi(ctx);
|
|
4069
|
+
* await api.write([{ path: "/en/hello/", data: article }]); // Node build
|
|
4070
|
+
* await api.at("/en/hello/"); // browser
|
|
4071
|
+
* ```
|
|
4072
|
+
*/
|
|
4073
|
+
function dataApi(ctx) {
|
|
4074
|
+
return {
|
|
4075
|
+
/**
|
|
4076
|
+
* READ (browser) — fetch (and cache) the persisted data for a page path.
|
|
4077
|
+
* Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
|
|
4078
|
+
* or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
|
|
4079
|
+
*
|
|
4080
|
+
* @param path - The page URL path (e.g. `/en/hello/`).
|
|
4081
|
+
* @returns The page's raw data, or `null` on failure.
|
|
4082
|
+
* @example
|
|
4083
|
+
* ```ts
|
|
4084
|
+
* const raw = await api.at("/en/hello/");
|
|
4085
|
+
* ```
|
|
4086
|
+
*/
|
|
4087
|
+
async at(path) {
|
|
4088
|
+
if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
|
|
4089
|
+
try {
|
|
4090
|
+
const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
|
|
4091
|
+
ctx.state.cache.set(path, data);
|
|
4092
|
+
return data;
|
|
4093
|
+
} catch {
|
|
4094
|
+
return null;
|
|
4095
|
+
}
|
|
4096
|
+
},
|
|
4097
|
+
/**
|
|
4098
|
+
* WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
|
|
4099
|
+
* `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
|
|
4100
|
+
* browser bundle node-free).
|
|
4101
|
+
*
|
|
4102
|
+
* @param entries - The per-page data to persist.
|
|
4103
|
+
* @param options - Optional `{ outDir }` override (defaults to `./dist`).
|
|
4104
|
+
* @param options.outDir - Build output directory the write happens under.
|
|
4105
|
+
* @returns A summary of the written files.
|
|
4106
|
+
* @example
|
|
4107
|
+
* ```ts
|
|
4108
|
+
* await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
|
|
4109
|
+
* ```
|
|
4110
|
+
*/
|
|
4111
|
+
async write(entries, options) {
|
|
4112
|
+
const { writeData } = await import("./writer-BcWqa_7I.mjs");
|
|
4113
|
+
return writeData(ctx, entries, options);
|
|
4114
|
+
},
|
|
4115
|
+
/**
|
|
4116
|
+
* PURE — the browser fetch URL for a page path.
|
|
4117
|
+
*
|
|
4118
|
+
* @param path - The page URL path.
|
|
4119
|
+
* @returns The site-root-relative data URL.
|
|
4120
|
+
* @example
|
|
4121
|
+
* ```ts
|
|
4122
|
+
* api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
|
|
4123
|
+
* ```
|
|
4124
|
+
*/
|
|
4125
|
+
urlFor(path) {
|
|
4126
|
+
return `${ctx.config.baseUrl}${dataSuffix(path)}`;
|
|
4127
|
+
},
|
|
4128
|
+
/**
|
|
4129
|
+
* PURE — the `outDir`-relative file path for a page path.
|
|
4130
|
+
*
|
|
4131
|
+
* @param path - The page URL path.
|
|
4132
|
+
* @returns The output-relative file path.
|
|
4133
|
+
* @example
|
|
4134
|
+
* ```ts
|
|
4135
|
+
* api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
|
|
4136
|
+
* ```
|
|
4137
|
+
*/
|
|
4138
|
+
fileFor(path) {
|
|
4139
|
+
return `${trimTrailingSlash(ctx.config.outputDir)}/${dataSuffix(path)}`;
|
|
4140
|
+
}
|
|
4141
|
+
};
|
|
4142
|
+
}
|
|
4143
|
+
//#endregion
|
|
4144
|
+
//#region src/plugins/data/config.ts
|
|
4145
|
+
/**
|
|
4146
|
+
* Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
|
|
4147
|
+
* (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
|
|
4148
|
+
* (site-root-relative) the browser fetches from — the defaults agree
|
|
4149
|
+
* (`"_data"` ↔ `"/_data/"`).
|
|
4150
|
+
*
|
|
4151
|
+
* @example
|
|
4152
|
+
* ```ts
|
|
4153
|
+
* createPlugin("data", { config: defaultDataConfig });
|
|
4154
|
+
* ```
|
|
4155
|
+
*/
|
|
4156
|
+
const defaultDataConfig = {
|
|
4157
|
+
outputDir: "_data",
|
|
4158
|
+
baseUrl: "/_data/"
|
|
4159
|
+
};
|
|
4160
|
+
//#endregion
|
|
4161
|
+
//#region src/plugins/data/state.ts
|
|
4162
|
+
/**
|
|
4163
|
+
* Creates initial data state: a null `lastWrite` slot (populated by the Node
|
|
4164
|
+
* `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
|
|
4165
|
+
* side on first fetch).
|
|
4166
|
+
*
|
|
4167
|
+
* @param _ctx - Minimal context with global and config.
|
|
4168
|
+
* @param _ctx.global - Global framework configuration.
|
|
4169
|
+
* @param _ctx.config - Resolved plugin configuration.
|
|
4170
|
+
* @returns Fresh data state with no recorded write and an empty per-path cache.
|
|
4171
|
+
* @example
|
|
4172
|
+
* ```ts
|
|
4173
|
+
* const state = createDataState({ global: {}, config });
|
|
4174
|
+
* ```
|
|
4175
|
+
*/
|
|
4176
|
+
function createDataState(_ctx) {
|
|
4177
|
+
return {
|
|
4178
|
+
lastWrite: null,
|
|
4179
|
+
cache: /* @__PURE__ */ new Map()
|
|
4180
|
+
};
|
|
4181
|
+
}
|
|
4182
|
+
//#endregion
|
|
4183
|
+
//#region src/plugins/data/validate.ts
|
|
4184
|
+
/**
|
|
4185
|
+
* Validates the resolved data config: the browser `baseUrl` must be a non-empty,
|
|
4186
|
+
* site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
|
|
4187
|
+
*
|
|
4188
|
+
* @param config - The resolved plugin configuration.
|
|
4189
|
+
* @throws {Error} If `baseUrl` is empty or not a rooted URL path.
|
|
4190
|
+
* @example
|
|
4191
|
+
* ```ts
|
|
4192
|
+
* validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
|
|
4193
|
+
* ```
|
|
4194
|
+
*/
|
|
4195
|
+
function validateDataConfig(config) {
|
|
4196
|
+
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/").`);
|
|
4197
|
+
}
|
|
4198
|
+
//#endregion
|
|
4199
|
+
//#region src/plugins/data/index.ts
|
|
4200
|
+
/**
|
|
4201
|
+
* @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
|
|
4202
|
+
* the SSG→DATA→SPA pattern.
|
|
4203
|
+
*
|
|
4204
|
+
* Owns ONE contract — `page path → persisted JSON file` — and nothing about what
|
|
4205
|
+
* the data is: `write(entries)` persists per-page JSON on Node (build supplies the
|
|
4206
|
+
* entries it already expanded); `at(path)` fetches + caches it in the browser as
|
|
4207
|
+
* `unknown`, which the route's `parse` validates before `render`. NOT a framework
|
|
4208
|
+
* default — the consumer composes it where needed (Node build AND/OR browser app).
|
|
4209
|
+
*
|
|
4210
|
+
* **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
|
|
4211
|
+
* a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
|
|
4212
|
+
* writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
|
|
4213
|
+
* No `onStart`/`onStop`.
|
|
4214
|
+
* @see README.md
|
|
4215
|
+
*/
|
|
4216
|
+
/**
|
|
4217
|
+
* Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
|
|
4218
|
+
* `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
|
|
4219
|
+
*
|
|
4220
|
+
* @example
|
|
4221
|
+
* ```ts
|
|
4222
|
+
* // Node build: `build` calls app.data.write(...) during its pages phase when
|
|
4223
|
+
* // router.mode !== "ssg". Just compose the plugin:
|
|
4224
|
+
* const app = createApp({
|
|
4225
|
+
* plugins: [dataPlugin, contentPlugin, buildPlugin],
|
|
4226
|
+
* pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
|
|
4227
|
+
* });
|
|
4228
|
+
* await app.start();
|
|
4229
|
+
* await app.build.run(); // writes HTML + per-page data sidecars
|
|
4230
|
+
*
|
|
4231
|
+
* // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
|
|
4232
|
+
* ```
|
|
4233
|
+
*/
|
|
4234
|
+
const dataPlugin = createPlugin$1("data", {
|
|
4235
|
+
config: defaultDataConfig,
|
|
4236
|
+
createState: createDataState,
|
|
4237
|
+
onInit: (ctx) => validateDataConfig(ctx.config),
|
|
4238
|
+
api: dataApi
|
|
4239
|
+
});
|
|
4240
|
+
//#endregion
|
|
3638
4241
|
//#region src/plugins/build/phases/pages.tsx
|
|
3639
4242
|
/**
|
|
3640
4243
|
* @file build phase 3 — pages. Pulls `router.manifest()` + `head.render(route, data)`
|
|
3641
4244
|
* and SSR-renders each route to static HTML (preact-render-to-string). Appends the
|
|
3642
4245
|
* build-id meta tag after `head.render()` returns. Does NOT compose `<head>` itself.
|
|
3643
4246
|
*/
|
|
4247
|
+
/** Template placeholder for the composed `<head>` inner HTML. */
|
|
4248
|
+
const HEAD_PLACEHOLDER = "<!--moku:head-->";
|
|
4249
|
+
/** Template placeholder for the SSR-rendered body HTML. */
|
|
4250
|
+
const BODY_PLACEHOLDER = "<!--moku:body-->";
|
|
4251
|
+
/** Template placeholder for the injected asset `<link>`/`<script>` tags. */
|
|
4252
|
+
const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
|
|
4253
|
+
/**
|
|
4254
|
+
* Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
|
|
4255
|
+
* as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
|
|
4256
|
+
*
|
|
4257
|
+
* @param ctx - Plugin context (provides `state`).
|
|
4258
|
+
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
4259
|
+
* @returns The hashed-path manifest entry, or an empty object when absent.
|
|
4260
|
+
* @example
|
|
4261
|
+
* ```ts
|
|
4262
|
+
* readManifest(ctx, "css");
|
|
4263
|
+
* ```
|
|
4264
|
+
*/
|
|
4265
|
+
function readManifest(ctx, kind) {
|
|
4266
|
+
const entry = ctx.state.buildCache.get(kind);
|
|
4267
|
+
return entry && typeof entry === "object" ? entry : {};
|
|
4268
|
+
}
|
|
4269
|
+
/**
|
|
4270
|
+
* Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
|
|
4271
|
+
* an empty string when `config.injectAssets === false`. Asset paths are emitted as
|
|
4272
|
+
* absolute (`/`-rooted) URLs.
|
|
4273
|
+
*
|
|
4274
|
+
* @param ctx - Plugin context (provides `state`, `config`).
|
|
4275
|
+
* @returns The injected asset tags, or `""` when injection is disabled.
|
|
4276
|
+
* @example
|
|
4277
|
+
* ```ts
|
|
4278
|
+
* buildAssetTags(ctx);
|
|
4279
|
+
* ```
|
|
4280
|
+
*/
|
|
4281
|
+
function buildAssetTags(ctx) {
|
|
4282
|
+
if (ctx.config.injectAssets === false) return "";
|
|
4283
|
+
const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
|
|
4284
|
+
const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
|
|
4285
|
+
return [...css, ...js].join("");
|
|
4286
|
+
}
|
|
3644
4287
|
/**
|
|
3645
|
-
* Compose the full static HTML document
|
|
3646
|
-
* `<head>` AFTER the head plugin's composed HTML (build
|
|
4288
|
+
* Compose the full static HTML document with the in-code shell, injecting the
|
|
4289
|
+
* build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
|
|
4290
|
+
* metadata, not content) and the asset tags at the end of `<head>`.
|
|
3647
4291
|
*
|
|
3648
|
-
* @param
|
|
3649
|
-
* @param bodyHtml - The SSR-rendered body HTML.
|
|
3650
|
-
* @param runId - The per-run build id injected as `<meta name="build-id">`.
|
|
3651
|
-
* @param locale - The page locale for the `<html lang>` attribute.
|
|
4292
|
+
* @param parts - The composed head/body/assets/locale pieces.
|
|
3652
4293
|
* @returns The complete HTML document string.
|
|
3653
4294
|
* @example
|
|
3654
4295
|
* ```ts
|
|
3655
|
-
* renderDocument("<title>Hi</title>", "<h1>Hi</h1>", "
|
|
4296
|
+
* renderDocument({ head: "<title>Hi</title>", body: "<h1>Hi</h1>", assets: "", locale: "en" });
|
|
4297
|
+
* ```
|
|
4298
|
+
*/
|
|
4299
|
+
function renderDocument(parts) {
|
|
4300
|
+
return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
|
|
4301
|
+
}
|
|
4302
|
+
/**
|
|
4303
|
+
* Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
|
|
4304
|
+
* `<!--moku:assets-->` placeholders deterministically at build time.
|
|
4305
|
+
*
|
|
4306
|
+
* @param template - The raw shell template HTML.
|
|
4307
|
+
* @param parts - The composed head/body/assets pieces.
|
|
4308
|
+
* @returns The filled document string.
|
|
4309
|
+
* @example
|
|
4310
|
+
* ```ts
|
|
4311
|
+
* fillTemplate(shell, { head, body, assets, locale: "en" });
|
|
3656
4312
|
* ```
|
|
3657
4313
|
*/
|
|
3658
|
-
function
|
|
3659
|
-
return
|
|
4314
|
+
function fillTemplate(template, parts) {
|
|
4315
|
+
return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
|
|
3660
4316
|
}
|
|
3661
4317
|
/**
|
|
3662
4318
|
* Expand one route definition into its concrete page instances across all
|
|
@@ -3733,18 +4389,24 @@ function adaptHeadConfig(config) {
|
|
|
3733
4389
|
return adapted;
|
|
3734
4390
|
}
|
|
3735
4391
|
/**
|
|
3736
|
-
* Render one page instance to its static HTML document and write it to disk.
|
|
4392
|
+
* Render one page instance to its static HTML document and write it to disk. Uses
|
|
4393
|
+
* the configured shell `template` (filled at build time) when supplied, otherwise
|
|
4394
|
+
* the in-code shell; injects the precomputed asset tags + build-id meta.
|
|
3737
4395
|
*
|
|
3738
4396
|
* @param ctx - Plugin context (provides `require`, `state`, `config`).
|
|
3739
4397
|
* @param instance - The concrete page instance to render.
|
|
4398
|
+
* @param shell - Wiring shared across instances (asset tags + optional template).
|
|
4399
|
+
* @param shell.assets - The injected asset `<link>`/`<script>` tags.
|
|
4400
|
+
* @param shell.template - The shell template HTML, or `null` for the in-code shell.
|
|
3740
4401
|
* @returns The instance's URL and rendered HTML (HTML reused for the root page).
|
|
3741
4402
|
* @example
|
|
3742
4403
|
* ```ts
|
|
3743
|
-
* await renderInstance(ctx, instance);
|
|
4404
|
+
* await renderInstance(ctx, instance, { assets: "", template: null });
|
|
3744
4405
|
* ```
|
|
3745
4406
|
*/
|
|
3746
|
-
async function renderInstance(ctx, instance) {
|
|
4407
|
+
async function renderInstance(ctx, instance, shell) {
|
|
3747
4408
|
const { definition, entry, params, locale, name } = instance;
|
|
4409
|
+
const hasData = definition._handlers.load !== void 0;
|
|
3748
4410
|
const data = definition._handlers.load ? await definition._handlers.load(params, locale) : void 0;
|
|
3749
4411
|
const routeContext = {
|
|
3750
4412
|
params,
|
|
@@ -3761,14 +4423,29 @@ async function renderInstance(ctx, instance) {
|
|
|
3761
4423
|
};
|
|
3762
4424
|
if (headConfig) resolved.head = adaptHeadConfig(headConfig);
|
|
3763
4425
|
const headHtml = ctx.require(headPlugin).render(resolved, data);
|
|
4426
|
+
const buildIdMeta = `<meta name="build-id" content="${ctx.state.runId ?? ""}">`;
|
|
3764
4427
|
const vnode = definition._handlers.render?.(routeContext);
|
|
3765
|
-
const
|
|
4428
|
+
const layoutCtx = {
|
|
4429
|
+
...routeContext,
|
|
4430
|
+
meta: definition._meta
|
|
4431
|
+
};
|
|
4432
|
+
const page = vnode && definition._handlers.layout ? definition._handlers.layout(layoutCtx, vnode) : vnode;
|
|
4433
|
+
const bodyHtml = page ? renderToString(page) : "";
|
|
4434
|
+
const parts = {
|
|
4435
|
+
head: `${headHtml}${buildIdMeta}`,
|
|
4436
|
+
body: bodyHtml,
|
|
4437
|
+
assets: shell.assets,
|
|
4438
|
+
locale
|
|
4439
|
+
};
|
|
4440
|
+
const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
|
|
3766
4441
|
const filePath = join(ctx.config.outDir, entry.toFile(params));
|
|
3767
4442
|
await mkdir(dirname(filePath), { recursive: true });
|
|
3768
4443
|
await writeFile(filePath, html, "utf8");
|
|
3769
4444
|
return {
|
|
3770
4445
|
url,
|
|
3771
|
-
html
|
|
4446
|
+
html,
|
|
4447
|
+
data,
|
|
4448
|
+
hasData
|
|
3772
4449
|
};
|
|
3773
4450
|
}
|
|
3774
4451
|
/**
|
|
@@ -3786,14 +4463,56 @@ async function renderInstance(ctx, instance) {
|
|
|
3786
4463
|
* const { pageCount, rootHtml } = await renderPages(ctx);
|
|
3787
4464
|
* ```
|
|
3788
4465
|
*/
|
|
4466
|
+
/**
|
|
4467
|
+
* Enforce the data-validation contract: in `hybrid`/`spa` mode, any route that
|
|
4468
|
+
* has BOTH a `render` and a `load` (so it will be client-data-navigated) MUST
|
|
4469
|
+
* declare a `.parse()` validator — otherwise fetched JSON would reach `render`
|
|
4470
|
+
* unvalidated. Converts a runtime safety hole into a build error.
|
|
4471
|
+
*
|
|
4472
|
+
* @param manifest - The route definitions from `router.manifest()`.
|
|
4473
|
+
* @param mode - The resolved render mode.
|
|
4474
|
+
* @throws {Error} If a data-navigable route is missing `.parse()` in hybrid/spa mode.
|
|
4475
|
+
* @example
|
|
4476
|
+
* ```ts
|
|
4477
|
+
* assertDataValidators(router.manifest(), router.mode());
|
|
4478
|
+
* ```
|
|
4479
|
+
*/
|
|
4480
|
+
function assertDataValidators(manifest, mode) {
|
|
4481
|
+
if (mode === "ssg") return;
|
|
4482
|
+
for (const definition of manifest) {
|
|
4483
|
+
const { render, load, parse } = definition._handlers;
|
|
4484
|
+
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.`);
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
3789
4487
|
async function renderPages(ctx) {
|
|
3790
4488
|
const router = ctx.require(routerPlugin);
|
|
3791
4489
|
const manifest = router.manifest();
|
|
3792
4490
|
ctx.state.manifest = [...manifest];
|
|
4491
|
+
const mode = router.mode();
|
|
4492
|
+
assertDataValidators(manifest, mode);
|
|
3793
4493
|
const byPattern = makeEntryMap(router);
|
|
3794
4494
|
const locales = ctx.require(i18nPlugin).locales();
|
|
4495
|
+
const templatePath = ctx.config.template;
|
|
4496
|
+
const template = typeof templatePath === "string" && existsSync(templatePath) ? await readFile(templatePath, "utf8") : null;
|
|
4497
|
+
const shell = {
|
|
4498
|
+
assets: buildAssetTags(ctx),
|
|
4499
|
+
template
|
|
4500
|
+
};
|
|
3795
4501
|
const instances = (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern)))).flat();
|
|
3796
|
-
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance)));
|
|
4502
|
+
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
|
|
4503
|
+
if (mode !== "ssg" && ctx.has("data")) {
|
|
4504
|
+
const entries = rendered.filter((page) => page.hasData).map((page) => ({
|
|
4505
|
+
path: page.url,
|
|
4506
|
+
data: page.data
|
|
4507
|
+
}));
|
|
4508
|
+
if (entries.length > 0) {
|
|
4509
|
+
const summary = await ctx.require(dataPlugin).write(entries, { outDir: ctx.config.outDir });
|
|
4510
|
+
ctx.log.debug("build:data", {
|
|
4511
|
+
files: summary.fileCount,
|
|
4512
|
+
bytes: summary.bytes
|
|
4513
|
+
});
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
3797
4516
|
const root = rendered.find((page) => page.url === "/" || page.url === "");
|
|
3798
4517
|
ctx.log.debug("build:pages", { count: rendered.length });
|
|
3799
4518
|
return {
|
|
@@ -3801,6 +4520,37 @@ async function renderPages(ctx) {
|
|
|
3801
4520
|
rootHtml: root?.html ?? null
|
|
3802
4521
|
};
|
|
3803
4522
|
}
|
|
4523
|
+
/**
|
|
4524
|
+
* Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
|
|
4525
|
+
* preserving the nested directory structure. Skips silently (returns `null`) when
|
|
4526
|
+
* the source directory does not exist.
|
|
4527
|
+
*
|
|
4528
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
4529
|
+
* @returns The copy result, or `null` when the public directory is absent.
|
|
4530
|
+
* @example
|
|
4531
|
+
* ```ts
|
|
4532
|
+
* const result = await copyPublic(ctx);
|
|
4533
|
+
* ```
|
|
4534
|
+
*/
|
|
4535
|
+
async function copyPublic(ctx) {
|
|
4536
|
+
const from = ctx.config.publicDir ?? "public";
|
|
4537
|
+
if (!existsSync(from)) {
|
|
4538
|
+
ctx.log.debug("build:public", {
|
|
4539
|
+
skipped: true,
|
|
4540
|
+
from
|
|
4541
|
+
});
|
|
4542
|
+
return null;
|
|
4543
|
+
}
|
|
4544
|
+
await cp(from, ctx.config.outDir, { recursive: true });
|
|
4545
|
+
ctx.log.debug("build:public", {
|
|
4546
|
+
from,
|
|
4547
|
+
dest: ctx.config.outDir
|
|
4548
|
+
});
|
|
4549
|
+
return {
|
|
4550
|
+
from: path.normalize(from),
|
|
4551
|
+
copied: 1
|
|
4552
|
+
};
|
|
4553
|
+
}
|
|
3804
4554
|
//#endregion
|
|
3805
4555
|
//#region src/plugins/build/phases/sitemap.ts
|
|
3806
4556
|
/**
|
|
@@ -3904,6 +4654,9 @@ const PHASE_ORDER = [
|
|
|
3904
4654
|
"feeds",
|
|
3905
4655
|
"sitemap",
|
|
3906
4656
|
"og-images",
|
|
4657
|
+
"public",
|
|
4658
|
+
"not-found",
|
|
4659
|
+
"locale-redirects",
|
|
3907
4660
|
"root-index"
|
|
3908
4661
|
];
|
|
3909
4662
|
/**
|
|
@@ -3947,10 +4700,11 @@ function resetRun(ctx) {
|
|
|
3947
4700
|
ctx.state.runId = `${Date.now()}-${randomUUID()}`;
|
|
3948
4701
|
}
|
|
3949
4702
|
/**
|
|
3950
|
-
* Phase 4 — run feeds / sitemap / og-images
|
|
3951
|
-
*
|
|
3952
|
-
*
|
|
3953
|
-
*
|
|
4703
|
+
* Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
|
|
4704
|
+
* concurrently, each gated by its config flag (or, for `public`, the presence of the
|
|
4705
|
+
* source dir), isolated with `Promise.allSettled` so one failure does not lose the
|
|
4706
|
+
* others. A disabled output is skipped entirely — it emits NO `build:phase` boundary
|
|
4707
|
+
* (the `withPhase` wrapper is gated on the config flag, not just the phase body).
|
|
3954
4708
|
*
|
|
3955
4709
|
* @param ctx - The phase context.
|
|
3956
4710
|
* @example
|
|
@@ -3963,6 +4717,9 @@ async function runOutputs(ctx) {
|
|
|
3963
4717
|
if (ctx.config.feeds) tasks.push(withPhase(ctx, "feeds", () => generateFeeds(ctx)));
|
|
3964
4718
|
if (ctx.config.sitemap) tasks.push(withPhase(ctx, "sitemap", () => generateSitemap(ctx)));
|
|
3965
4719
|
if (ctx.config.ogImage) tasks.push(withPhase(ctx, "og-images", () => generateOgImages(ctx)));
|
|
4720
|
+
if (existsSync(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
|
|
4721
|
+
if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
|
|
4722
|
+
if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
|
|
3966
4723
|
const settled = await Promise.allSettled(tasks);
|
|
3967
4724
|
for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
|
|
3968
4725
|
}
|
|
@@ -4105,6 +4862,9 @@ function validateFonts(og) {
|
|
|
4105
4862
|
*/
|
|
4106
4863
|
function validateConfig$1(config) {
|
|
4107
4864
|
if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$5}.outDir: must be a non-empty string.`);
|
|
4865
|
+
if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$5}.publicDir: must be a string when set.`);
|
|
4866
|
+
if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$5}.template: must be a string path when set.`);
|
|
4867
|
+
if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$5}.clientEntry: must be a string path when set.`);
|
|
4108
4868
|
if (config.ogImage) validateFonts(config.ogImage);
|
|
4109
4869
|
}
|
|
4110
4870
|
//#endregion
|
|
@@ -5072,7 +5832,7 @@ function spaEvents(register) {
|
|
|
5072
5832
|
}
|
|
5073
5833
|
//#endregion
|
|
5074
5834
|
//#region src/plugins/spa/types.ts
|
|
5075
|
-
var types_exports$
|
|
5835
|
+
var types_exports$8 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
|
|
5076
5836
|
/** Allowed hook names — single source of truth for fail-fast validation. */
|
|
5077
5837
|
const COMPONENT_HOOK_NAMES = [
|
|
5078
5838
|
"onCreate",
|
|
@@ -5542,11 +6302,12 @@ function resolveClickTarget(event) {
|
|
|
5542
6302
|
* Navigation API is unavailable).
|
|
5543
6303
|
*
|
|
5544
6304
|
* @param handlers - The navigation lifecycle callbacks.
|
|
6305
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5545
6306
|
* @returns A teardown that removes the attached listeners.
|
|
5546
6307
|
* @example
|
|
5547
6308
|
* const dispose = attachHistoryFallback(handlers);
|
|
5548
6309
|
*/
|
|
5549
|
-
function attachHistoryFallback(handlers) {
|
|
6310
|
+
function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
5550
6311
|
/**
|
|
5551
6312
|
* Intercept an internal-link click and run a History-API navigation.
|
|
5552
6313
|
*
|
|
@@ -5567,7 +6328,7 @@ function attachHistoryFallback(handlers) {
|
|
|
5567
6328
|
}
|
|
5568
6329
|
saveScrollPosition(location.pathname);
|
|
5569
6330
|
history.pushState({ scrollY: 0 }, "", url.pathname);
|
|
5570
|
-
|
|
6331
|
+
navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
|
|
5571
6332
|
};
|
|
5572
6333
|
/**
|
|
5573
6334
|
* Re-run navigation on back/forward, restoring the saved scroll position.
|
|
@@ -5576,7 +6337,7 @@ function attachHistoryFallback(handlers) {
|
|
|
5576
6337
|
* globalThis.addEventListener("popstate", onPopState);
|
|
5577
6338
|
*/
|
|
5578
6339
|
const onPopState = () => {
|
|
5579
|
-
|
|
6340
|
+
navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
|
|
5580
6341
|
};
|
|
5581
6342
|
document.addEventListener("click", onClick);
|
|
5582
6343
|
globalThis.addEventListener("popstate", onPopState);
|
|
@@ -5590,11 +6351,12 @@ function attachHistoryFallback(handlers) {
|
|
|
5590
6351
|
*
|
|
5591
6352
|
* @param navigation - The Navigation API object to attach the listener to.
|
|
5592
6353
|
* @param handlers - The navigation lifecycle callbacks.
|
|
6354
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5593
6355
|
* @returns A teardown that removes the `navigate` listener.
|
|
5594
6356
|
* @example
|
|
5595
6357
|
* const dispose = attachNavigationApi(navigation, handlers);
|
|
5596
6358
|
*/
|
|
5597
|
-
function attachNavigationApi(navigation, handlers) {
|
|
6359
|
+
function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
5598
6360
|
/**
|
|
5599
6361
|
* Handle a `navigate` event: classify, then intercept with fetch-and-swap.
|
|
5600
6362
|
*
|
|
@@ -5619,7 +6381,7 @@ function attachNavigationApi(navigation, handlers) {
|
|
|
5619
6381
|
navEvent.intercept({
|
|
5620
6382
|
scroll: "manual",
|
|
5621
6383
|
handler: async () => {
|
|
5622
|
-
await
|
|
6384
|
+
await navigate(url.pathname);
|
|
5623
6385
|
if (navEvent.navigationType === "traverse") navEvent.scroll();
|
|
5624
6386
|
else window.scrollTo(0, 0);
|
|
5625
6387
|
}
|
|
@@ -5633,13 +6395,14 @@ function attachNavigationApi(navigation, handlers) {
|
|
|
5633
6395
|
* fallback. Returns a teardown removing every listener it attached.
|
|
5634
6396
|
*
|
|
5635
6397
|
* @param handlers - The navigation lifecycle callbacks the kernel supplies.
|
|
6398
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5636
6399
|
* @returns A teardown removing all attached listeners.
|
|
5637
6400
|
* @example
|
|
5638
|
-
* const dispose = attachRouter(handlers);
|
|
6401
|
+
* const dispose = attachRouter(handlers, navigate);
|
|
5639
6402
|
*/
|
|
5640
|
-
function attachRouter(handlers) {
|
|
6403
|
+
function attachRouter(handlers, navigate) {
|
|
5641
6404
|
const navigation = getNavigation();
|
|
5642
|
-
return navigation ? attachNavigationApi(navigation, handlers) : attachHistoryFallback(handlers);
|
|
6405
|
+
return navigation ? attachNavigationApi(navigation, handlers, navigate) : attachHistoryFallback(handlers, navigate);
|
|
5643
6406
|
}
|
|
5644
6407
|
//#endregion
|
|
5645
6408
|
//#region src/plugins/spa/state.ts
|
|
@@ -5758,6 +6521,20 @@ function currentLocationUrl() {
|
|
|
5758
6521
|
return location.pathname + location.search;
|
|
5759
6522
|
}
|
|
5760
6523
|
/**
|
|
6524
|
+
* Apply the matched route's `head` config to the live document (minimal client
|
|
6525
|
+
* head-sync for the DATA path: title only — the full meta sync runs on the
|
|
6526
|
+
* HTML-over-fetch path from the fetched `<head>`).
|
|
6527
|
+
*
|
|
6528
|
+
* @param route - The matched route definition.
|
|
6529
|
+
* @param routeContext - The render context (params/data/locale).
|
|
6530
|
+
* @example
|
|
6531
|
+
* syncDataHead(hit.route, { params, data, locale });
|
|
6532
|
+
*/
|
|
6533
|
+
function syncDataHead(route, routeContext) {
|
|
6534
|
+
const title = route._handlers.head?.(routeContext)?.title;
|
|
6535
|
+
if (title !== void 0 && title !== "") document.title = title;
|
|
6536
|
+
}
|
|
6537
|
+
/**
|
|
5761
6538
|
* Builds the single shared SPA kernel — a pure factory over state/config/emit.
|
|
5762
6539
|
* Unit-testable with a mock state object and a spy emit; no Moku ctx involved.
|
|
5763
6540
|
*
|
|
@@ -5821,6 +6598,71 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5821
6598
|
onEnd: handleEnd,
|
|
5822
6599
|
onError: handleError
|
|
5823
6600
|
};
|
|
6601
|
+
/**
|
|
6602
|
+
* The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
|
|
6603
|
+
* `data` reader, VALIDATE it through the route's `parse` gate, then run the
|
|
6604
|
+
* route's OWN `render` (the same component the build used for SSG) and
|
|
6605
|
+
* Preact-render the VNode into the swap region. Returns `false` (touching nothing
|
|
6606
|
+
* the fallback cares about) on no-match / no-render / no-data / fetch-miss /
|
|
6607
|
+
* parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
|
|
6608
|
+
* run on the client — the build already persisted its output.
|
|
6609
|
+
*
|
|
6610
|
+
* @param pathname - The destination pathname (search stripped for matching).
|
|
6611
|
+
* @returns `true` if the route was rendered from validated data, else `false`.
|
|
6612
|
+
* @example
|
|
6613
|
+
* if (await tryDataRender("/en/world/")) return;
|
|
6614
|
+
*/
|
|
6615
|
+
const tryDataRender = async (pathname) => {
|
|
6616
|
+
if (!deps.dataAt) return false;
|
|
6617
|
+
const matchPath = pathname.split("?")[0] ?? pathname;
|
|
6618
|
+
const hit = deps.router.match(matchPath);
|
|
6619
|
+
if (!hit?.route._handlers.render) return false;
|
|
6620
|
+
try {
|
|
6621
|
+
const raw = await deps.dataAt(pathname);
|
|
6622
|
+
if (raw === null) return false;
|
|
6623
|
+
const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
|
|
6624
|
+
const locale = hit.params.lang ?? document.documentElement.lang ?? "";
|
|
6625
|
+
const routeContext = {
|
|
6626
|
+
params: hit.params,
|
|
6627
|
+
data,
|
|
6628
|
+
locale
|
|
6629
|
+
};
|
|
6630
|
+
const vnode = hit.route._handlers.render(routeContext);
|
|
6631
|
+
const region = document.querySelector(resolved.swapSelector);
|
|
6632
|
+
if (!region) return false;
|
|
6633
|
+
handleStart(pathname);
|
|
6634
|
+
const { renderVNode } = await import("./render-BL9Fv6G6.mjs");
|
|
6635
|
+
syncDataHead(hit.route, routeContext);
|
|
6636
|
+
unmountPageSpecific(state, emit);
|
|
6637
|
+
runSwap(() => {
|
|
6638
|
+
region.replaceChildren();
|
|
6639
|
+
renderVNode(vnode, region);
|
|
6640
|
+
scanAndMount(state, emit, resolved.swapSelector);
|
|
6641
|
+
notifyNavEnd(state);
|
|
6642
|
+
}, resolved.viewTransitions);
|
|
6643
|
+
state.currentUrl = pathname;
|
|
6644
|
+
progress?.done();
|
|
6645
|
+
emit("spa:navigated", { url: pathname });
|
|
6646
|
+
return true;
|
|
6647
|
+
} catch {
|
|
6648
|
+
return false;
|
|
6649
|
+
}
|
|
6650
|
+
};
|
|
6651
|
+
/**
|
|
6652
|
+
* Unified navigation: try the client DATA path first (only when the `data`
|
|
6653
|
+
* plugin is composed), then fall back to HTML-over-fetch (which itself falls
|
|
6654
|
+
* back to a full `location.href` reload). Injected into the router so every
|
|
6655
|
+
* navigation entry point (Navigation API, History, programmatic) goes through it.
|
|
6656
|
+
*
|
|
6657
|
+
* @param pathname - The destination pathname.
|
|
6658
|
+
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
6659
|
+
* @example
|
|
6660
|
+
* await navigate("/en/world/");
|
|
6661
|
+
*/
|
|
6662
|
+
const navigate = async (pathname) => {
|
|
6663
|
+
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
|
|
6664
|
+
await performNavigation(pathname, handlers);
|
|
6665
|
+
};
|
|
5824
6666
|
return {
|
|
5825
6667
|
/**
|
|
5826
6668
|
* Register config components and seed currentUrl from the document.
|
|
@@ -5843,7 +6685,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5843
6685
|
if (state.started) throw new Error(`${ERROR_PREFIX} spa kernel already started.\n Call app.stop() before booting again (single boot per app).`);
|
|
5844
6686
|
progress = createProgressBar(resolved.progressBar);
|
|
5845
6687
|
state.currentUrl = currentLocationUrl();
|
|
5846
|
-
state.destroyRouter = attachRouter(handlers);
|
|
6688
|
+
state.destroyRouter = attachRouter(handlers, navigate);
|
|
5847
6689
|
scanAndMount(state, emit, resolved.swapSelector);
|
|
5848
6690
|
state.started = true;
|
|
5849
6691
|
},
|
|
@@ -5866,7 +6708,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5866
6708
|
*/
|
|
5867
6709
|
processNav(path) {
|
|
5868
6710
|
if (typeof document === "undefined") return;
|
|
5869
|
-
|
|
6711
|
+
navigate(path).catch(() => {});
|
|
5870
6712
|
},
|
|
5871
6713
|
/**
|
|
5872
6714
|
* Scan the swap region and mount components for matching elements.
|
|
@@ -5893,20 +6735,41 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5893
6735
|
};
|
|
5894
6736
|
}
|
|
5895
6737
|
/**
|
|
6738
|
+
* Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
|
|
6739
|
+
* a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
|
|
6740
|
+
* WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
|
|
6741
|
+
* its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
|
|
6742
|
+
*/
|
|
6743
|
+
const dataPluginHandle = {
|
|
6744
|
+
name: "data",
|
|
6745
|
+
spec: void 0,
|
|
6746
|
+
_phantom: {
|
|
6747
|
+
config: void 0,
|
|
6748
|
+
state: void 0,
|
|
6749
|
+
api: void 0,
|
|
6750
|
+
events: {}
|
|
6751
|
+
}
|
|
6752
|
+
};
|
|
6753
|
+
/**
|
|
5896
6754
|
* Builds the shared kernel from the plugin context, stores it on `ctx.state`
|
|
5897
6755
|
* and `kernelRef`, and runs its init step (validate config, register
|
|
5898
|
-
* config.components, seed currentUrl).
|
|
5899
|
-
*
|
|
6756
|
+
* config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
|
|
6757
|
+
* the `data` plugin is composed (enabling client DATA navigation).
|
|
5900
6758
|
*
|
|
5901
|
-
* @param ctx - The plugin context (state/config/emit/require/log).
|
|
6759
|
+
* @param ctx - The plugin context (state/config/emit/require/has/log).
|
|
5902
6760
|
* @example
|
|
5903
6761
|
* initSpa(ctx);
|
|
5904
6762
|
*/
|
|
5905
6763
|
function initSpa(ctx) {
|
|
5906
|
-
const
|
|
6764
|
+
const deps = {
|
|
5907
6765
|
router: ctx.require(routerPlugin),
|
|
5908
6766
|
head: ctx.require(headPlugin)
|
|
5909
|
-
}
|
|
6767
|
+
};
|
|
6768
|
+
if (ctx.has("data")) {
|
|
6769
|
+
const reader = ctx.require(dataPluginHandle);
|
|
6770
|
+
deps.dataAt = (path) => reader.at(path);
|
|
6771
|
+
}
|
|
6772
|
+
const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
|
|
5910
6773
|
ctx.state.kernel = kernel;
|
|
5911
6774
|
kernelRef.current = kernel;
|
|
5912
6775
|
kernel.init();
|
|
@@ -6008,24 +6871,177 @@ var types_exports = /* @__PURE__ */ __exportAll({});
|
|
|
6008
6871
|
//#region src/plugins/content/types.ts
|
|
6009
6872
|
var types_exports$1 = /* @__PURE__ */ __exportAll({});
|
|
6010
6873
|
//#endregion
|
|
6011
|
-
//#region src/plugins/
|
|
6874
|
+
//#region src/plugins/data/types.ts
|
|
6012
6875
|
var types_exports$2 = /* @__PURE__ */ __exportAll({});
|
|
6013
6876
|
//#endregion
|
|
6014
|
-
//#region src/plugins/
|
|
6877
|
+
//#region src/plugins/deploy/types.ts
|
|
6015
6878
|
var types_exports$3 = /* @__PURE__ */ __exportAll({});
|
|
6016
6879
|
//#endregion
|
|
6017
|
-
//#region src/plugins/
|
|
6880
|
+
//#region src/plugins/env/types.ts
|
|
6018
6881
|
var types_exports$4 = /* @__PURE__ */ __exportAll({});
|
|
6019
6882
|
//#endregion
|
|
6020
|
-
//#region src/plugins/
|
|
6883
|
+
//#region src/plugins/head/types.ts
|
|
6021
6884
|
var types_exports$5 = /* @__PURE__ */ __exportAll({});
|
|
6022
6885
|
//#endregion
|
|
6023
|
-
//#region src/plugins/
|
|
6886
|
+
//#region src/plugins/log/types.ts
|
|
6024
6887
|
var types_exports$6 = /* @__PURE__ */ __exportAll({});
|
|
6025
6888
|
//#endregion
|
|
6889
|
+
//#region src/plugins/router/types.ts
|
|
6890
|
+
var types_exports$7 = /* @__PURE__ */ __exportAll({});
|
|
6891
|
+
//#endregion
|
|
6892
|
+
//#region src/plugins/env/providers.ts
|
|
6893
|
+
/**
|
|
6894
|
+
* @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
|
|
6895
|
+
*/
|
|
6896
|
+
/** Default dotenv file path: optional local overrides. */
|
|
6897
|
+
const DEFAULT_DOTENV_PATH = ".env.local";
|
|
6898
|
+
/** Property on `globalThis` that the consumer sets per Cloudflare request. */
|
|
6899
|
+
const CLOUDFLARE_GLOBAL = "__CLOUDFLARE_ENV__";
|
|
6900
|
+
/**
|
|
6901
|
+
* Strips a single matching pair of surrounding double or single quotes from a
|
|
6902
|
+
* value. Leaves unquoted values (and trailing inline comments) untouched.
|
|
6903
|
+
*
|
|
6904
|
+
* @param value - The already-trimmed raw value.
|
|
6905
|
+
* @returns The value with one outer quote pair removed, if present.
|
|
6906
|
+
* @example
|
|
6907
|
+
* ```ts
|
|
6908
|
+
* stripQuotes('"a"'); // "a"
|
|
6909
|
+
* stripQuotes("plain # c"); // "plain # c"
|
|
6910
|
+
* ```
|
|
6911
|
+
*/
|
|
6912
|
+
function stripQuotes(value) {
|
|
6913
|
+
if (value.length >= 2) {
|
|
6914
|
+
const first = value[0];
|
|
6915
|
+
const last = value.at(-1);
|
|
6916
|
+
if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
|
|
6917
|
+
}
|
|
6918
|
+
return value;
|
|
6919
|
+
}
|
|
6920
|
+
/**
|
|
6921
|
+
* Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
|
|
6922
|
+
* full-line `#` comments, first-`=` splitting, key/value trimming, and a single
|
|
6923
|
+
* outer quote pair. Does not strip trailing inline comments on unquoted values.
|
|
6924
|
+
*
|
|
6925
|
+
* @param text - The raw file contents.
|
|
6926
|
+
* @returns A flat record of parsed key/value pairs.
|
|
6927
|
+
* @example
|
|
6928
|
+
* ```ts
|
|
6929
|
+
* parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
|
|
6930
|
+
* ```
|
|
6931
|
+
*/
|
|
6932
|
+
function parseDotenv(text) {
|
|
6933
|
+
const out = {};
|
|
6934
|
+
for (const line of text.split(/\r?\n/)) {
|
|
6935
|
+
const trimmed = line.trim();
|
|
6936
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
6937
|
+
const eq = trimmed.indexOf("=");
|
|
6938
|
+
if (eq === -1) continue;
|
|
6939
|
+
const key = trimmed.slice(0, eq).trim();
|
|
6940
|
+
out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
|
|
6941
|
+
}
|
|
6942
|
+
return out;
|
|
6943
|
+
}
|
|
6944
|
+
/**
|
|
6945
|
+
* A zero-dependency `.env`-style provider that re-reads and re-parses the file
|
|
6946
|
+
* from disk on every `load()`. Missing file resolves to `{}` (optional
|
|
6947
|
+
* overrides). Strips a single outer quote pair; does not strip trailing inline
|
|
6948
|
+
* comments on unquoted values.
|
|
6949
|
+
*
|
|
6950
|
+
* @param path - Path to the dotenv file. Defaults to `.env.local`.
|
|
6951
|
+
* @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
|
|
6952
|
+
* @example
|
|
6953
|
+
* ```ts
|
|
6954
|
+
* const provider = dotenv(".env.local");
|
|
6955
|
+
* provider.load(); // { PUBLIC_API_URL: "/api", ... }
|
|
6956
|
+
* ```
|
|
6957
|
+
*/
|
|
6958
|
+
function dotenv(path = DEFAULT_DOTENV_PATH) {
|
|
6959
|
+
return {
|
|
6960
|
+
name: `dotenv:${path}`,
|
|
6961
|
+
/**
|
|
6962
|
+
* Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
|
|
6963
|
+
*
|
|
6964
|
+
* @returns The parsed environment record, or `{}` when the file is absent.
|
|
6965
|
+
* @example
|
|
6966
|
+
* ```ts
|
|
6967
|
+
* dotenv(".env.local").load();
|
|
6968
|
+
* ```
|
|
6969
|
+
*/
|
|
6970
|
+
load() {
|
|
6971
|
+
if (!existsSync(path)) return {};
|
|
6972
|
+
return parseDotenv(readFileSync(path, "utf8"));
|
|
6973
|
+
}
|
|
6974
|
+
};
|
|
6975
|
+
}
|
|
6976
|
+
/**
|
|
6977
|
+
* A provider that returns a shallow copy of `process.env` at `load()` time.
|
|
6978
|
+
*
|
|
6979
|
+
* @returns An {@link EnvProvider} named `process-env`.
|
|
6980
|
+
* @example
|
|
6981
|
+
* ```ts
|
|
6982
|
+
* const provider = processEnv();
|
|
6983
|
+
* provider.load().HOME; // current process value
|
|
6984
|
+
* ```
|
|
6985
|
+
*/
|
|
6986
|
+
function processEnv() {
|
|
6987
|
+
return {
|
|
6988
|
+
name: "process-env",
|
|
6989
|
+
/**
|
|
6990
|
+
* Returns a shallow copy of `process.env` at call time.
|
|
6991
|
+
*
|
|
6992
|
+
* @returns A fresh shallow copy of `process.env`.
|
|
6993
|
+
* @example
|
|
6994
|
+
* ```ts
|
|
6995
|
+
* processEnv().load();
|
|
6996
|
+
* ```
|
|
6997
|
+
*/
|
|
6998
|
+
load() {
|
|
6999
|
+
return { ...process.env };
|
|
7000
|
+
}
|
|
7001
|
+
};
|
|
7002
|
+
}
|
|
7003
|
+
/**
|
|
7004
|
+
* A provider that reads live, per-request Cloudflare bindings from
|
|
7005
|
+
* `globalThis.__CLOUDFLARE_ENV__` at `load()` time (`?? {}` when absent). Never
|
|
7006
|
+
* caches the binding object; the consumer owns the global's request lifecycle.
|
|
7007
|
+
*
|
|
7008
|
+
* @returns An {@link EnvProvider} named `cloudflare`.
|
|
7009
|
+
* @example
|
|
7010
|
+
* ```ts
|
|
7011
|
+
* globalThis.__CLOUDFLARE_ENV__ = env; // set by the request handler
|
|
7012
|
+
* const provider = cloudflareBindings();
|
|
7013
|
+
* provider.load(); // reads the current request's bindings
|
|
7014
|
+
* ```
|
|
7015
|
+
*/
|
|
7016
|
+
function cloudflareBindings() {
|
|
7017
|
+
return {
|
|
7018
|
+
name: "cloudflare",
|
|
7019
|
+
/**
|
|
7020
|
+
* Reads `globalThis.__CLOUDFLARE_ENV__` fresh, never caching the bindings.
|
|
7021
|
+
*
|
|
7022
|
+
* @returns The current Cloudflare bindings, or `{}` when the global is unset.
|
|
7023
|
+
* @example
|
|
7024
|
+
* ```ts
|
|
7025
|
+
* cloudflareBindings().load();
|
|
7026
|
+
* ```
|
|
7027
|
+
*/
|
|
7028
|
+
load() {
|
|
7029
|
+
return globalThis[CLOUDFLARE_GLOBAL] ?? {};
|
|
7030
|
+
}
|
|
7031
|
+
};
|
|
7032
|
+
}
|
|
7033
|
+
//#endregion
|
|
6026
7034
|
//#region src/index.ts
|
|
6027
7035
|
/**
|
|
6028
7036
|
* @file `@moku-labs/web` — a Moku Layer-2 content static-site + SPA framework.
|
|
7037
|
+
*
|
|
7038
|
+
* `createApp`'s defaults are the **isomorphic** plugins that run unchanged on both
|
|
7039
|
+
* Node and the browser (`site`, `i18n`, `router`, `head`, `spa`, plus the
|
|
7040
|
+
* `log`/`env` core). The Node-only plugins (`content`, `build`, `deploy`,
|
|
7041
|
+
* `data`) are exported for Layer-3 composition: add them with
|
|
7042
|
+
* `createApp({ plugins: [...] })` in a Node build; omit them in a browser app.
|
|
7043
|
+
* The framework never hard-blocks either runtime — the consumer composes the
|
|
7044
|
+
* variant it needs and supplies the matching `env` provider.
|
|
6029
7045
|
* @see README.md
|
|
6030
7046
|
*/
|
|
6031
7047
|
const framework = createCore(coreConfig, {
|
|
@@ -6033,11 +7049,8 @@ const framework = createCore(coreConfig, {
|
|
|
6033
7049
|
sitePlugin,
|
|
6034
7050
|
i18nPlugin,
|
|
6035
7051
|
routerPlugin,
|
|
6036
|
-
contentPlugin,
|
|
6037
7052
|
headPlugin,
|
|
6038
|
-
|
|
6039
|
-
spaPlugin,
|
|
6040
|
-
deployPlugin
|
|
7053
|
+
spaPlugin
|
|
6041
7054
|
],
|
|
6042
7055
|
pluginConfigs: {}
|
|
6043
7056
|
});
|
|
@@ -6046,22 +7059,28 @@ const framework = createCore(coreConfig, {
|
|
|
6046
7059
|
* Your overrides are merged over the framework defaults through the 4-level config
|
|
6047
7060
|
* cascade, every plugin's lifecycle runs, and a fully-typed, frozen app is returned.
|
|
6048
7061
|
*
|
|
7062
|
+
* The defaults are the isomorphic plugin set (`site`, `i18n`, `router`, `head`,
|
|
7063
|
+
* `spa` + `log`/`env` core). Add the Node-only plugins for an SSG build:
|
|
7064
|
+
* `createApp({ plugins: [contentPlugin, buildPlugin, deployPlugin] })`.
|
|
7065
|
+
*
|
|
6049
7066
|
* @param options - Optional configuration:
|
|
6050
|
-
* - `pluginConfigs` — per-plugin overrides, keyed by plugin name
|
|
6051
|
-
* (`site`, `i18n`, `router`, `content`, `head`, `build`, `spa`, `deploy`, `env`).
|
|
7067
|
+
* - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
|
|
6052
7068
|
* - `config` — global framework config (e.g. `{ mode: "development" }`).
|
|
6053
|
-
* - `plugins` — extra
|
|
7069
|
+
* - `plugins` — extra plugins (Node-only built-ins or your own) merged into the app and its type.
|
|
6054
7070
|
* - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
|
|
6055
7071
|
* @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
|
|
6056
7072
|
* @example
|
|
6057
7073
|
* ```ts
|
|
7074
|
+
* // Node SSG build — add the node-only plugins:
|
|
6058
7075
|
* const app = createApp({
|
|
7076
|
+
* plugins: [contentPlugin, buildPlugin, deployPlugin],
|
|
6059
7077
|
* pluginConfigs: {
|
|
6060
7078
|
* site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes" },
|
|
6061
7079
|
* router: { routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
|
|
6062
7080
|
* }
|
|
6063
7081
|
* });
|
|
6064
7082
|
* await app.start();
|
|
7083
|
+
* await app.build.run();
|
|
6065
7084
|
* ```
|
|
6066
7085
|
*/
|
|
6067
7086
|
const createApp = framework.createApp;
|
|
@@ -6082,4 +7101,4 @@ const createApp = framework.createApp;
|
|
|
6082
7101
|
*/
|
|
6083
7102
|
const createPlugin = framework.createPlugin;
|
|
6084
7103
|
//#endregion
|
|
6085
|
-
export { types_exports as Build, types_exports$1 as Content, types_exports$2 as
|
|
7104
|
+
export { types_exports as Build, types_exports$1 as Content, types_exports$2 as Data, types_exports$3 as Deploy, types_exports$4 as Env, types_exports$5 as Head, types_exports$6 as Log, types_exports$7 as Router, types_exports$8 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cloudflareBindings, contentPlugin, createApp, createPlugin, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
|