@moku-labs/web 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -12
- package/dist/convention-Dr8jxG70.cjs +81 -0
- package/dist/convention-X3zLTlJ8.mjs +33 -0
- package/dist/index.cjs +1503 -295
- package/dist/index.d.cts +1145 -632
- package/dist/index.d.mts +1144 -631
- package/dist/index.mjs +1478 -249
- 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,12 +733,29 @@ 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
|
+
/**
|
|
739
|
+
* Create a custom plugin bound to this framework's `Config`/`Events` and the core
|
|
740
|
+
* plugin APIs (`log`, `env`). Plugin types are fully inferred from the spec
|
|
741
|
+
* object — never write them explicitly. This is the binding every built-in
|
|
742
|
+
* plugin is wired with, and the one consumer plugins should use too.
|
|
743
|
+
*
|
|
744
|
+
* @example
|
|
745
|
+
* ```ts
|
|
746
|
+
* const analytics = createPlugin("analytics", {
|
|
747
|
+
* config: { writeKey: "" },
|
|
748
|
+
* api: (ctx) => ({ track: (event: string) => ctx.log.info("analytics:track", { event }) })
|
|
749
|
+
* });
|
|
750
|
+
* ```
|
|
751
|
+
*/
|
|
752
|
+
const createPlugin$1 = coreConfig.createPlugin;
|
|
753
|
+
/**
|
|
754
|
+
* Step 2 of the factory chain — captures the framework's default plugin set and
|
|
755
|
+
* returns the consumer entry points ({@link createApp} + a re-exported
|
|
756
|
+
* `createPlugin`). Wired once in `src/index.ts`; consumers don't call it directly.
|
|
757
|
+
*/
|
|
758
|
+
const createCore = coreConfig.createCore;
|
|
810
759
|
//#endregion
|
|
811
760
|
//#region src/plugins/i18n/api.ts
|
|
812
761
|
/** Error prefix for all i18n lifecycle failures. */
|
|
@@ -934,6 +883,25 @@ function createI18nApi(ctx) {
|
|
|
934
883
|
}
|
|
935
884
|
};
|
|
936
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* Internationalization plugin — locale registry plus a flat translation helper
|
|
888
|
+
* with default-locale fallback. Pure config-as-data (no state or events);
|
|
889
|
+
* consumed read-only by content, router, head, and build.
|
|
890
|
+
*
|
|
891
|
+
* @example Register locales and translations
|
|
892
|
+
* ```ts
|
|
893
|
+
* const app = createApp({
|
|
894
|
+
* pluginConfigs: {
|
|
895
|
+
* i18n: {
|
|
896
|
+
* locales: ["en", "uk"],
|
|
897
|
+
* defaultLocale: "en",
|
|
898
|
+
* localeNames: { en: "English", uk: "Українська" },
|
|
899
|
+
* translations: { uk: { "nav.home": "Головна" } }
|
|
900
|
+
* }
|
|
901
|
+
* }
|
|
902
|
+
* });
|
|
903
|
+
* ```
|
|
904
|
+
*/
|
|
937
905
|
const i18nPlugin = createPlugin$1("i18n", {
|
|
938
906
|
config: {
|
|
939
907
|
locales: ["en"],
|
|
@@ -1334,6 +1302,23 @@ function articleToUrl(locale, slug) {
|
|
|
1334
1302
|
return `/${locale}/${slug}/`;
|
|
1335
1303
|
}
|
|
1336
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
|
+
/**
|
|
1337
1322
|
* Plugin `api` factory: assembles the kernel-free {@link ContentApiContext} from
|
|
1338
1323
|
* the plugin context (resolving i18n via `ctx.require`) and delegates to
|
|
1339
1324
|
* {@link createContentApi}. Referenced directly as the plugin's `api` so
|
|
@@ -1567,11 +1552,16 @@ function createContentApi(ctx) {
|
|
|
1567
1552
|
/**
|
|
1568
1553
|
* Resolve and render a single article for one locale with locale fallback.
|
|
1569
1554
|
* Throws a `[web] content` error when neither the requested nor the
|
|
1570
|
-
* 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.
|
|
1571
1559
|
*
|
|
1572
1560
|
* @param slug - Article directory name.
|
|
1573
1561
|
* @param locale - Requested locale code.
|
|
1574
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"`.
|
|
1575
1565
|
* @example
|
|
1576
1566
|
* ```ts
|
|
1577
1567
|
* const article = await api.load("intro", "uk");
|
|
@@ -1579,7 +1569,8 @@ function createContentApi(ctx) {
|
|
|
1579
1569
|
*/
|
|
1580
1570
|
async load(slug, locale) {
|
|
1581
1571
|
const article = await resolveArticle(ctx, slug, locale);
|
|
1582
|
-
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);
|
|
1583
1574
|
const cache = ctx.state.articles.get(locale) ?? /* @__PURE__ */ new Map();
|
|
1584
1575
|
cache.set(slug, article);
|
|
1585
1576
|
ctx.state.articles.set(locale, cache);
|
|
@@ -1729,6 +1720,26 @@ function validateContentConfig(config) {
|
|
|
1729
1720
|
* and `content:invalidated`.
|
|
1730
1721
|
* @see README.md
|
|
1731
1722
|
*/
|
|
1723
|
+
/**
|
|
1724
|
+
* Content plugin — Markdown pipeline: discovers files, parses frontmatter, renders
|
|
1725
|
+
* to sanitized HTML (rehype-sanitize unless `trustedContent`), and exposes a
|
|
1726
|
+
* locale-keyed Article model. Depends on i18n; emits `content:ready` and
|
|
1727
|
+
* `content:invalidated`.
|
|
1728
|
+
*
|
|
1729
|
+
* @example Point at a content directory and pick a Shiki theme
|
|
1730
|
+
* ```ts
|
|
1731
|
+
* const app = createApp({
|
|
1732
|
+
* pluginConfigs: {
|
|
1733
|
+
* content: {
|
|
1734
|
+
* contentDir: "./content",
|
|
1735
|
+
* shikiTheme: "github-dark",
|
|
1736
|
+
* defaultAuthor: "Ada Lovelace"
|
|
1737
|
+
* // trustedContent: true // ONLY for fully author-controlled Markdown — disables sanitize
|
|
1738
|
+
* }
|
|
1739
|
+
* }
|
|
1740
|
+
* });
|
|
1741
|
+
* ```
|
|
1742
|
+
*/
|
|
1732
1743
|
const contentPlugin = createPlugin$1("content", {
|
|
1733
1744
|
depends: [i18nPlugin],
|
|
1734
1745
|
events: contentEvents,
|
|
@@ -1892,6 +1903,25 @@ function createSiteApi(ctx) {
|
|
|
1892
1903
|
}
|
|
1893
1904
|
};
|
|
1894
1905
|
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Site plugin — holds global, frozen site metadata (name, url, author,
|
|
1908
|
+
* description) and builds canonical URLs. Consumed by router, head, and build.
|
|
1909
|
+
* `name` and `url` must be non-empty (validated at `onInit`).
|
|
1910
|
+
*
|
|
1911
|
+
* @example Set your site identity
|
|
1912
|
+
* ```ts
|
|
1913
|
+
* const app = createApp({
|
|
1914
|
+
* pluginConfigs: {
|
|
1915
|
+
* site: {
|
|
1916
|
+
* name: "My Blog",
|
|
1917
|
+
* url: "https://blog.dev",
|
|
1918
|
+
* author: "Ada Lovelace",
|
|
1919
|
+
* description: "Notes on computing"
|
|
1920
|
+
* }
|
|
1921
|
+
* }
|
|
1922
|
+
* });
|
|
1923
|
+
* ```
|
|
1924
|
+
*/
|
|
1895
1925
|
const sitePlugin = createPlugin$1("site", {
|
|
1896
1926
|
config: {
|
|
1897
1927
|
name: "",
|
|
@@ -2018,6 +2048,24 @@ function toTypedRoute(entry) {
|
|
|
2018
2048
|
};
|
|
2019
2049
|
}
|
|
2020
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
|
+
/**
|
|
2021
2069
|
* Creates the router plugin API surface. Every closure reads the compiled table
|
|
2022
2070
|
* from `ctx.state` and returns values/fresh copies — never the raw state arrays.
|
|
2023
2071
|
*
|
|
@@ -2087,11 +2135,113 @@ function createApi$4(ctx) {
|
|
|
2087
2135
|
*/
|
|
2088
2136
|
manifest() {
|
|
2089
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;
|
|
2090
2163
|
}
|
|
2091
2164
|
};
|
|
2092
2165
|
}
|
|
2093
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
|
|
2094
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
|
+
*/
|
|
2095
2245
|
/** Shared `[web]` error prefix for router validation failures. */
|
|
2096
2246
|
const ERROR_PREFIX$8 = "[web] router";
|
|
2097
2247
|
/**
|
|
@@ -2206,27 +2356,6 @@ function buildFilePath(pattern, params) {
|
|
|
2206
2356
|
return cleanPath === "" ? "index.html" : `${cleanPath}/index.html`;
|
|
2207
2357
|
}
|
|
2208
2358
|
/**
|
|
2209
|
-
* Count dynamic segments in a pattern (lower = more specific). The optional
|
|
2210
|
-
* `{lang:?}` segment is excluded so locale-prefixing does not affect priority.
|
|
2211
|
-
*
|
|
2212
|
-
* @param pattern - The route pattern.
|
|
2213
|
-
* @returns The number of dynamic (non-lang) segments.
|
|
2214
|
-
* @example
|
|
2215
|
-
* ```ts
|
|
2216
|
-
* countDynamicSegments("/{lang:?}/{slug}/"); // 1
|
|
2217
|
-
* ```
|
|
2218
|
-
*/
|
|
2219
|
-
function countDynamicSegments(pattern) {
|
|
2220
|
-
let count = 0;
|
|
2221
|
-
for (const segment of pattern.split("/")) {
|
|
2222
|
-
const placeholder = parsePlaceholder(segment);
|
|
2223
|
-
const isBraceDynamic = placeholder && !(placeholder.name === "lang" && placeholder.optional);
|
|
2224
|
-
const isColonDynamic = !placeholder && segment.startsWith(":");
|
|
2225
|
-
if (isBraceDynamic || isColonDynamic) count += 1;
|
|
2226
|
-
}
|
|
2227
|
-
return count;
|
|
2228
|
-
}
|
|
2229
|
-
/**
|
|
2230
2359
|
* Compile a single route definition into its `CompiledRoute` entry.
|
|
2231
2360
|
*
|
|
2232
2361
|
* @param name - The route name key.
|
|
@@ -2248,7 +2377,7 @@ function compileRoute(name, definition, input) {
|
|
|
2248
2377
|
return {
|
|
2249
2378
|
name,
|
|
2250
2379
|
pattern,
|
|
2251
|
-
dynamicSegmentCount:
|
|
2380
|
+
dynamicSegmentCount: dynamicSegmentCount(pattern),
|
|
2252
2381
|
matchers,
|
|
2253
2382
|
matchFn: createMatchFunction(matchers, input.defaultLocale),
|
|
2254
2383
|
/**
|
|
@@ -2305,10 +2434,7 @@ function compileRoutes(input) {
|
|
|
2305
2434
|
byName.set(name, entry);
|
|
2306
2435
|
}
|
|
2307
2436
|
return {
|
|
2308
|
-
compiled: declarationOrder.
|
|
2309
|
-
entry,
|
|
2310
|
-
index
|
|
2311
|
-
})).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),
|
|
2312
2438
|
byName
|
|
2313
2439
|
};
|
|
2314
2440
|
}
|
|
@@ -2421,6 +2547,21 @@ function route(pattern) {
|
|
|
2421
2547
|
return set("render", handler);
|
|
2422
2548
|
},
|
|
2423
2549
|
/**
|
|
2550
|
+
* Attach the client-side validation gate (raw `unknown` → this route's data
|
|
2551
|
+
* type). Runs at the trust boundary before `render` on the client; throw to
|
|
2552
|
+
* reject malformed data (spa falls back to HTML-over-fetch).
|
|
2553
|
+
*
|
|
2554
|
+
* @param handler - The validator/parser.
|
|
2555
|
+
* @returns The same builder for chaining.
|
|
2556
|
+
* @example
|
|
2557
|
+
* ```ts
|
|
2558
|
+
* route("/shop/{id}/").parse(raw => ProductSchema.parse(raw));
|
|
2559
|
+
* ```
|
|
2560
|
+
*/
|
|
2561
|
+
parse(handler) {
|
|
2562
|
+
return set("parse", handler);
|
|
2563
|
+
},
|
|
2564
|
+
/**
|
|
2424
2565
|
* Attach the head/SEO handler.
|
|
2425
2566
|
*
|
|
2426
2567
|
* @param handler - The head handler.
|
|
@@ -2447,9 +2588,11 @@ function route(pattern) {
|
|
|
2447
2588
|
return set("generate", handler);
|
|
2448
2589
|
},
|
|
2449
2590
|
/**
|
|
2450
|
-
* Merge an arbitrary metadata bag into the route's `_meta`.
|
|
2591
|
+
* Merge an arbitrary metadata bag into the route's `_meta`. The bag MUST be
|
|
2592
|
+
* JSON-serializable — it is projected verbatim into `clientManifest()` and
|
|
2593
|
+
* shipped to the browser, so functions/symbols/class instances are unsupported.
|
|
2451
2594
|
*
|
|
2452
|
-
* @param meta -
|
|
2595
|
+
* @param meta - JSON-serializable metadata to merge.
|
|
2453
2596
|
* @returns The same builder for chaining.
|
|
2454
2597
|
* @example
|
|
2455
2598
|
* ```ts
|
|
@@ -2519,8 +2662,31 @@ function defineRoutes(routes) {
|
|
|
2519
2662
|
* ```
|
|
2520
2663
|
*/
|
|
2521
2664
|
function createState$4(_ctx) {
|
|
2522
|
-
return {
|
|
2665
|
+
return {
|
|
2666
|
+
table: null,
|
|
2667
|
+
mode: _ctx.config.mode ?? "hybrid"
|
|
2668
|
+
};
|
|
2523
2669
|
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Router plugin — typed, named route definitions with locale-aware URL generation
|
|
2672
|
+
* and matching. Author routes with {@link route} + {@link defineRoutes}. Depends
|
|
2673
|
+
* on site (base URL) and i18n (locales).
|
|
2674
|
+
*
|
|
2675
|
+
* @example Define routes and choose a render mode
|
|
2676
|
+
* ```ts
|
|
2677
|
+
* const app = createApp({
|
|
2678
|
+
* pluginConfigs: {
|
|
2679
|
+
* router: {
|
|
2680
|
+
* routes: defineRoutes({
|
|
2681
|
+
* home: route("/"),
|
|
2682
|
+
* article: route("/blog/{slug}/")
|
|
2683
|
+
* }),
|
|
2684
|
+
* mode: "hybrid" // "ssg" | "spa" | "hybrid" (default)
|
|
2685
|
+
* }
|
|
2686
|
+
* }
|
|
2687
|
+
* });
|
|
2688
|
+
* ```
|
|
2689
|
+
*/
|
|
2524
2690
|
const routerPlugin = createPlugin$1("router", {
|
|
2525
2691
|
depends: [sitePlugin, i18nPlugin],
|
|
2526
2692
|
helpers: {
|
|
@@ -3034,6 +3200,25 @@ function createState$3(_ctx) {
|
|
|
3034
3200
|
* @file head — Standard Plugin wiring harness (logic in primitives/compose/api/config).
|
|
3035
3201
|
* @see README.md
|
|
3036
3202
|
*/
|
|
3203
|
+
/**
|
|
3204
|
+
* Head plugin — composes per-route `<head>` metadata (title template, Open Graph,
|
|
3205
|
+
* Twitter cards, canonical, hreflang). Use the re-exported SEO primitives
|
|
3206
|
+
* ({@link meta}, {@link og}, {@link twitter}, …) inside a route's `.head()`.
|
|
3207
|
+
* Depends on site, i18n, and router.
|
|
3208
|
+
*
|
|
3209
|
+
* @example Set global head defaults
|
|
3210
|
+
* ```ts
|
|
3211
|
+
* const app = createApp({
|
|
3212
|
+
* pluginConfigs: {
|
|
3213
|
+
* head: {
|
|
3214
|
+
* titleTemplate: "%s — My Blog",
|
|
3215
|
+
* twitterCard: "summary_large_image",
|
|
3216
|
+
* twitterHandle: "@moku_labs"
|
|
3217
|
+
* }
|
|
3218
|
+
* }
|
|
3219
|
+
* });
|
|
3220
|
+
* ```
|
|
3221
|
+
*/
|
|
3037
3222
|
const headPlugin = createPlugin$1("head", {
|
|
3038
3223
|
depends: [
|
|
3039
3224
|
sitePlugin,
|
|
@@ -3098,6 +3283,29 @@ function resolveEntrypoints(candidates) {
|
|
|
3098
3283
|
return [];
|
|
3099
3284
|
}
|
|
3100
3285
|
/**
|
|
3286
|
+
* Resolve the authoritative JS client entrypoint (#8): when `config.clientEntry` is
|
|
3287
|
+
* set, use it directly (the authoritative override); otherwise fall back to the
|
|
3288
|
+
* conventional candidate scan. When neither yields an entry, `ctx.log.warn` (no
|
|
3289
|
+
* client bundle is produced) and an empty list is returned.
|
|
3290
|
+
*
|
|
3291
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
3292
|
+
* @returns The resolved JS entrypoint list (possibly empty).
|
|
3293
|
+
* @example
|
|
3294
|
+
* ```ts
|
|
3295
|
+
* resolveJsEntrypoints(ctx);
|
|
3296
|
+
* ```
|
|
3297
|
+
*/
|
|
3298
|
+
function resolveJsEntrypoints(ctx) {
|
|
3299
|
+
const { clientEntry } = ctx.config;
|
|
3300
|
+
if (typeof clientEntry === "string" && clientEntry.length > 0) return [clientEntry];
|
|
3301
|
+
const scanned = resolveEntrypoints(JS_ENTRY_CANDIDATES);
|
|
3302
|
+
if (scanned.length === 0) ctx.log.warn("build:bundle", {
|
|
3303
|
+
clientEntry: "none",
|
|
3304
|
+
scanned: JS_ENTRY_CANDIDATES
|
|
3305
|
+
});
|
|
3306
|
+
return scanned;
|
|
3307
|
+
}
|
|
3308
|
+
/**
|
|
3101
3309
|
* Run one bundler pass for a single asset kind and record the hashed output
|
|
3102
3310
|
* paths under `state.buildCache` keyed by the original entry basename.
|
|
3103
3311
|
*
|
|
@@ -3145,7 +3353,7 @@ async function bundle(ctx, options = {}) {
|
|
|
3145
3353
|
const runner = options.runner ?? defaultRunner;
|
|
3146
3354
|
const { minify, outDir } = ctx.config;
|
|
3147
3355
|
const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
|
|
3148
|
-
const jsEntrypoints = options.jsEntrypoints ??
|
|
3356
|
+
const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
|
|
3149
3357
|
await runOne(ctx, runner, "css", cssEntrypoints, path.join(outDir, "assets"), minify);
|
|
3150
3358
|
await runOne(ctx, runner, "js", jsEntrypoints, path.join(outDir, "assets"), minify);
|
|
3151
3359
|
}
|
|
@@ -3312,17 +3520,167 @@ async function processImages(ctx, options = {}) {
|
|
|
3312
3520
|
return copied;
|
|
3313
3521
|
}
|
|
3314
3522
|
//#endregion
|
|
3315
|
-
//#region src/plugins/build/phases/
|
|
3523
|
+
//#region src/plugins/build/phases/locale-redirects.ts
|
|
3316
3524
|
/**
|
|
3317
|
-
* @file build phase
|
|
3318
|
-
*
|
|
3319
|
-
*
|
|
3320
|
-
*
|
|
3525
|
+
* @file build phase — locale-redirects. For each non-prefixed route path, emits a
|
|
3526
|
+
* redirect HTML page (`<meta http-equiv="refresh">` + canonical `<link>`) at the
|
|
3527
|
+
* bare path that points at the default-locale-prefixed URL. Deliberately does NOT
|
|
3528
|
+
* emit a Cloudflare `_redirects` catch-all (an SSG infinite-loop trap). Gated by
|
|
3529
|
+
* `config.localeRedirects` (false/unset disables).
|
|
3321
3530
|
*/
|
|
3322
|
-
/**
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3531
|
+
/**
|
|
3532
|
+
* Render a redirect HTML page: a `0;url` refresh meta + a canonical link to `target`.
|
|
3533
|
+
*
|
|
3534
|
+
* @param target - The default-locale-prefixed URL to redirect to.
|
|
3535
|
+
* @returns The complete redirect HTML document string.
|
|
3536
|
+
* @example
|
|
3537
|
+
* ```ts
|
|
3538
|
+
* redirectHtml("/en/about/");
|
|
3539
|
+
* ```
|
|
3540
|
+
*/
|
|
3541
|
+
function redirectHtml(target) {
|
|
3542
|
+
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>`;
|
|
3543
|
+
}
|
|
3544
|
+
/**
|
|
3545
|
+
* Correlate manifest definitions to compiled `TypedRoute` entries by pattern (the
|
|
3546
|
+
* shared stable key); routes without a compiled entry are skipped.
|
|
3547
|
+
*
|
|
3548
|
+
* @param router - The router API exposing `manifest` + `entries`.
|
|
3549
|
+
* @returns Pairs of `[definition, entry]` for every correlated route.
|
|
3550
|
+
* @example
|
|
3551
|
+
* ```ts
|
|
3552
|
+
* pairRoutes(router);
|
|
3553
|
+
* ```
|
|
3554
|
+
*/
|
|
3555
|
+
function pairRoutes(router) {
|
|
3556
|
+
const byPattern = /* @__PURE__ */ new Map();
|
|
3557
|
+
for (const entry of router.entries()) byPattern.set(entry.pattern, entry);
|
|
3558
|
+
const pairs = [];
|
|
3559
|
+
for (const definition of router.manifest()) {
|
|
3560
|
+
const entry = byPattern.get(definition.pattern);
|
|
3561
|
+
if (entry) pairs.push([definition, entry]);
|
|
3562
|
+
}
|
|
3563
|
+
return pairs;
|
|
3564
|
+
}
|
|
3565
|
+
/**
|
|
3566
|
+
* Expand one route into bare→default redirect jobs for the default locale. Uses
|
|
3567
|
+
* `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
|
|
3568
|
+
* only when the bare file path differs from the default-locale URL (i.e. the route
|
|
3569
|
+
* is locale-prefixed) — otherwise no redirect is needed.
|
|
3570
|
+
*
|
|
3571
|
+
* @param definition - The route definition (carries `generate`).
|
|
3572
|
+
* @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
|
|
3573
|
+
* @param defaultLocale - The default locale to redirect bare paths to.
|
|
3574
|
+
* @returns Redirect jobs of `{ file, target }` for this route.
|
|
3575
|
+
* @example
|
|
3576
|
+
* ```ts
|
|
3577
|
+
* await expandRedirects(def, entry, "en");
|
|
3578
|
+
* ```
|
|
3579
|
+
*/
|
|
3580
|
+
async function expandRedirects(definition, entry, defaultLocale) {
|
|
3581
|
+
const parameterSets = definition._handlers.generate ? await definition._handlers.generate(defaultLocale) : [{}];
|
|
3582
|
+
const jobs = [];
|
|
3583
|
+
for (const raw of parameterSets) {
|
|
3584
|
+
const params = raw ?? {};
|
|
3585
|
+
const file = entry.toFile(params);
|
|
3586
|
+
const target = entry.toUrl({
|
|
3587
|
+
...params,
|
|
3588
|
+
lang: defaultLocale
|
|
3589
|
+
});
|
|
3590
|
+
if (target !== entry.toUrl(params)) jobs.push({
|
|
3591
|
+
file,
|
|
3592
|
+
target
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
return jobs;
|
|
3596
|
+
}
|
|
3597
|
+
/**
|
|
3598
|
+
* Emits one bare-path redirect HTML page per locale-prefixed route path, each a
|
|
3599
|
+
* `0;url` refresh + canonical link to the default-locale URL. Never writes a
|
|
3600
|
+
* Cloudflare `_redirects` file. No-op (returns `null`) when `localeRedirects` is
|
|
3601
|
+
* false/unset.
|
|
3602
|
+
*
|
|
3603
|
+
* @param ctx - Plugin context (provides `require`, `config`, `log`).
|
|
3604
|
+
* @returns The count of redirect pages written, or `null` when disabled.
|
|
3605
|
+
* @example
|
|
3606
|
+
* ```ts
|
|
3607
|
+
* const result = await generateLocaleRedirects(ctx);
|
|
3608
|
+
* ```
|
|
3609
|
+
*/
|
|
3610
|
+
async function generateLocaleRedirects(ctx) {
|
|
3611
|
+
if (!ctx.config.localeRedirects) {
|
|
3612
|
+
ctx.log.debug("build:locale-redirects", { skipped: true });
|
|
3613
|
+
return null;
|
|
3614
|
+
}
|
|
3615
|
+
const router = ctx.require(routerPlugin);
|
|
3616
|
+
const defaultLocale = ctx.require(i18nPlugin).defaultLocale();
|
|
3617
|
+
const jobs = (await Promise.all(pairRoutes(router).map(([definition, entry]) => expandRedirects(definition, entry, defaultLocale)))).flat();
|
|
3618
|
+
await Promise.all(jobs.map(async ({ file, target }) => {
|
|
3619
|
+
const filePath = path.join(ctx.config.outDir, file);
|
|
3620
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
3621
|
+
await writeFile(filePath, redirectHtml(target), "utf8");
|
|
3622
|
+
}));
|
|
3623
|
+
ctx.log.debug("build:locale-redirects", { written: jobs.length });
|
|
3624
|
+
return { written: jobs.length };
|
|
3625
|
+
}
|
|
3626
|
+
//#endregion
|
|
3627
|
+
//#region src/plugins/build/phases/not-found.ts
|
|
3628
|
+
/**
|
|
3629
|
+
* @file build phase — not-found. Emits `outDir/404.html` from configured route
|
|
3630
|
+
* content or a built-in default. Gated by `config.notFound` (false/unset disables).
|
|
3631
|
+
*/
|
|
3632
|
+
/** The built-in default 404 page body when no custom route content is supplied. */
|
|
3633
|
+
const DEFAULT_BODY = "<h1>404</h1><p>The page you requested could not be found.</p>";
|
|
3634
|
+
/**
|
|
3635
|
+
* Wrap a body fragment in a minimal HTML document for the 404 page.
|
|
3636
|
+
*
|
|
3637
|
+
* @param body - The inner body HTML (default or configured).
|
|
3638
|
+
* @returns The complete HTML document string.
|
|
3639
|
+
* @example
|
|
3640
|
+
* ```ts
|
|
3641
|
+
* wrap("<h1>404</h1>");
|
|
3642
|
+
* ```
|
|
3643
|
+
*/
|
|
3644
|
+
function wrap(body) {
|
|
3645
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>404 — Not Found</title></head><body>${body}</body></html>`;
|
|
3646
|
+
}
|
|
3647
|
+
/**
|
|
3648
|
+
* Emits `outDir/404.html`. When `config.notFound` is `true`, writes the built-in
|
|
3649
|
+
* default page; when it is `{ route }`, writes the supplied route content verbatim
|
|
3650
|
+
* inside the document shell. No-op (returns `null`) when `notFound` is false/unset.
|
|
3651
|
+
*
|
|
3652
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
3653
|
+
* @returns The written file path, or `null` when disabled.
|
|
3654
|
+
* @example
|
|
3655
|
+
* ```ts
|
|
3656
|
+
* const result = await generateNotFound(ctx);
|
|
3657
|
+
* ```
|
|
3658
|
+
*/
|
|
3659
|
+
async function generateNotFound(ctx) {
|
|
3660
|
+
const { notFound, outDir } = ctx.config;
|
|
3661
|
+
if (!notFound) {
|
|
3662
|
+
ctx.log.debug("build:not-found", { skipped: true });
|
|
3663
|
+
return null;
|
|
3664
|
+
}
|
|
3665
|
+
const body = typeof notFound === "object" && notFound.route ? notFound.route : DEFAULT_BODY;
|
|
3666
|
+
await mkdir(outDir, { recursive: true });
|
|
3667
|
+
const file = path.join(outDir, "404.html");
|
|
3668
|
+
await writeFile(file, wrap(body), "utf8");
|
|
3669
|
+
ctx.log.debug("build:not-found", { path: file });
|
|
3670
|
+
return { path: file };
|
|
3671
|
+
}
|
|
3672
|
+
//#endregion
|
|
3673
|
+
//#region src/plugins/build/phases/og-images.tsx
|
|
3674
|
+
/**
|
|
3675
|
+
* @file build phase 4 — og-images. Renders one OG image per published article via
|
|
3676
|
+
* Satori → SVG → resvg → PNG, bounded by `p-limit(4)`, with a persisted
|
|
3677
|
+
* content-hash cache (`<outDir>/.cache/og-images.json`) skipping unchanged articles.
|
|
3678
|
+
* Gated by config.ogImage (object enables; false disables).
|
|
3679
|
+
*/
|
|
3680
|
+
/** Default OG image dimensions when `size` is omitted. */
|
|
3681
|
+
const DEFAULT_SIZE = {
|
|
3682
|
+
width: 1200,
|
|
3683
|
+
height: 630
|
|
3326
3684
|
};
|
|
3327
3685
|
/** Recognized font file extensions. */
|
|
3328
3686
|
const FONT_EXTENSIONS$1 = [
|
|
@@ -3331,76 +3689,131 @@ const FONT_EXTENSIONS$1 = [
|
|
|
3331
3689
|
".woff"
|
|
3332
3690
|
];
|
|
3333
3691
|
/**
|
|
3334
|
-
* Compute
|
|
3692
|
+
* Compute a stable cache key for the `fonts` configuration so a font change
|
|
3693
|
+
* invalidates cached PNGs. Hashes the name/path/weight/style of each entry (order
|
|
3694
|
+
* preserved); an empty/omitted list yields a fixed sentinel.
|
|
3335
3695
|
*
|
|
3336
|
-
* @param
|
|
3337
|
-
* @
|
|
3338
|
-
* @param size - The output dimensions.
|
|
3339
|
-
* @returns The hex-encoded SHA-256 digest.
|
|
3696
|
+
* @param fonts - The configured OG fonts (optional).
|
|
3697
|
+
* @returns A short stable key derived from the fonts list.
|
|
3340
3698
|
* @example
|
|
3341
3699
|
* ```ts
|
|
3342
|
-
*
|
|
3700
|
+
* fontsKey([{ name: "Inter", path: "./Inter.ttf" }]);
|
|
3343
3701
|
* ```
|
|
3344
3702
|
*/
|
|
3345
|
-
function
|
|
3346
|
-
|
|
3703
|
+
function fontsKey(fonts) {
|
|
3704
|
+
if (!fonts || fonts.length === 0) return "default-font";
|
|
3705
|
+
const parts = fonts.map((font) => `${font.name}:${font.path}:${font.weight ?? 400}:${font.style ?? "normal"}`);
|
|
3706
|
+
return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 16);
|
|
3347
3707
|
}
|
|
3348
3708
|
/**
|
|
3349
|
-
*
|
|
3709
|
+
* Compute the content-hash cache key for an article OG image. Covers the FULL
|
|
3710
|
+
* {@link RichOgInput} (title/description/date/tags/author/locale/siteName/size),
|
|
3711
|
+
* the resolved `template`, and a {@link fontsKey} of the fonts list — so changing
|
|
3712
|
+
* any input field OR the fonts invalidates the cached PNG.
|
|
3350
3713
|
*
|
|
3351
|
-
* @param
|
|
3352
|
-
* @
|
|
3714
|
+
* @param input - The full rich OG input for the card.
|
|
3715
|
+
* @param template - The resolved OG template identifier.
|
|
3716
|
+
* @param fontsHash - The {@link fontsKey} of the configured fonts.
|
|
3717
|
+
* @returns The hex-encoded SHA-256 digest.
|
|
3353
3718
|
* @example
|
|
3354
3719
|
* ```ts
|
|
3355
|
-
*
|
|
3720
|
+
* ogHash(input, "default", fontsKey());
|
|
3356
3721
|
* ```
|
|
3357
3722
|
*/
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3723
|
+
function ogHash(input, template, fontsHash) {
|
|
3724
|
+
const payload = [
|
|
3725
|
+
input.title,
|
|
3726
|
+
input.description,
|
|
3727
|
+
input.date,
|
|
3728
|
+
input.tags.join(","),
|
|
3729
|
+
input.author ?? "",
|
|
3730
|
+
input.locale,
|
|
3731
|
+
input.siteName,
|
|
3732
|
+
`${input.size.width}x${input.size.height}`,
|
|
3733
|
+
template,
|
|
3734
|
+
fontsHash
|
|
3735
|
+
].join("|");
|
|
3736
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
3737
|
+
}
|
|
3738
|
+
/**
|
|
3739
|
+
* Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
|
|
3740
|
+
* `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
|
|
3741
|
+
* font entry; otherwise the first font file found in `fontDir` is used as a single
|
|
3742
|
+
* 400/normal fallback.
|
|
3743
|
+
*
|
|
3744
|
+
* @param og - The font directory + optional explicit fonts list.
|
|
3745
|
+
* @param og.fontDir - Directory scanned for a fallback font when `fonts` is unset.
|
|
3746
|
+
* @param og.fonts - Explicit named fonts (each loaded once).
|
|
3747
|
+
* @returns The loaded fonts (empty when no font is available).
|
|
3748
|
+
* @example
|
|
3749
|
+
* ```ts
|
|
3750
|
+
* await loadFonts({ fontDir: "./fonts" });
|
|
3751
|
+
* ```
|
|
3752
|
+
*/
|
|
3753
|
+
async function loadFonts(og) {
|
|
3754
|
+
if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
|
|
3755
|
+
name: font.name,
|
|
3756
|
+
data: await readFile(font.path),
|
|
3757
|
+
weight: font.weight ?? 400,
|
|
3758
|
+
style: font.style ?? "normal"
|
|
3759
|
+
})));
|
|
3760
|
+
if (!existsSync(og.fontDir)) return [];
|
|
3761
|
+
const file = (await readdir(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
|
|
3762
|
+
if (!file) return [];
|
|
3763
|
+
return [{
|
|
3363
3764
|
name: "OG",
|
|
3364
|
-
data: await readFile(path.join(fontDir,
|
|
3365
|
-
|
|
3765
|
+
data: await readFile(path.join(og.fontDir, file)),
|
|
3766
|
+
weight: 400,
|
|
3767
|
+
style: "normal"
|
|
3768
|
+
}];
|
|
3366
3769
|
}
|
|
3367
3770
|
/**
|
|
3368
|
-
* The default
|
|
3771
|
+
* The built-in default OG card — a centered title on a dark background. Used when
|
|
3772
|
+
* no custom `ogImage.render` hook is configured. (`@jsxImportSource preact`.)
|
|
3369
3773
|
*
|
|
3370
|
-
* @param
|
|
3371
|
-
* @
|
|
3372
|
-
* @
|
|
3774
|
+
* @param input - The rich OG input (only `title` is used by the default card).
|
|
3775
|
+
* @returns The Preact `VNode` for the default card.
|
|
3776
|
+
* @example
|
|
3777
|
+
* ```ts
|
|
3778
|
+
* defaultCard(input);
|
|
3779
|
+
* ```
|
|
3780
|
+
*/
|
|
3781
|
+
function defaultCard(input) {
|
|
3782
|
+
return h("div", { style: {
|
|
3783
|
+
display: "flex",
|
|
3784
|
+
width: "100%",
|
|
3785
|
+
height: "100%",
|
|
3786
|
+
alignItems: "center",
|
|
3787
|
+
justifyContent: "center",
|
|
3788
|
+
fontSize: 64,
|
|
3789
|
+
background: "#0b0b0c",
|
|
3790
|
+
color: "#ffffff"
|
|
3791
|
+
} }, input.title);
|
|
3792
|
+
}
|
|
3793
|
+
/**
|
|
3794
|
+
* The default PNG renderer: a Preact `VNode` (custom `render` hook or the built-in
|
|
3795
|
+
* card) is rendered to SVG by Satori, then rasterized to PNG by resvg. Both native
|
|
3796
|
+
* deps are imported LAZILY (browser-safe goal); the VNode→Satori-input cast happens
|
|
3797
|
+
* at this single framework boundary only.
|
|
3798
|
+
*
|
|
3799
|
+
* @param ctx - The renderer wiring (preloaded fonts + optional custom card).
|
|
3800
|
+
* @param ctx.fonts - Fonts loaded once for the whole render pass.
|
|
3801
|
+
* @param ctx.render - Optional custom card renderer; defaults to {@link defaultCard}.
|
|
3802
|
+
* @returns An {@link OgPngRenderer} bound to the loaded fonts + renderer.
|
|
3373
3803
|
* @example
|
|
3374
3804
|
* ```ts
|
|
3375
|
-
* const render = makeDefaultRenderer({
|
|
3805
|
+
* const render = makeDefaultRenderer({ fonts, render: undefined });
|
|
3376
3806
|
* ```
|
|
3377
3807
|
*/
|
|
3378
3808
|
function makeDefaultRenderer(ctx) {
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
const
|
|
3382
|
-
|
|
3383
|
-
return new Resvg(await satori(
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
height: "100%",
|
|
3388
|
-
alignItems: "center",
|
|
3389
|
-
justifyContent: "center",
|
|
3390
|
-
fontSize: 64,
|
|
3391
|
-
background: "#0b0b0c",
|
|
3392
|
-
color: "#ffffff"
|
|
3393
|
-
},
|
|
3394
|
-
children: title
|
|
3395
|
-
}), {
|
|
3396
|
-
width,
|
|
3397
|
-
height,
|
|
3398
|
-
fonts: [{
|
|
3399
|
-
name: font.name,
|
|
3400
|
-
data: font.data,
|
|
3401
|
-
weight: 400,
|
|
3402
|
-
style: "normal"
|
|
3403
|
-
}]
|
|
3809
|
+
return async (input) => {
|
|
3810
|
+
if (ctx.fonts.length === 0) throw new Error("[web] build.ogImage no font available for rendering");
|
|
3811
|
+
const { default: satori } = await import("satori");
|
|
3812
|
+
const { Resvg } = await import("@resvg/resvg-js");
|
|
3813
|
+
return new Resvg(await satori((ctx.render ?? defaultCard)(input), {
|
|
3814
|
+
width: input.size.width,
|
|
3815
|
+
height: input.size.height,
|
|
3816
|
+
fonts: ctx.fonts
|
|
3404
3817
|
})).render().asPng();
|
|
3405
3818
|
};
|
|
3406
3819
|
}
|
|
@@ -3418,13 +3831,59 @@ function selectArticles(byLocale) {
|
|
|
3418
3831
|
return ([...byLocale.values()][0] ?? []).filter((article) => article.computed.status === "published");
|
|
3419
3832
|
}
|
|
3420
3833
|
/**
|
|
3834
|
+
* Build the {@link RichOgInput} for one article from its frontmatter/computed
|
|
3835
|
+
* fields plus the resolved size and site name.
|
|
3836
|
+
*
|
|
3837
|
+
* @param article - The published article to render a card for.
|
|
3838
|
+
* @param size - The resolved OG output dimensions.
|
|
3839
|
+
* @param siteName - The site name (from the site plugin, or `""` when unavailable).
|
|
3840
|
+
* @returns The fully-populated rich OG input.
|
|
3841
|
+
* @example
|
|
3842
|
+
* ```ts
|
|
3843
|
+
* buildInput(article, { width: 1200, height: 630 }, "Blog");
|
|
3844
|
+
* ```
|
|
3845
|
+
*/
|
|
3846
|
+
function buildInput(article, size, siteName) {
|
|
3847
|
+
const input = {
|
|
3848
|
+
title: article.frontmatter.title,
|
|
3849
|
+
description: article.frontmatter.description,
|
|
3850
|
+
date: article.frontmatter.date,
|
|
3851
|
+
tags: [...article.frontmatter.tags],
|
|
3852
|
+
locale: article.locale,
|
|
3853
|
+
siteName,
|
|
3854
|
+
size
|
|
3855
|
+
};
|
|
3856
|
+
if (article.frontmatter.author !== void 0) input.author = article.frontmatter.author;
|
|
3857
|
+
return input;
|
|
3858
|
+
}
|
|
3859
|
+
/**
|
|
3860
|
+
* Resolve the site name via `ctx.require(sitePlugin)`, falling back to `""` when the
|
|
3861
|
+
* site API is unavailable (e.g. unit mocks that omit it).
|
|
3862
|
+
*
|
|
3863
|
+
* @param ctx - Plugin context (provides `require`).
|
|
3864
|
+
* @returns The site name, or `""` when the site plugin is not wired.
|
|
3865
|
+
* @example
|
|
3866
|
+
* ```ts
|
|
3867
|
+
* resolveSiteName(ctx);
|
|
3868
|
+
* ```
|
|
3869
|
+
*/
|
|
3870
|
+
function resolveSiteName(ctx) {
|
|
3871
|
+
try {
|
|
3872
|
+
return ctx.require(sitePlugin).name();
|
|
3873
|
+
} catch {
|
|
3874
|
+
return "";
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
/**
|
|
3421
3878
|
* Renders OG images for published articles with a `p-limit(4)` concurrency pool.
|
|
3422
|
-
* Computes
|
|
3423
|
-
* the hash matches `state.ogImageHashCache`; writes the
|
|
3424
|
-
* `<outDir>/.cache/og-images.json`.
|
|
3879
|
+
* Computes {@link ogHash} (full {@link RichOgInput} + template + fonts) per article
|
|
3880
|
+
* and skips regeneration when the hash matches `state.ogImageHashCache`; writes the
|
|
3881
|
+
* cache back to `<outDir>/.cache/og-images.json`. The configured `ogImage.render`
|
|
3882
|
+
* hook (when present) builds each card; otherwise the built-in card is used. Fonts
|
|
3883
|
+
* are loaded ONCE for the whole pass. No-op when `config.ogImage` is false.
|
|
3425
3884
|
*
|
|
3426
|
-
* @param ctx - Plugin context (provides `state`, `config`, `log`).
|
|
3427
|
-
* @param options - Optional dependency-injection seam (PNG
|
|
3885
|
+
* @param ctx - Plugin context (provides `require`, `state`, `config`, `log`).
|
|
3886
|
+
* @param options - Optional dependency-injection seam (PNG rasterizer).
|
|
3428
3887
|
* @returns The render/skip counts + peak concurrency, or `null` when disabled.
|
|
3429
3888
|
* @example
|
|
3430
3889
|
* ```ts
|
|
@@ -3438,9 +3897,17 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3438
3897
|
return null;
|
|
3439
3898
|
}
|
|
3440
3899
|
const { default: pLimit } = await import("p-limit");
|
|
3441
|
-
const
|
|
3442
|
-
const
|
|
3443
|
-
const
|
|
3900
|
+
const config = og;
|
|
3901
|
+
const size = config.size ?? DEFAULT_SIZE;
|
|
3902
|
+
const template = config.template ?? "default";
|
|
3903
|
+
const fontsHash = fontsKey(config.fonts);
|
|
3904
|
+
const fonts = options.renderPng ? [] : await loadFonts(config);
|
|
3905
|
+
const renderHook = config.render ? { render: config.render } : {};
|
|
3906
|
+
const renderPng = options.renderPng ?? makeDefaultRenderer({
|
|
3907
|
+
fonts,
|
|
3908
|
+
...renderHook
|
|
3909
|
+
});
|
|
3910
|
+
const siteName = resolveSiteName(ctx);
|
|
3444
3911
|
const articles = selectArticles(readCachedContent(ctx));
|
|
3445
3912
|
const cache = ctx.state.ogImageHashCache;
|
|
3446
3913
|
await loadDiskCache(ctx.config.outDir, cache);
|
|
@@ -3452,7 +3919,8 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3452
3919
|
const outDir = path.join(ctx.config.outDir, "og");
|
|
3453
3920
|
await Promise.all(articles.map((article) => limit(async () => {
|
|
3454
3921
|
const key = article.computed.contentId;
|
|
3455
|
-
const
|
|
3922
|
+
const input = buildInput(article, size, siteName);
|
|
3923
|
+
const hash = ogHash(input, template, fontsHash);
|
|
3456
3924
|
if (cache.get(key) === hash) {
|
|
3457
3925
|
skipped += 1;
|
|
3458
3926
|
return;
|
|
@@ -3460,10 +3928,7 @@ async function generateOgImages(ctx, options = {}) {
|
|
|
3460
3928
|
active += 1;
|
|
3461
3929
|
peakConcurrency = Math.max(peakConcurrency, active);
|
|
3462
3930
|
try {
|
|
3463
|
-
const png = await renderPng(
|
|
3464
|
-
title: article.frontmatter.title,
|
|
3465
|
-
...size
|
|
3466
|
-
});
|
|
3931
|
+
const png = await renderPng(input);
|
|
3467
3932
|
await mkdir(outDir, { recursive: true });
|
|
3468
3933
|
await writeFile(path.join(outDir, `${key}.png`), png);
|
|
3469
3934
|
cache.set(key, hash);
|
|
@@ -3518,28 +3983,329 @@ async function persistDiskCache(outDir, cache) {
|
|
|
3518
3983
|
await writeFile(path.join(dir, "og-images.json"), JSON.stringify(Object.fromEntries(cache)), "utf8");
|
|
3519
3984
|
}
|
|
3520
3985
|
//#endregion
|
|
3986
|
+
//#region src/plugins/data/load-json.ts
|
|
3987
|
+
/**
|
|
3988
|
+
* @file `loadJson` — the data plugin's isomorphic JSON read primitive (the
|
|
3989
|
+
* SSG↔SPA seam). Internal to the `data` plugin (NOT a framework-root export):
|
|
3990
|
+
* `data.load(locale)` uses it, and consumers read through `app.data.load(locale)`.
|
|
3991
|
+
*
|
|
3992
|
+
* A read runs in BOTH worlds: on Node it reads the emitted data file from disk;
|
|
3993
|
+
* on the client (browser) it fetches the same data over HTTP. `loadJson` is the
|
|
3994
|
+
* single point where those two worlds differ — everything above it (the route's
|
|
3995
|
+
* `load`/`render`) is shared, so SSR/client parity is structural, not hoped-for.
|
|
3996
|
+
*
|
|
3997
|
+
* The browser path uses the `fetch` global. The Node path lazy-imports
|
|
3998
|
+
* `node:fs/promises` via `await import(...)`, so a browser bundle that includes
|
|
3999
|
+
* `loadJson` never statically pulls `node:*` (the bundler splits the Node branch
|
|
4000
|
+
* into its own chunk that the browser never loads).
|
|
4001
|
+
*/
|
|
4002
|
+
/**
|
|
4003
|
+
* Read + parse a JSON resource, isomorphically. In a browser (`document`
|
|
4004
|
+
* defined) it `fetch`es `pathOrUrl`; on Node it reads the file from disk. Throws
|
|
4005
|
+
* on a failed fetch or unreadable file so the caller (`route.load`/`data.load`)
|
|
4006
|
+
* can decide whether to fall back.
|
|
4007
|
+
*
|
|
4008
|
+
* @template T - The expected shape of the parsed JSON.
|
|
4009
|
+
* @param pathOrUrl - A site-root URL (browser) or filesystem path (Node).
|
|
4010
|
+
* @returns The parsed JSON, typed as `T`.
|
|
4011
|
+
* @throws {Error} If the browser fetch is not OK, or the Node file read fails.
|
|
4012
|
+
* @example
|
|
4013
|
+
* ```ts
|
|
4014
|
+
* // Browser: fetch("/_data/en/articles.json")
|
|
4015
|
+
* // Node: read "dist/_data/en/articles.json"
|
|
4016
|
+
* const articles = await loadJson<Article[]>("/_data/en/articles.json");
|
|
4017
|
+
* ```
|
|
4018
|
+
*/
|
|
4019
|
+
async function loadJson(pathOrUrl) {
|
|
4020
|
+
if (typeof document === "undefined") {
|
|
4021
|
+
const { readFile } = await import("node:fs/promises");
|
|
4022
|
+
return JSON.parse(await readFile(pathOrUrl, "utf8"));
|
|
4023
|
+
}
|
|
4024
|
+
const response = await fetch(pathOrUrl);
|
|
4025
|
+
if (!response.ok) throw new Error(`[web] loadJson: failed to fetch ${pathOrUrl} (${String(response.status)}).`);
|
|
4026
|
+
return response.json();
|
|
4027
|
+
}
|
|
4028
|
+
//#endregion
|
|
4029
|
+
//#region src/plugins/data/api.ts
|
|
4030
|
+
/**
|
|
4031
|
+
* @file data plugin — API factory (the agnostic data provider surface).
|
|
4032
|
+
*
|
|
4033
|
+
* Node-free by construction: this module statically imports only types + the pure
|
|
4034
|
+
* convention. The Node write side (`write()`) reaches its `node:fs` writer through
|
|
4035
|
+
* a lazy `await import("./writer")` at call time, so a browser bundle that composes
|
|
4036
|
+
* `data` for the read side never pulls `node:*`. The read side (`at()`) uses only
|
|
4037
|
+
* the isomorphic `loadJson` (whose Node branch is itself lazy).
|
|
4038
|
+
*/
|
|
4039
|
+
/**
|
|
4040
|
+
* Trim a single trailing slash from a config dir so `fileFor` joins cleanly.
|
|
4041
|
+
*
|
|
4042
|
+
* @param dir - The configured output dir (e.g. `"_data"` or `"_data/"`).
|
|
4043
|
+
* @returns The dir without a trailing slash.
|
|
4044
|
+
* @example
|
|
4045
|
+
* ```ts
|
|
4046
|
+
* trimTrailingSlash("_data/"); // "_data"
|
|
4047
|
+
* ```
|
|
4048
|
+
*/
|
|
4049
|
+
function trimTrailingSlash(dir) {
|
|
4050
|
+
return dir.endsWith("/") ? dir.slice(0, -1) : dir;
|
|
4051
|
+
}
|
|
4052
|
+
/**
|
|
4053
|
+
* Builds the data provider — the agnostic bridge. `write()` is the Node persist
|
|
4054
|
+
* side; `at()` is the browser read side; `urlFor`/`fileFor` are the pure
|
|
4055
|
+
* convention. No `onStart`/`onStop` (holds no long-lived resource).
|
|
4056
|
+
*
|
|
4057
|
+
* @param ctx - The data plugin context.
|
|
4058
|
+
* @returns The {@link DataProvider} mounted at `app.data`.
|
|
4059
|
+
* @example
|
|
4060
|
+
* ```ts
|
|
4061
|
+
* const api = dataApi(ctx);
|
|
4062
|
+
* await api.write([{ path: "/en/hello/", data: article }]); // Node build
|
|
4063
|
+
* await api.at("/en/hello/"); // browser
|
|
4064
|
+
* ```
|
|
4065
|
+
*/
|
|
4066
|
+
function dataApi(ctx) {
|
|
4067
|
+
return {
|
|
4068
|
+
/**
|
|
4069
|
+
* READ (browser) — fetch (and cache) the persisted data for a page path.
|
|
4070
|
+
* Returns the raw JSON as `unknown` (the caller's `route.parse` validates it),
|
|
4071
|
+
* or `null` if the fetch/parse fails (so `spa` can fall back to HTML).
|
|
4072
|
+
*
|
|
4073
|
+
* @param path - The page URL path (e.g. `/en/hello/`).
|
|
4074
|
+
* @returns The page's raw data, or `null` on failure.
|
|
4075
|
+
* @example
|
|
4076
|
+
* ```ts
|
|
4077
|
+
* const raw = await api.at("/en/hello/");
|
|
4078
|
+
* ```
|
|
4079
|
+
*/
|
|
4080
|
+
async at(path) {
|
|
4081
|
+
if (ctx.state.cache.has(path)) return ctx.state.cache.get(path);
|
|
4082
|
+
try {
|
|
4083
|
+
const data = await loadJson(`${ctx.config.baseUrl}${dataSuffix(path)}`);
|
|
4084
|
+
ctx.state.cache.set(path, data);
|
|
4085
|
+
return data;
|
|
4086
|
+
} catch {
|
|
4087
|
+
return null;
|
|
4088
|
+
}
|
|
4089
|
+
},
|
|
4090
|
+
/**
|
|
4091
|
+
* WRITE (Node) — persist one JSON file per entry, keyed by page path. Called by
|
|
4092
|
+
* `build` after it expands routes. Lazily loads its `node:fs` writer (keeping a
|
|
4093
|
+
* browser bundle node-free).
|
|
4094
|
+
*
|
|
4095
|
+
* @param entries - The per-page data to persist.
|
|
4096
|
+
* @param options - Optional `{ outDir }` override (defaults to `./dist`).
|
|
4097
|
+
* @param options.outDir - Build output directory the write happens under.
|
|
4098
|
+
* @returns A summary of the written files.
|
|
4099
|
+
* @example
|
|
4100
|
+
* ```ts
|
|
4101
|
+
* await api.write([{ path: "/en/hello/", data: article }], { outDir: "dist" });
|
|
4102
|
+
* ```
|
|
4103
|
+
*/
|
|
4104
|
+
async write(entries, options) {
|
|
4105
|
+
const { writeData } = await import("./writer-BcWqa_7I.mjs");
|
|
4106
|
+
return writeData(ctx, entries, options);
|
|
4107
|
+
},
|
|
4108
|
+
/**
|
|
4109
|
+
* PURE — the browser fetch URL for a page path.
|
|
4110
|
+
*
|
|
4111
|
+
* @param path - The page URL path.
|
|
4112
|
+
* @returns The site-root-relative data URL.
|
|
4113
|
+
* @example
|
|
4114
|
+
* ```ts
|
|
4115
|
+
* api.urlFor("/en/hello/"); // "/_data/en/hello/index.json"
|
|
4116
|
+
* ```
|
|
4117
|
+
*/
|
|
4118
|
+
urlFor(path) {
|
|
4119
|
+
return `${ctx.config.baseUrl}${dataSuffix(path)}`;
|
|
4120
|
+
},
|
|
4121
|
+
/**
|
|
4122
|
+
* PURE — the `outDir`-relative file path for a page path.
|
|
4123
|
+
*
|
|
4124
|
+
* @param path - The page URL path.
|
|
4125
|
+
* @returns The output-relative file path.
|
|
4126
|
+
* @example
|
|
4127
|
+
* ```ts
|
|
4128
|
+
* api.fileFor("/en/hello/"); // "_data/en/hello/index.json"
|
|
4129
|
+
* ```
|
|
4130
|
+
*/
|
|
4131
|
+
fileFor(path) {
|
|
4132
|
+
return `${trimTrailingSlash(ctx.config.outputDir)}/${dataSuffix(path)}`;
|
|
4133
|
+
}
|
|
4134
|
+
};
|
|
4135
|
+
}
|
|
4136
|
+
//#endregion
|
|
4137
|
+
//#region src/plugins/data/config.ts
|
|
4138
|
+
/**
|
|
4139
|
+
* Typed default data config (R6: no inline `as`). `outputDir` is the WRITE path
|
|
4140
|
+
* (filesystem, relative to the build `outDir`); `baseUrl` is the matching READ URL
|
|
4141
|
+
* (site-root-relative) the browser fetches from — the defaults agree
|
|
4142
|
+
* (`"_data"` ↔ `"/_data/"`).
|
|
4143
|
+
*
|
|
4144
|
+
* @example
|
|
4145
|
+
* ```ts
|
|
4146
|
+
* createPlugin("data", { config: defaultDataConfig });
|
|
4147
|
+
* ```
|
|
4148
|
+
*/
|
|
4149
|
+
const defaultDataConfig = {
|
|
4150
|
+
outputDir: "_data",
|
|
4151
|
+
baseUrl: "/_data/"
|
|
4152
|
+
};
|
|
4153
|
+
//#endregion
|
|
4154
|
+
//#region src/plugins/data/state.ts
|
|
4155
|
+
/**
|
|
4156
|
+
* Creates initial data state: a null `lastWrite` slot (populated by the Node
|
|
4157
|
+
* `write()` side) and an empty `cache` (populated lazily by the browser `at(path)`
|
|
4158
|
+
* side on first fetch).
|
|
4159
|
+
*
|
|
4160
|
+
* @param _ctx - Minimal context with global and config.
|
|
4161
|
+
* @param _ctx.global - Global framework configuration.
|
|
4162
|
+
* @param _ctx.config - Resolved plugin configuration.
|
|
4163
|
+
* @returns Fresh data state with no recorded write and an empty per-path cache.
|
|
4164
|
+
* @example
|
|
4165
|
+
* ```ts
|
|
4166
|
+
* const state = createDataState({ global: {}, config });
|
|
4167
|
+
* ```
|
|
4168
|
+
*/
|
|
4169
|
+
function createDataState(_ctx) {
|
|
4170
|
+
return {
|
|
4171
|
+
lastWrite: null,
|
|
4172
|
+
cache: /* @__PURE__ */ new Map()
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
//#endregion
|
|
4176
|
+
//#region src/plugins/data/validate.ts
|
|
4177
|
+
/**
|
|
4178
|
+
* Validates the resolved data config: the browser `baseUrl` must be a non-empty,
|
|
4179
|
+
* site-root-relative URL path. The emit/read pipelines are wired in build waves 3/4.
|
|
4180
|
+
*
|
|
4181
|
+
* @param config - The resolved plugin configuration.
|
|
4182
|
+
* @throws {Error} If `baseUrl` is empty or not a rooted URL path.
|
|
4183
|
+
* @example
|
|
4184
|
+
* ```ts
|
|
4185
|
+
* validateDataConfig({ outputDir: "_data", baseUrl: "/_data/" });
|
|
4186
|
+
* ```
|
|
4187
|
+
*/
|
|
4188
|
+
function validateDataConfig(config) {
|
|
4189
|
+
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/").`);
|
|
4190
|
+
}
|
|
4191
|
+
//#endregion
|
|
4192
|
+
//#region src/plugins/data/index.ts
|
|
4193
|
+
/**
|
|
4194
|
+
* @file data — Standard tier plugin (wiring-only). The AGNOSTIC data provider for
|
|
4195
|
+
* the SSG→DATA→SPA pattern.
|
|
4196
|
+
*
|
|
4197
|
+
* Owns ONE contract — `page path → persisted JSON file` — and nothing about what
|
|
4198
|
+
* the data is: `write(entries)` persists per-page JSON on Node (build supplies the
|
|
4199
|
+
* entries it already expanded); `at(path)` fetches + caches it in the browser as
|
|
4200
|
+
* `unknown`, which the route's `parse` validates before `render`. NOT a framework
|
|
4201
|
+
* default — the consumer composes it where needed (Node build AND/OR browser app).
|
|
4202
|
+
*
|
|
4203
|
+
* **No hard `depends`** — fully browser-composable; the `node:fs` writer is behind
|
|
4204
|
+
* a lazy `import()` inside `write()`. Build ordering is a call-site contract: build
|
|
4205
|
+
* writes data during its pages phase (after its Phase-0 clean), via `app.data.write`.
|
|
4206
|
+
* No `onStart`/`onStop`.
|
|
4207
|
+
* @see README.md
|
|
4208
|
+
*/
|
|
4209
|
+
/**
|
|
4210
|
+
* Data plugin — the agnostic data provider. Mounts `write(entries)` (Node persist),
|
|
4211
|
+
* `at(path)` (browser read), and the pure `urlFor`/`fileFor` convention at `app.data`.
|
|
4212
|
+
*
|
|
4213
|
+
* @example
|
|
4214
|
+
* ```ts
|
|
4215
|
+
* // Node build: `build` calls app.data.write(...) during its pages phase when
|
|
4216
|
+
* // router.mode !== "ssg". Just compose the plugin:
|
|
4217
|
+
* const app = createApp({
|
|
4218
|
+
* plugins: [dataPlugin, contentPlugin, buildPlugin],
|
|
4219
|
+
* pluginConfigs: { content: { contentDir: "./content" }, router: { routes, mode: "hybrid" } }
|
|
4220
|
+
* });
|
|
4221
|
+
* await app.start();
|
|
4222
|
+
* await app.build.run(); // writes HTML + per-page data sidecars
|
|
4223
|
+
*
|
|
4224
|
+
* // Browser app: compose `dataPlugin` too; spa fetches via app.data.at(path) on nav.
|
|
4225
|
+
* ```
|
|
4226
|
+
*/
|
|
4227
|
+
const dataPlugin = createPlugin$1("data", {
|
|
4228
|
+
config: defaultDataConfig,
|
|
4229
|
+
createState: createDataState,
|
|
4230
|
+
onInit: (ctx) => validateDataConfig(ctx.config),
|
|
4231
|
+
api: dataApi
|
|
4232
|
+
});
|
|
4233
|
+
//#endregion
|
|
3521
4234
|
//#region src/plugins/build/phases/pages.tsx
|
|
3522
4235
|
/**
|
|
3523
4236
|
* @file build phase 3 — pages. Pulls `router.manifest()` + `head.render(route, data)`
|
|
3524
4237
|
* and SSR-renders each route to static HTML (preact-render-to-string). Appends the
|
|
3525
4238
|
* build-id meta tag after `head.render()` returns. Does NOT compose `<head>` itself.
|
|
3526
4239
|
*/
|
|
4240
|
+
/** Template placeholder for the composed `<head>` inner HTML. */
|
|
4241
|
+
const HEAD_PLACEHOLDER = "<!--moku:head-->";
|
|
4242
|
+
/** Template placeholder for the SSR-rendered body HTML. */
|
|
4243
|
+
const BODY_PLACEHOLDER = "<!--moku:body-->";
|
|
4244
|
+
/** Template placeholder for the injected asset `<link>`/`<script>` tags. */
|
|
4245
|
+
const ASSETS_PLACEHOLDER = "<!--moku:assets-->";
|
|
3527
4246
|
/**
|
|
3528
|
-
*
|
|
3529
|
-
*
|
|
4247
|
+
* Read the bundle phase's hashed asset manifest for one kind from `state.buildCache`
|
|
4248
|
+
* as a typed {@link BuildCacheEntry} (no `Map<string, unknown>` reads).
|
|
3530
4249
|
*
|
|
3531
|
-
* @param
|
|
3532
|
-
* @param
|
|
3533
|
-
* @
|
|
3534
|
-
* @
|
|
4250
|
+
* @param ctx - Plugin context (provides `state`).
|
|
4251
|
+
* @param kind - The asset kind key (`"css"` / `"js"`).
|
|
4252
|
+
* @returns The hashed-path manifest entry, or an empty object when absent.
|
|
4253
|
+
* @example
|
|
4254
|
+
* ```ts
|
|
4255
|
+
* readManifest(ctx, "css");
|
|
4256
|
+
* ```
|
|
4257
|
+
*/
|
|
4258
|
+
function readManifest(ctx, kind) {
|
|
4259
|
+
const entry = ctx.state.buildCache.get(kind);
|
|
4260
|
+
return entry && typeof entry === "object" ? entry : {};
|
|
4261
|
+
}
|
|
4262
|
+
/**
|
|
4263
|
+
* Build the asset `<link>`/`<script>` tag block from the hashed manifests. Returns
|
|
4264
|
+
* an empty string when `config.injectAssets === false`. Asset paths are emitted as
|
|
4265
|
+
* absolute (`/`-rooted) URLs.
|
|
4266
|
+
*
|
|
4267
|
+
* @param ctx - Plugin context (provides `state`, `config`).
|
|
4268
|
+
* @returns The injected asset tags, or `""` when injection is disabled.
|
|
4269
|
+
* @example
|
|
4270
|
+
* ```ts
|
|
4271
|
+
* buildAssetTags(ctx);
|
|
4272
|
+
* ```
|
|
4273
|
+
*/
|
|
4274
|
+
function buildAssetTags(ctx) {
|
|
4275
|
+
if (ctx.config.injectAssets === false) return "";
|
|
4276
|
+
const css = Object.values(readManifest(ctx, "css")).map((href) => `<link rel="stylesheet" href="/${href}">`);
|
|
4277
|
+
const js = Object.values(readManifest(ctx, "js")).map((src) => `<script type="module" src="/${src}"><\/script>`);
|
|
4278
|
+
return [...css, ...js].join("");
|
|
4279
|
+
}
|
|
4280
|
+
/**
|
|
4281
|
+
* Compose the full static HTML document with the in-code shell, injecting the
|
|
4282
|
+
* build-id meta tag into `<head>` AFTER the head plugin's composed HTML (build
|
|
4283
|
+
* metadata, not content) and the asset tags at the end of `<head>`.
|
|
4284
|
+
*
|
|
4285
|
+
* @param parts - The composed head/body/assets/locale pieces.
|
|
3535
4286
|
* @returns The complete HTML document string.
|
|
3536
4287
|
* @example
|
|
3537
4288
|
* ```ts
|
|
3538
|
-
* renderDocument("<title>Hi</title>", "<h1>Hi</h1>", "
|
|
4289
|
+
* renderDocument({ head: "<title>Hi</title>", body: "<h1>Hi</h1>", assets: "", locale: "en" });
|
|
4290
|
+
* ```
|
|
4291
|
+
*/
|
|
4292
|
+
function renderDocument(parts) {
|
|
4293
|
+
return `<!DOCTYPE html><html lang="${parts.locale}"><head>${parts.head}${parts.assets}</head><body>${parts.body}</body></html>`;
|
|
4294
|
+
}
|
|
4295
|
+
/**
|
|
4296
|
+
* Fill a shell template's `<!--moku:head-->` / `<!--moku:body-->` /
|
|
4297
|
+
* `<!--moku:assets-->` placeholders deterministically at build time.
|
|
4298
|
+
*
|
|
4299
|
+
* @param template - The raw shell template HTML.
|
|
4300
|
+
* @param parts - The composed head/body/assets pieces.
|
|
4301
|
+
* @returns The filled document string.
|
|
4302
|
+
* @example
|
|
4303
|
+
* ```ts
|
|
4304
|
+
* fillTemplate(shell, { head, body, assets, locale: "en" });
|
|
3539
4305
|
* ```
|
|
3540
4306
|
*/
|
|
3541
|
-
function
|
|
3542
|
-
return
|
|
4307
|
+
function fillTemplate(template, parts) {
|
|
4308
|
+
return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
|
|
3543
4309
|
}
|
|
3544
4310
|
/**
|
|
3545
4311
|
* Expand one route definition into its concrete page instances across all
|
|
@@ -3616,18 +4382,24 @@ function adaptHeadConfig(config) {
|
|
|
3616
4382
|
return adapted;
|
|
3617
4383
|
}
|
|
3618
4384
|
/**
|
|
3619
|
-
* Render one page instance to its static HTML document and write it to disk.
|
|
4385
|
+
* Render one page instance to its static HTML document and write it to disk. Uses
|
|
4386
|
+
* the configured shell `template` (filled at build time) when supplied, otherwise
|
|
4387
|
+
* the in-code shell; injects the precomputed asset tags + build-id meta.
|
|
3620
4388
|
*
|
|
3621
4389
|
* @param ctx - Plugin context (provides `require`, `state`, `config`).
|
|
3622
4390
|
* @param instance - The concrete page instance to render.
|
|
4391
|
+
* @param shell - Wiring shared across instances (asset tags + optional template).
|
|
4392
|
+
* @param shell.assets - The injected asset `<link>`/`<script>` tags.
|
|
4393
|
+
* @param shell.template - The shell template HTML, or `null` for the in-code shell.
|
|
3623
4394
|
* @returns The instance's URL and rendered HTML (HTML reused for the root page).
|
|
3624
4395
|
* @example
|
|
3625
4396
|
* ```ts
|
|
3626
|
-
* await renderInstance(ctx, instance);
|
|
4397
|
+
* await renderInstance(ctx, instance, { assets: "", template: null });
|
|
3627
4398
|
* ```
|
|
3628
4399
|
*/
|
|
3629
|
-
async function renderInstance(ctx, instance) {
|
|
4400
|
+
async function renderInstance(ctx, instance, shell) {
|
|
3630
4401
|
const { definition, entry, params, locale, name } = instance;
|
|
4402
|
+
const hasData = definition._handlers.load !== void 0;
|
|
3631
4403
|
const data = definition._handlers.load ? await definition._handlers.load(params, locale) : void 0;
|
|
3632
4404
|
const routeContext = {
|
|
3633
4405
|
params,
|
|
@@ -3644,14 +4416,24 @@ async function renderInstance(ctx, instance) {
|
|
|
3644
4416
|
};
|
|
3645
4417
|
if (headConfig) resolved.head = adaptHeadConfig(headConfig);
|
|
3646
4418
|
const headHtml = ctx.require(headPlugin).render(resolved, data);
|
|
4419
|
+
const buildIdMeta = `<meta name="build-id" content="${ctx.state.runId ?? ""}">`;
|
|
3647
4420
|
const vnode = definition._handlers.render?.(routeContext);
|
|
3648
|
-
const
|
|
4421
|
+
const bodyHtml = vnode ? renderToString(vnode) : "";
|
|
4422
|
+
const parts = {
|
|
4423
|
+
head: `${headHtml}${buildIdMeta}`,
|
|
4424
|
+
body: bodyHtml,
|
|
4425
|
+
assets: shell.assets,
|
|
4426
|
+
locale
|
|
4427
|
+
};
|
|
4428
|
+
const html = shell.template === null ? renderDocument(parts) : fillTemplate(shell.template, parts);
|
|
3649
4429
|
const filePath = join(ctx.config.outDir, entry.toFile(params));
|
|
3650
4430
|
await mkdir(dirname(filePath), { recursive: true });
|
|
3651
4431
|
await writeFile(filePath, html, "utf8");
|
|
3652
4432
|
return {
|
|
3653
4433
|
url,
|
|
3654
|
-
html
|
|
4434
|
+
html,
|
|
4435
|
+
data,
|
|
4436
|
+
hasData
|
|
3655
4437
|
};
|
|
3656
4438
|
}
|
|
3657
4439
|
/**
|
|
@@ -3669,14 +4451,56 @@ async function renderInstance(ctx, instance) {
|
|
|
3669
4451
|
* const { pageCount, rootHtml } = await renderPages(ctx);
|
|
3670
4452
|
* ```
|
|
3671
4453
|
*/
|
|
4454
|
+
/**
|
|
4455
|
+
* Enforce the data-validation contract: in `hybrid`/`spa` mode, any route that
|
|
4456
|
+
* has BOTH a `render` and a `load` (so it will be client-data-navigated) MUST
|
|
4457
|
+
* declare a `.parse()` validator — otherwise fetched JSON would reach `render`
|
|
4458
|
+
* unvalidated. Converts a runtime safety hole into a build error.
|
|
4459
|
+
*
|
|
4460
|
+
* @param manifest - The route definitions from `router.manifest()`.
|
|
4461
|
+
* @param mode - The resolved render mode.
|
|
4462
|
+
* @throws {Error} If a data-navigable route is missing `.parse()` in hybrid/spa mode.
|
|
4463
|
+
* @example
|
|
4464
|
+
* ```ts
|
|
4465
|
+
* assertDataValidators(router.manifest(), router.mode());
|
|
4466
|
+
* ```
|
|
4467
|
+
*/
|
|
4468
|
+
function assertDataValidators(manifest, mode) {
|
|
4469
|
+
if (mode === "ssg") return;
|
|
4470
|
+
for (const definition of manifest) {
|
|
4471
|
+
const { render, load, parse } = definition._handlers;
|
|
4472
|
+
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.`);
|
|
4473
|
+
}
|
|
4474
|
+
}
|
|
3672
4475
|
async function renderPages(ctx) {
|
|
3673
4476
|
const router = ctx.require(routerPlugin);
|
|
3674
4477
|
const manifest = router.manifest();
|
|
3675
4478
|
ctx.state.manifest = [...manifest];
|
|
4479
|
+
const mode = router.mode();
|
|
4480
|
+
assertDataValidators(manifest, mode);
|
|
3676
4481
|
const byPattern = makeEntryMap(router);
|
|
3677
4482
|
const locales = ctx.require(i18nPlugin).locales();
|
|
4483
|
+
const templatePath = ctx.config.template;
|
|
4484
|
+
const template = typeof templatePath === "string" && existsSync(templatePath) ? await readFile(templatePath, "utf8") : null;
|
|
4485
|
+
const shell = {
|
|
4486
|
+
assets: buildAssetTags(ctx),
|
|
4487
|
+
template
|
|
4488
|
+
};
|
|
3678
4489
|
const instances = (await Promise.all(manifest.map((definition) => expandRoute(definition, locales, byPattern)))).flat();
|
|
3679
|
-
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance)));
|
|
4490
|
+
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
|
|
4491
|
+
if (mode !== "ssg" && ctx.has("data")) {
|
|
4492
|
+
const entries = rendered.filter((page) => page.hasData).map((page) => ({
|
|
4493
|
+
path: page.url,
|
|
4494
|
+
data: page.data
|
|
4495
|
+
}));
|
|
4496
|
+
if (entries.length > 0) {
|
|
4497
|
+
const summary = await ctx.require(dataPlugin).write(entries, { outDir: ctx.config.outDir });
|
|
4498
|
+
ctx.log.debug("build:data", {
|
|
4499
|
+
files: summary.fileCount,
|
|
4500
|
+
bytes: summary.bytes
|
|
4501
|
+
});
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
3680
4504
|
const root = rendered.find((page) => page.url === "/" || page.url === "");
|
|
3681
4505
|
ctx.log.debug("build:pages", { count: rendered.length });
|
|
3682
4506
|
return {
|
|
@@ -3684,6 +4508,37 @@ async function renderPages(ctx) {
|
|
|
3684
4508
|
rootHtml: root?.html ?? null
|
|
3685
4509
|
};
|
|
3686
4510
|
}
|
|
4511
|
+
/**
|
|
4512
|
+
* Copies the configured `publicDir` (default `"public"`) verbatim into `outDir`,
|
|
4513
|
+
* preserving the nested directory structure. Skips silently (returns `null`) when
|
|
4514
|
+
* the source directory does not exist.
|
|
4515
|
+
*
|
|
4516
|
+
* @param ctx - Plugin context (provides `config`, `log`).
|
|
4517
|
+
* @returns The copy result, or `null` when the public directory is absent.
|
|
4518
|
+
* @example
|
|
4519
|
+
* ```ts
|
|
4520
|
+
* const result = await copyPublic(ctx);
|
|
4521
|
+
* ```
|
|
4522
|
+
*/
|
|
4523
|
+
async function copyPublic(ctx) {
|
|
4524
|
+
const from = ctx.config.publicDir ?? "public";
|
|
4525
|
+
if (!existsSync(from)) {
|
|
4526
|
+
ctx.log.debug("build:public", {
|
|
4527
|
+
skipped: true,
|
|
4528
|
+
from
|
|
4529
|
+
});
|
|
4530
|
+
return null;
|
|
4531
|
+
}
|
|
4532
|
+
await cp(from, ctx.config.outDir, { recursive: true });
|
|
4533
|
+
ctx.log.debug("build:public", {
|
|
4534
|
+
from,
|
|
4535
|
+
dest: ctx.config.outDir
|
|
4536
|
+
});
|
|
4537
|
+
return {
|
|
4538
|
+
from: path.normalize(from),
|
|
4539
|
+
copied: 1
|
|
4540
|
+
};
|
|
4541
|
+
}
|
|
3687
4542
|
//#endregion
|
|
3688
4543
|
//#region src/plugins/build/phases/sitemap.ts
|
|
3689
4544
|
/**
|
|
@@ -3787,6 +4642,9 @@ const PHASE_ORDER = [
|
|
|
3787
4642
|
"feeds",
|
|
3788
4643
|
"sitemap",
|
|
3789
4644
|
"og-images",
|
|
4645
|
+
"public",
|
|
4646
|
+
"not-found",
|
|
4647
|
+
"locale-redirects",
|
|
3790
4648
|
"root-index"
|
|
3791
4649
|
];
|
|
3792
4650
|
/**
|
|
@@ -3830,10 +4688,11 @@ function resetRun(ctx) {
|
|
|
3830
4688
|
ctx.state.runId = `${Date.now()}-${randomUUID()}`;
|
|
3831
4689
|
}
|
|
3832
4690
|
/**
|
|
3833
|
-
* Phase 4 — run feeds / sitemap / og-images
|
|
3834
|
-
*
|
|
3835
|
-
*
|
|
3836
|
-
*
|
|
4691
|
+
* Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
|
|
4692
|
+
* concurrently, each gated by its config flag (or, for `public`, the presence of the
|
|
4693
|
+
* source dir), isolated with `Promise.allSettled` so one failure does not lose the
|
|
4694
|
+
* others. A disabled output is skipped entirely — it emits NO `build:phase` boundary
|
|
4695
|
+
* (the `withPhase` wrapper is gated on the config flag, not just the phase body).
|
|
3837
4696
|
*
|
|
3838
4697
|
* @param ctx - The phase context.
|
|
3839
4698
|
* @example
|
|
@@ -3846,6 +4705,9 @@ async function runOutputs(ctx) {
|
|
|
3846
4705
|
if (ctx.config.feeds) tasks.push(withPhase(ctx, "feeds", () => generateFeeds(ctx)));
|
|
3847
4706
|
if (ctx.config.sitemap) tasks.push(withPhase(ctx, "sitemap", () => generateSitemap(ctx)));
|
|
3848
4707
|
if (ctx.config.ogImage) tasks.push(withPhase(ctx, "og-images", () => generateOgImages(ctx)));
|
|
4708
|
+
if (existsSync(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
|
|
4709
|
+
if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
|
|
4710
|
+
if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
|
|
3849
4711
|
const settled = await Promise.allSettled(tasks);
|
|
3850
4712
|
for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
|
|
3851
4713
|
}
|
|
@@ -3988,6 +4850,9 @@ function validateFonts(og) {
|
|
|
3988
4850
|
*/
|
|
3989
4851
|
function validateConfig$1(config) {
|
|
3990
4852
|
if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$5}.outDir: must be a non-empty string.`);
|
|
4853
|
+
if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$5}.publicDir: must be a string when set.`);
|
|
4854
|
+
if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$5}.template: must be a string path when set.`);
|
|
4855
|
+
if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$5}.clientEntry: must be a string path when set.`);
|
|
3991
4856
|
if (config.ogImage) validateFonts(config.ogImage);
|
|
3992
4857
|
}
|
|
3993
4858
|
//#endregion
|
|
@@ -4041,6 +4906,27 @@ function createState$2(ctx) {
|
|
|
4041
4906
|
* @file build — Complex plugin: SSG orchestrator (wiring harness only).
|
|
4042
4907
|
* @see README.md
|
|
4043
4908
|
*/
|
|
4909
|
+
/**
|
|
4910
|
+
* Build plugin — the static-site-generation orchestrator. Renders every route to
|
|
4911
|
+
* `outDir`, and optionally emits feeds, a sitemap, optimized images, and OG
|
|
4912
|
+
* images. Depends on site, i18n, content, router, and head; emits `build:phase`.
|
|
4913
|
+
*
|
|
4914
|
+
* @example Configure the production build
|
|
4915
|
+
* ```ts
|
|
4916
|
+
* const app = createApp({
|
|
4917
|
+
* pluginConfigs: {
|
|
4918
|
+
* build: {
|
|
4919
|
+
* outDir: "dist",
|
|
4920
|
+
* minify: true,
|
|
4921
|
+
* feeds: true,
|
|
4922
|
+
* sitemap: true,
|
|
4923
|
+
* images: true,
|
|
4924
|
+
* ogImage: false // or an object to enable + configure OG-image generation
|
|
4925
|
+
* }
|
|
4926
|
+
* }
|
|
4927
|
+
* });
|
|
4928
|
+
* ```
|
|
4929
|
+
*/
|
|
4044
4930
|
const buildPlugin = createPlugin$1("build", {
|
|
4045
4931
|
depends: [
|
|
4046
4932
|
sitePlugin,
|
|
@@ -4840,6 +5726,24 @@ function createState$1(_ctx) {
|
|
|
4840
5726
|
* Depends: site. Emits: deploy:complete.
|
|
4841
5727
|
* @see README.md
|
|
4842
5728
|
*/
|
|
5729
|
+
/**
|
|
5730
|
+
* Deploy plugin — ships the built `outDir` to Cloudflare Pages via the injectable
|
|
5731
|
+
* wrangler subprocess, with entropy-gated secret scrubbing of logged output.
|
|
5732
|
+
* Depends on site; emits `deploy:complete`.
|
|
5733
|
+
*
|
|
5734
|
+
* @example Configure the deploy target
|
|
5735
|
+
* ```ts
|
|
5736
|
+
* const app = createApp({
|
|
5737
|
+
* pluginConfigs: {
|
|
5738
|
+
* deploy: {
|
|
5739
|
+
* target: "cloudflare-pages",
|
|
5740
|
+
* outDir: "dist",
|
|
5741
|
+
* productionBranch: "main"
|
|
5742
|
+
* }
|
|
5743
|
+
* }
|
|
5744
|
+
* });
|
|
5745
|
+
* ```
|
|
5746
|
+
*/
|
|
4843
5747
|
const deployPlugin = createPlugin$1("deploy", {
|
|
4844
5748
|
config: defaultConfig,
|
|
4845
5749
|
depends: [sitePlugin],
|
|
@@ -4916,7 +5820,7 @@ function spaEvents(register) {
|
|
|
4916
5820
|
}
|
|
4917
5821
|
//#endregion
|
|
4918
5822
|
//#region src/plugins/spa/types.ts
|
|
4919
|
-
var types_exports$
|
|
5823
|
+
var types_exports$8 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
|
|
4920
5824
|
/** Allowed hook names — single source of truth for fail-fast validation. */
|
|
4921
5825
|
const COMPONENT_HOOK_NAMES = [
|
|
4922
5826
|
"onCreate",
|
|
@@ -5386,11 +6290,12 @@ function resolveClickTarget(event) {
|
|
|
5386
6290
|
* Navigation API is unavailable).
|
|
5387
6291
|
*
|
|
5388
6292
|
* @param handlers - The navigation lifecycle callbacks.
|
|
6293
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5389
6294
|
* @returns A teardown that removes the attached listeners.
|
|
5390
6295
|
* @example
|
|
5391
6296
|
* const dispose = attachHistoryFallback(handlers);
|
|
5392
6297
|
*/
|
|
5393
|
-
function attachHistoryFallback(handlers) {
|
|
6298
|
+
function attachHistoryFallback(handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
5394
6299
|
/**
|
|
5395
6300
|
* Intercept an internal-link click and run a History-API navigation.
|
|
5396
6301
|
*
|
|
@@ -5411,7 +6316,7 @@ function attachHistoryFallback(handlers) {
|
|
|
5411
6316
|
}
|
|
5412
6317
|
saveScrollPosition(location.pathname);
|
|
5413
6318
|
history.pushState({ scrollY: 0 }, "", url.pathname);
|
|
5414
|
-
|
|
6319
|
+
navigate(url.pathname).then(() => window.scrollTo(0, 0)).catch(() => {});
|
|
5415
6320
|
};
|
|
5416
6321
|
/**
|
|
5417
6322
|
* Re-run navigation on back/forward, restoring the saved scroll position.
|
|
@@ -5420,7 +6325,7 @@ function attachHistoryFallback(handlers) {
|
|
|
5420
6325
|
* globalThis.addEventListener("popstate", onPopState);
|
|
5421
6326
|
*/
|
|
5422
6327
|
const onPopState = () => {
|
|
5423
|
-
|
|
6328
|
+
navigate(location.pathname).then(() => restoreScrollPosition(location.pathname)).catch(() => {});
|
|
5424
6329
|
};
|
|
5425
6330
|
document.addEventListener("click", onClick);
|
|
5426
6331
|
globalThis.addEventListener("popstate", onPopState);
|
|
@@ -5434,11 +6339,12 @@ function attachHistoryFallback(handlers) {
|
|
|
5434
6339
|
*
|
|
5435
6340
|
* @param navigation - The Navigation API object to attach the listener to.
|
|
5436
6341
|
* @param handlers - The navigation lifecycle callbacks.
|
|
6342
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5437
6343
|
* @returns A teardown that removes the `navigate` listener.
|
|
5438
6344
|
* @example
|
|
5439
6345
|
* const dispose = attachNavigationApi(navigation, handlers);
|
|
5440
6346
|
*/
|
|
5441
|
-
function attachNavigationApi(navigation, handlers) {
|
|
6347
|
+
function attachNavigationApi(navigation, handlers, navigate = (pathname) => performNavigation(pathname, handlers)) {
|
|
5442
6348
|
/**
|
|
5443
6349
|
* Handle a `navigate` event: classify, then intercept with fetch-and-swap.
|
|
5444
6350
|
*
|
|
@@ -5463,7 +6369,7 @@ function attachNavigationApi(navigation, handlers) {
|
|
|
5463
6369
|
navEvent.intercept({
|
|
5464
6370
|
scroll: "manual",
|
|
5465
6371
|
handler: async () => {
|
|
5466
|
-
await
|
|
6372
|
+
await navigate(url.pathname);
|
|
5467
6373
|
if (navEvent.navigationType === "traverse") navEvent.scroll();
|
|
5468
6374
|
else window.scrollTo(0, 0);
|
|
5469
6375
|
}
|
|
@@ -5477,13 +6383,14 @@ function attachNavigationApi(navigation, handlers) {
|
|
|
5477
6383
|
* fallback. Returns a teardown removing every listener it attached.
|
|
5478
6384
|
*
|
|
5479
6385
|
* @param handlers - The navigation lifecycle callbacks the kernel supplies.
|
|
6386
|
+
* @param navigate - The navigation strategy (defaults to HTML-over-fetch via `performNavigation`).
|
|
5480
6387
|
* @returns A teardown removing all attached listeners.
|
|
5481
6388
|
* @example
|
|
5482
|
-
* const dispose = attachRouter(handlers);
|
|
6389
|
+
* const dispose = attachRouter(handlers, navigate);
|
|
5483
6390
|
*/
|
|
5484
|
-
function attachRouter(handlers) {
|
|
6391
|
+
function attachRouter(handlers, navigate) {
|
|
5485
6392
|
const navigation = getNavigation();
|
|
5486
|
-
return navigation ? attachNavigationApi(navigation, handlers) : attachHistoryFallback(handlers);
|
|
6393
|
+
return navigation ? attachNavigationApi(navigation, handlers, navigate) : attachHistoryFallback(handlers, navigate);
|
|
5487
6394
|
}
|
|
5488
6395
|
//#endregion
|
|
5489
6396
|
//#region src/plugins/spa/state.ts
|
|
@@ -5602,6 +6509,20 @@ function currentLocationUrl() {
|
|
|
5602
6509
|
return location.pathname + location.search;
|
|
5603
6510
|
}
|
|
5604
6511
|
/**
|
|
6512
|
+
* Apply the matched route's `head` config to the live document (minimal client
|
|
6513
|
+
* head-sync for the DATA path: title only — the full meta sync runs on the
|
|
6514
|
+
* HTML-over-fetch path from the fetched `<head>`).
|
|
6515
|
+
*
|
|
6516
|
+
* @param route - The matched route definition.
|
|
6517
|
+
* @param routeContext - The render context (params/data/locale).
|
|
6518
|
+
* @example
|
|
6519
|
+
* syncDataHead(hit.route, { params, data, locale });
|
|
6520
|
+
*/
|
|
6521
|
+
function syncDataHead(route, routeContext) {
|
|
6522
|
+
const title = route._handlers.head?.(routeContext)?.title;
|
|
6523
|
+
if (title !== void 0 && title !== "") document.title = title;
|
|
6524
|
+
}
|
|
6525
|
+
/**
|
|
5605
6526
|
* Builds the single shared SPA kernel — a pure factory over state/config/emit.
|
|
5606
6527
|
* Unit-testable with a mock state object and a spy emit; no Moku ctx involved.
|
|
5607
6528
|
*
|
|
@@ -5665,6 +6586,71 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5665
6586
|
onEnd: handleEnd,
|
|
5666
6587
|
onError: handleError
|
|
5667
6588
|
};
|
|
6589
|
+
/**
|
|
6590
|
+
* The client DATA path: match `pathname`, fetch the page's PERSISTED data via the
|
|
6591
|
+
* `data` reader, VALIDATE it through the route's `parse` gate, then run the
|
|
6592
|
+
* route's OWN `render` (the same component the build used for SSG) and
|
|
6593
|
+
* Preact-render the VNode into the swap region. Returns `false` (touching nothing
|
|
6594
|
+
* the fallback cares about) on no-match / no-render / no-data / fetch-miss /
|
|
6595
|
+
* parse-throw, so the caller falls back to HTML-over-fetch. `route.load` does NOT
|
|
6596
|
+
* run on the client — the build already persisted its output.
|
|
6597
|
+
*
|
|
6598
|
+
* @param pathname - The destination pathname (search stripped for matching).
|
|
6599
|
+
* @returns `true` if the route was rendered from validated data, else `false`.
|
|
6600
|
+
* @example
|
|
6601
|
+
* if (await tryDataRender("/en/world/")) return;
|
|
6602
|
+
*/
|
|
6603
|
+
const tryDataRender = async (pathname) => {
|
|
6604
|
+
if (!deps.dataAt) return false;
|
|
6605
|
+
const matchPath = pathname.split("?")[0] ?? pathname;
|
|
6606
|
+
const hit = deps.router.match(matchPath);
|
|
6607
|
+
if (!hit?.route._handlers.render) return false;
|
|
6608
|
+
try {
|
|
6609
|
+
const raw = await deps.dataAt(pathname);
|
|
6610
|
+
if (raw === null) return false;
|
|
6611
|
+
const data = hit.route._handlers.parse ? hit.route._handlers.parse(raw) : raw;
|
|
6612
|
+
const locale = hit.params.lang ?? document.documentElement.lang ?? "";
|
|
6613
|
+
const routeContext = {
|
|
6614
|
+
params: hit.params,
|
|
6615
|
+
data,
|
|
6616
|
+
locale
|
|
6617
|
+
};
|
|
6618
|
+
const vnode = hit.route._handlers.render(routeContext);
|
|
6619
|
+
const region = document.querySelector(resolved.swapSelector);
|
|
6620
|
+
if (!region) return false;
|
|
6621
|
+
handleStart(pathname);
|
|
6622
|
+
const { renderVNode } = await import("./render-BL9Fv6G6.mjs");
|
|
6623
|
+
syncDataHead(hit.route, routeContext);
|
|
6624
|
+
unmountPageSpecific(state, emit);
|
|
6625
|
+
runSwap(() => {
|
|
6626
|
+
region.replaceChildren();
|
|
6627
|
+
renderVNode(vnode, region);
|
|
6628
|
+
scanAndMount(state, emit, resolved.swapSelector);
|
|
6629
|
+
notifyNavEnd(state);
|
|
6630
|
+
}, resolved.viewTransitions);
|
|
6631
|
+
state.currentUrl = pathname;
|
|
6632
|
+
progress?.done();
|
|
6633
|
+
emit("spa:navigated", { url: pathname });
|
|
6634
|
+
return true;
|
|
6635
|
+
} catch {
|
|
6636
|
+
return false;
|
|
6637
|
+
}
|
|
6638
|
+
};
|
|
6639
|
+
/**
|
|
6640
|
+
* Unified navigation: try the client DATA path first (only when the `data`
|
|
6641
|
+
* plugin is composed), then fall back to HTML-over-fetch (which itself falls
|
|
6642
|
+
* back to a full `location.href` reload). Injected into the router so every
|
|
6643
|
+
* navigation entry point (Navigation API, History, programmatic) goes through it.
|
|
6644
|
+
*
|
|
6645
|
+
* @param pathname - The destination pathname.
|
|
6646
|
+
* @returns A promise resolving once the swap (or fallback) is dispatched.
|
|
6647
|
+
* @example
|
|
6648
|
+
* await navigate("/en/world/");
|
|
6649
|
+
*/
|
|
6650
|
+
const navigate = async (pathname) => {
|
|
6651
|
+
if (deps.router.mode() !== "ssg" && await tryDataRender(pathname)) return;
|
|
6652
|
+
await performNavigation(pathname, handlers);
|
|
6653
|
+
};
|
|
5668
6654
|
return {
|
|
5669
6655
|
/**
|
|
5670
6656
|
* Register config components and seed currentUrl from the document.
|
|
@@ -5687,7 +6673,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5687
6673
|
if (state.started) throw new Error(`${ERROR_PREFIX} spa kernel already started.\n Call app.stop() before booting again (single boot per app).`);
|
|
5688
6674
|
progress = createProgressBar(resolved.progressBar);
|
|
5689
6675
|
state.currentUrl = currentLocationUrl();
|
|
5690
|
-
state.destroyRouter = attachRouter(handlers);
|
|
6676
|
+
state.destroyRouter = attachRouter(handlers, navigate);
|
|
5691
6677
|
scanAndMount(state, emit, resolved.swapSelector);
|
|
5692
6678
|
state.started = true;
|
|
5693
6679
|
},
|
|
@@ -5710,7 +6696,7 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5710
6696
|
*/
|
|
5711
6697
|
processNav(path) {
|
|
5712
6698
|
if (typeof document === "undefined") return;
|
|
5713
|
-
|
|
6699
|
+
navigate(path).catch(() => {});
|
|
5714
6700
|
},
|
|
5715
6701
|
/**
|
|
5716
6702
|
* Scan the swap region and mount components for matching elements.
|
|
@@ -5737,20 +6723,41 @@ function createSpaKernel(state, config, emit, deps) {
|
|
|
5737
6723
|
};
|
|
5738
6724
|
}
|
|
5739
6725
|
/**
|
|
6726
|
+
* Structural by-name handle for the OPTIONAL `data` plugin. `ctx.require` resolves
|
|
6727
|
+
* a plugin by its `name` at runtime, so this lets `spa` obtain the `data` reader
|
|
6728
|
+
* WITHOUT importing the `data` plugin or its types — keeping `spa` decoupled and
|
|
6729
|
+
* its `depends` at `[router, head]`. The phantom types only the `at` slice it uses.
|
|
6730
|
+
*/
|
|
6731
|
+
const dataPluginHandle = {
|
|
6732
|
+
name: "data",
|
|
6733
|
+
spec: void 0,
|
|
6734
|
+
_phantom: {
|
|
6735
|
+
config: void 0,
|
|
6736
|
+
state: void 0,
|
|
6737
|
+
api: void 0,
|
|
6738
|
+
events: {}
|
|
6739
|
+
}
|
|
6740
|
+
};
|
|
6741
|
+
/**
|
|
5740
6742
|
* Builds the shared kernel from the plugin context, stores it on `ctx.state`
|
|
5741
6743
|
* and `kernelRef`, and runs its init step (validate config, register
|
|
5742
|
-
* config.components, seed currentUrl).
|
|
5743
|
-
*
|
|
6744
|
+
* config.components, seed currentUrl). Captures the OPTIONAL `data` reader when
|
|
6745
|
+
* the `data` plugin is composed (enabling client DATA navigation).
|
|
5744
6746
|
*
|
|
5745
|
-
* @param ctx - The plugin context (state/config/emit/require/log).
|
|
6747
|
+
* @param ctx - The plugin context (state/config/emit/require/has/log).
|
|
5746
6748
|
* @example
|
|
5747
6749
|
* initSpa(ctx);
|
|
5748
6750
|
*/
|
|
5749
6751
|
function initSpa(ctx) {
|
|
5750
|
-
const
|
|
6752
|
+
const deps = {
|
|
5751
6753
|
router: ctx.require(routerPlugin),
|
|
5752
6754
|
head: ctx.require(headPlugin)
|
|
5753
|
-
}
|
|
6755
|
+
};
|
|
6756
|
+
if (ctx.has("data")) {
|
|
6757
|
+
const reader = ctx.require(dataPluginHandle);
|
|
6758
|
+
deps.dataAt = (path) => reader.at(path);
|
|
6759
|
+
}
|
|
6760
|
+
const kernel = createSpaKernel(ctx.state, ctx.config, ctx.emit, deps);
|
|
5754
6761
|
ctx.state.kernel = kernel;
|
|
5755
6762
|
kernelRef.current = kernel;
|
|
5756
6763
|
kernel.init();
|
|
@@ -5812,6 +6819,26 @@ function disposeSpa() {
|
|
|
5812
6819
|
* Emits: spa:navigate, spa:navigated, spa:component-mount, spa:component-unmount.
|
|
5813
6820
|
* @see README.md
|
|
5814
6821
|
*/
|
|
6822
|
+
/**
|
|
6823
|
+
* SPA plugin — progressive client-side navigation layered over the static site:
|
|
6824
|
+
* swaps a page region on navigation, with an optional progress bar and View
|
|
6825
|
+
* Transitions. Register interactive islands with {@link createComponent}. Depends
|
|
6826
|
+
* on router and head; emits `spa:navigate`, `spa:navigated`, `spa:component-mount`,
|
|
6827
|
+
* and `spa:component-unmount`.
|
|
6828
|
+
*
|
|
6829
|
+
* @example Enable view transitions and a custom swap region
|
|
6830
|
+
* ```ts
|
|
6831
|
+
* const app = createApp({
|
|
6832
|
+
* pluginConfigs: {
|
|
6833
|
+
* spa: {
|
|
6834
|
+
* swapSelector: "main > section",
|
|
6835
|
+
* viewTransitions: true,
|
|
6836
|
+
* progressBar: true
|
|
6837
|
+
* }
|
|
6838
|
+
* }
|
|
6839
|
+
* });
|
|
6840
|
+
* ```
|
|
6841
|
+
*/
|
|
5815
6842
|
const spaPlugin = createPlugin$1("spa", {
|
|
5816
6843
|
depends: [routerPlugin, headPlugin],
|
|
5817
6844
|
config: defaultSpaConfig,
|
|
@@ -5832,32 +6859,234 @@ var types_exports = /* @__PURE__ */ __exportAll({});
|
|
|
5832
6859
|
//#region src/plugins/content/types.ts
|
|
5833
6860
|
var types_exports$1 = /* @__PURE__ */ __exportAll({});
|
|
5834
6861
|
//#endregion
|
|
5835
|
-
//#region src/plugins/
|
|
6862
|
+
//#region src/plugins/data/types.ts
|
|
5836
6863
|
var types_exports$2 = /* @__PURE__ */ __exportAll({});
|
|
5837
6864
|
//#endregion
|
|
5838
|
-
//#region src/plugins/
|
|
6865
|
+
//#region src/plugins/deploy/types.ts
|
|
5839
6866
|
var types_exports$3 = /* @__PURE__ */ __exportAll({});
|
|
5840
6867
|
//#endregion
|
|
5841
|
-
//#region src/plugins/
|
|
6868
|
+
//#region src/plugins/env/types.ts
|
|
5842
6869
|
var types_exports$4 = /* @__PURE__ */ __exportAll({});
|
|
5843
6870
|
//#endregion
|
|
5844
|
-
//#region src/plugins/
|
|
6871
|
+
//#region src/plugins/head/types.ts
|
|
5845
6872
|
var types_exports$5 = /* @__PURE__ */ __exportAll({});
|
|
5846
6873
|
//#endregion
|
|
5847
|
-
//#region src/plugins/
|
|
6874
|
+
//#region src/plugins/log/types.ts
|
|
5848
6875
|
var types_exports$6 = /* @__PURE__ */ __exportAll({});
|
|
5849
|
-
|
|
6876
|
+
//#endregion
|
|
6877
|
+
//#region src/plugins/router/types.ts
|
|
6878
|
+
var types_exports$7 = /* @__PURE__ */ __exportAll({});
|
|
6879
|
+
//#endregion
|
|
6880
|
+
//#region src/plugins/env/providers.ts
|
|
6881
|
+
/**
|
|
6882
|
+
* @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
|
|
6883
|
+
*/
|
|
6884
|
+
/** Default dotenv file path: optional local overrides. */
|
|
6885
|
+
const DEFAULT_DOTENV_PATH = ".env.local";
|
|
6886
|
+
/** Property on `globalThis` that the consumer sets per Cloudflare request. */
|
|
6887
|
+
const CLOUDFLARE_GLOBAL = "__CLOUDFLARE_ENV__";
|
|
6888
|
+
/**
|
|
6889
|
+
* Strips a single matching pair of surrounding double or single quotes from a
|
|
6890
|
+
* value. Leaves unquoted values (and trailing inline comments) untouched.
|
|
6891
|
+
*
|
|
6892
|
+
* @param value - The already-trimmed raw value.
|
|
6893
|
+
* @returns The value with one outer quote pair removed, if present.
|
|
6894
|
+
* @example
|
|
6895
|
+
* ```ts
|
|
6896
|
+
* stripQuotes('"a"'); // "a"
|
|
6897
|
+
* stripQuotes("plain # c"); // "plain # c"
|
|
6898
|
+
* ```
|
|
6899
|
+
*/
|
|
6900
|
+
function stripQuotes(value) {
|
|
6901
|
+
if (value.length >= 2) {
|
|
6902
|
+
const first = value[0];
|
|
6903
|
+
const last = value.at(-1);
|
|
6904
|
+
if ((first === "\"" || first === "'") && first === last) return value.slice(1, -1);
|
|
6905
|
+
}
|
|
6906
|
+
return value;
|
|
6907
|
+
}
|
|
6908
|
+
/**
|
|
6909
|
+
* Parses `.env`-style text into a flat record. Handles CRLF/LF, blank lines,
|
|
6910
|
+
* full-line `#` comments, first-`=` splitting, key/value trimming, and a single
|
|
6911
|
+
* outer quote pair. Does not strip trailing inline comments on unquoted values.
|
|
6912
|
+
*
|
|
6913
|
+
* @param text - The raw file contents.
|
|
6914
|
+
* @returns A flat record of parsed key/value pairs.
|
|
6915
|
+
* @example
|
|
6916
|
+
* ```ts
|
|
6917
|
+
* parseDotenv('A=1\nB="two"'); // { A: "1", B: "two" }
|
|
6918
|
+
* ```
|
|
6919
|
+
*/
|
|
6920
|
+
function parseDotenv(text) {
|
|
6921
|
+
const out = {};
|
|
6922
|
+
for (const line of text.split(/\r?\n/)) {
|
|
6923
|
+
const trimmed = line.trim();
|
|
6924
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
6925
|
+
const eq = trimmed.indexOf("=");
|
|
6926
|
+
if (eq === -1) continue;
|
|
6927
|
+
const key = trimmed.slice(0, eq).trim();
|
|
6928
|
+
out[key] = stripQuotes(trimmed.slice(eq + 1).trim());
|
|
6929
|
+
}
|
|
6930
|
+
return out;
|
|
6931
|
+
}
|
|
6932
|
+
/**
|
|
6933
|
+
* A zero-dependency `.env`-style provider that re-reads and re-parses the file
|
|
6934
|
+
* from disk on every `load()`. Missing file resolves to `{}` (optional
|
|
6935
|
+
* overrides). Strips a single outer quote pair; does not strip trailing inline
|
|
6936
|
+
* comments on unquoted values.
|
|
6937
|
+
*
|
|
6938
|
+
* @param path - Path to the dotenv file. Defaults to `.env.local`.
|
|
6939
|
+
* @returns An {@link EnvProvider} named `dotenv:<path>` that reads fresh per call.
|
|
6940
|
+
* @example
|
|
6941
|
+
* ```ts
|
|
6942
|
+
* const provider = dotenv(".env.local");
|
|
6943
|
+
* provider.load(); // { PUBLIC_API_URL: "/api", ... }
|
|
6944
|
+
* ```
|
|
6945
|
+
*/
|
|
6946
|
+
function dotenv(path = DEFAULT_DOTENV_PATH) {
|
|
6947
|
+
return {
|
|
6948
|
+
name: `dotenv:${path}`,
|
|
6949
|
+
/**
|
|
6950
|
+
* Reads and parses the dotenv file fresh from disk; `{}` if it is missing.
|
|
6951
|
+
*
|
|
6952
|
+
* @returns The parsed environment record, or `{}` when the file is absent.
|
|
6953
|
+
* @example
|
|
6954
|
+
* ```ts
|
|
6955
|
+
* dotenv(".env.local").load();
|
|
6956
|
+
* ```
|
|
6957
|
+
*/
|
|
6958
|
+
load() {
|
|
6959
|
+
if (!existsSync(path)) return {};
|
|
6960
|
+
return parseDotenv(readFileSync(path, "utf8"));
|
|
6961
|
+
}
|
|
6962
|
+
};
|
|
6963
|
+
}
|
|
6964
|
+
/**
|
|
6965
|
+
* A provider that returns a shallow copy of `process.env` at `load()` time.
|
|
6966
|
+
*
|
|
6967
|
+
* @returns An {@link EnvProvider} named `process-env`.
|
|
6968
|
+
* @example
|
|
6969
|
+
* ```ts
|
|
6970
|
+
* const provider = processEnv();
|
|
6971
|
+
* provider.load().HOME; // current process value
|
|
6972
|
+
* ```
|
|
6973
|
+
*/
|
|
6974
|
+
function processEnv() {
|
|
6975
|
+
return {
|
|
6976
|
+
name: "process-env",
|
|
6977
|
+
/**
|
|
6978
|
+
* Returns a shallow copy of `process.env` at call time.
|
|
6979
|
+
*
|
|
6980
|
+
* @returns A fresh shallow copy of `process.env`.
|
|
6981
|
+
* @example
|
|
6982
|
+
* ```ts
|
|
6983
|
+
* processEnv().load();
|
|
6984
|
+
* ```
|
|
6985
|
+
*/
|
|
6986
|
+
load() {
|
|
6987
|
+
return { ...process.env };
|
|
6988
|
+
}
|
|
6989
|
+
};
|
|
6990
|
+
}
|
|
6991
|
+
/**
|
|
6992
|
+
* A provider that reads live, per-request Cloudflare bindings from
|
|
6993
|
+
* `globalThis.__CLOUDFLARE_ENV__` at `load()` time (`?? {}` when absent). Never
|
|
6994
|
+
* caches the binding object; the consumer owns the global's request lifecycle.
|
|
6995
|
+
*
|
|
6996
|
+
* @returns An {@link EnvProvider} named `cloudflare`.
|
|
6997
|
+
* @example
|
|
6998
|
+
* ```ts
|
|
6999
|
+
* globalThis.__CLOUDFLARE_ENV__ = env; // set by the request handler
|
|
7000
|
+
* const provider = cloudflareBindings();
|
|
7001
|
+
* provider.load(); // reads the current request's bindings
|
|
7002
|
+
* ```
|
|
7003
|
+
*/
|
|
7004
|
+
function cloudflareBindings() {
|
|
7005
|
+
return {
|
|
7006
|
+
name: "cloudflare",
|
|
7007
|
+
/**
|
|
7008
|
+
* Reads `globalThis.__CLOUDFLARE_ENV__` fresh, never caching the bindings.
|
|
7009
|
+
*
|
|
7010
|
+
* @returns The current Cloudflare bindings, or `{}` when the global is unset.
|
|
7011
|
+
* @example
|
|
7012
|
+
* ```ts
|
|
7013
|
+
* cloudflareBindings().load();
|
|
7014
|
+
* ```
|
|
7015
|
+
*/
|
|
7016
|
+
load() {
|
|
7017
|
+
return globalThis[CLOUDFLARE_GLOBAL] ?? {};
|
|
7018
|
+
}
|
|
7019
|
+
};
|
|
7020
|
+
}
|
|
7021
|
+
//#endregion
|
|
7022
|
+
//#region src/index.ts
|
|
7023
|
+
/**
|
|
7024
|
+
* @file `@moku-labs/web` — a Moku Layer-2 content static-site + SPA framework.
|
|
7025
|
+
*
|
|
7026
|
+
* `createApp`'s defaults are the **isomorphic** plugins that run unchanged on both
|
|
7027
|
+
* Node and the browser (`site`, `i18n`, `router`, `head`, `spa`, plus the
|
|
7028
|
+
* `log`/`env` core). The Node-only plugins (`content`, `build`, `deploy`,
|
|
7029
|
+
* `data`) are exported for Layer-3 composition: add them with
|
|
7030
|
+
* `createApp({ plugins: [...] })` in a Node build; omit them in a browser app.
|
|
7031
|
+
* The framework never hard-blocks either runtime — the consumer composes the
|
|
7032
|
+
* variant it needs and supplies the matching `env` provider.
|
|
7033
|
+
* @see README.md
|
|
7034
|
+
*/
|
|
7035
|
+
const framework = createCore(coreConfig, {
|
|
5850
7036
|
plugins: [
|
|
5851
7037
|
sitePlugin,
|
|
5852
7038
|
i18nPlugin,
|
|
5853
7039
|
routerPlugin,
|
|
5854
|
-
contentPlugin,
|
|
5855
7040
|
headPlugin,
|
|
5856
|
-
|
|
5857
|
-
spaPlugin,
|
|
5858
|
-
deployPlugin
|
|
7041
|
+
spaPlugin
|
|
5859
7042
|
],
|
|
5860
7043
|
pluginConfigs: {}
|
|
5861
7044
|
});
|
|
7045
|
+
/**
|
|
7046
|
+
* Create and initialize a `@moku-labs/web` application — the Layer-3 entry point.
|
|
7047
|
+
* Your overrides are merged over the framework defaults through the 4-level config
|
|
7048
|
+
* cascade, every plugin's lifecycle runs, and a fully-typed, frozen app is returned.
|
|
7049
|
+
*
|
|
7050
|
+
* The defaults are the isomorphic plugin set (`site`, `i18n`, `router`, `head`,
|
|
7051
|
+
* `spa` + `log`/`env` core). Add the Node-only plugins for an SSG build:
|
|
7052
|
+
* `createApp({ plugins: [contentPlugin, buildPlugin, deployPlugin] })`.
|
|
7053
|
+
*
|
|
7054
|
+
* @param options - Optional configuration:
|
|
7055
|
+
* - `pluginConfigs` — per-plugin overrides, keyed by plugin name.
|
|
7056
|
+
* - `config` — global framework config (e.g. `{ mode: "development" }`).
|
|
7057
|
+
* - `plugins` — extra plugins (Node-only built-ins or your own) merged into the app and its type.
|
|
7058
|
+
* - `onReady` / `onError` / `onStart` / `onStop` — lifecycle callbacks.
|
|
7059
|
+
* @returns The initialized app: `start()`, `stop()`, every plugin's API, and `log`.
|
|
7060
|
+
* @example
|
|
7061
|
+
* ```ts
|
|
7062
|
+
* // Node SSG build — add the node-only plugins:
|
|
7063
|
+
* const app = createApp({
|
|
7064
|
+
* plugins: [contentPlugin, buildPlugin, deployPlugin],
|
|
7065
|
+
* pluginConfigs: {
|
|
7066
|
+
* site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes" },
|
|
7067
|
+
* router: { routes: defineRoutes({ home: route("/"), post: route("/blog/{slug}/") }) }
|
|
7068
|
+
* }
|
|
7069
|
+
* });
|
|
7070
|
+
* await app.start();
|
|
7071
|
+
* await app.build.run();
|
|
7072
|
+
* ```
|
|
7073
|
+
*/
|
|
7074
|
+
const createApp = framework.createApp;
|
|
7075
|
+
/**
|
|
7076
|
+
* Create a custom plugin bound to this framework's `Config`/`Events` and core
|
|
7077
|
+
* APIs. Plugin types are inferred from the spec object — never written explicitly.
|
|
7078
|
+
* Pass the result to {@link createApp} via `plugins`.
|
|
7079
|
+
*
|
|
7080
|
+
* @example
|
|
7081
|
+
* ```ts
|
|
7082
|
+
* const analytics = createPlugin("analytics", {
|
|
7083
|
+
* config: { writeKey: "" },
|
|
7084
|
+
* api: (ctx) => ({ track: (event: string) => ctx.log.info("analytics:track", { event }) })
|
|
7085
|
+
* });
|
|
7086
|
+
*
|
|
7087
|
+
* const app = createApp({ plugins: [analytics] });
|
|
7088
|
+
* ```
|
|
7089
|
+
*/
|
|
7090
|
+
const createPlugin = framework.createPlugin;
|
|
5862
7091
|
//#endregion
|
|
5863
|
-
export { types_exports as Build, types_exports$1 as Content, types_exports$2 as
|
|
7092
|
+
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 };
|