@moku-labs/web 0.5.5 → 0.6.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/dist/index.cjs CHANGED
@@ -35,9 +35,11 @@ let node_crypto = require("node:crypto");
35
35
  let feed = require("feed");
36
36
  let preact = require("preact");
37
37
  let preact_render_to_string = require("preact-render-to-string");
38
+ let node_readline = require("node:readline");
39
+ let node_os = require("node:os");
38
40
  //#region src/plugins/env/api.ts
39
41
  /** Error prefix for all env API failures. */
40
- const ERROR_PREFIX$14 = "[web]";
42
+ const ERROR_PREFIX$15 = "[web]";
41
43
  /**
42
44
  * Creates the env plugin API surface mounted at `ctx.env`. Closes over
43
45
  * `ctx.state` ({@link EnvState}) and reads the frozen `resolved` / `publicMap`
@@ -81,7 +83,7 @@ function createEnvApi(ctx) {
81
83
  */
82
84
  require(key) {
83
85
  const value = resolved.get(key);
84
- if (value === void 0) throw new Error(`${ERROR_PREFIX$14} env: required variable "${key}" is not defined.`);
86
+ if (value === void 0) throw new Error(`${ERROR_PREFIX$15} env: required variable "${key}" is not defined.`);
85
87
  return value;
86
88
  },
87
89
  /**
@@ -148,7 +150,7 @@ function createEnvState() {
148
150
  /** Error message thrown by every frozen-map mutator. */
149
151
  const FROZEN_MESSAGE = "env: map is frozen and cannot be mutated";
150
152
  /** Error prefix for all resolution-pipeline failures. */
151
- const ERROR_PREFIX$13 = "[web]";
153
+ const ERROR_PREFIX$14 = "[web]";
152
154
  /**
153
155
  * Throws the canonical frozen-map error; installed as a map's `set`/`clear`/`delete`.
154
156
  *
@@ -199,8 +201,8 @@ function crossCheckPublicPrefix(config) {
199
201
  const { schema, publicPrefix } = config;
200
202
  for (const [key, spec] of Object.entries(schema)) {
201
203
  const hasPrefix = key.startsWith(publicPrefix);
202
- if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$13} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
203
- if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$13} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
204
+ if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$14} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
205
+ if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$14} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
204
206
  }
205
207
  }
206
208
  /**
@@ -251,7 +253,7 @@ function validateSchema(ctx) {
251
253
  crossCheckPublicPrefix(config);
252
254
  for (const [key, spec] of Object.entries(schema)) {
253
255
  if (merged[key] === void 0 && spec.default !== void 0) merged[key] = spec.default;
254
- if (merged[key] === void 0 && spec.required === true) throw new Error(`${ERROR_PREFIX$13} env: required variable "${key}" is not defined by any provider or default.`);
256
+ if (merged[key] === void 0 && spec.required === true) throw new Error(`${ERROR_PREFIX$14} env: required variable "${key}" is not defined by any provider or default.`);
255
257
  }
256
258
  for (const [key, spec] of Object.entries(schema)) {
257
259
  const value = merged[key];
@@ -793,7 +795,7 @@ const createCore = coreConfig.createCore;
793
795
  //#endregion
794
796
  //#region src/plugins/i18n/api.ts
795
797
  /** Error prefix for all i18n lifecycle failures. */
796
- const ERROR_PREFIX$12 = "[web]";
798
+ const ERROR_PREFIX$13 = "[web]";
797
799
  /**
798
800
  * Validates the resolved i18n config (fail-fast at `createApp`). Throws when
799
801
  * `locales` is empty or when `defaultLocale` is not a member of `locales`.
@@ -809,8 +811,8 @@ const ERROR_PREFIX$12 = "[web]";
809
811
  */
810
812
  function validateI18nConfig(ctx) {
811
813
  const { locales, defaultLocale } = ctx.config;
812
- if (locales.length === 0) throw new Error(`${ERROR_PREFIX$12} i18n.locales must contain at least one locale.\n Set pluginConfigs.i18n.locales to a non-empty array, e.g. ["en"].`);
813
- if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$12} i18n.defaultLocale "${defaultLocale}" is not in i18n.locales [${locales.join(", ")}].\n Set pluginConfigs.i18n.defaultLocale to one of the configured locales, or add "${defaultLocale}" to i18n.locales.`);
814
+ if (locales.length === 0) throw new Error(`${ERROR_PREFIX$13} i18n.locales must contain at least one locale.\n Set pluginConfigs.i18n.locales to a non-empty array, e.g. ["en"].`);
815
+ if (!locales.includes(defaultLocale)) throw new Error(`${ERROR_PREFIX$13} i18n.defaultLocale "${defaultLocale}" is not in i18n.locales [${locales.join(", ")}].\n Set pluginConfigs.i18n.defaultLocale to one of the configured locales, or add "${defaultLocale}" to i18n.locales.`);
814
816
  }
815
817
  /**
816
818
  * Creates the i18n plugin API surface — locale registry accessors plus the
@@ -1335,6 +1337,23 @@ function calculateReadingTime(text) {
1335
1337
  function articleToUrl(locale, slug) {
1336
1338
  return `/${locale}/${slug}/`;
1337
1339
  }
1340
+ /** Matches an `<img>` `src` that points at the co-located `images/` dir (relative or root-relative). */
1341
+ const RELATIVE_IMAGE_SRC = /(<img\b[^>]*?\bsrc=")(?:\.?\/)?images\//g;
1342
+ /**
1343
+ * Rewrite relative co-located image URLs (`./images/x.webp`) in rendered article HTML to the shared
1344
+ * absolute path the build copies them to (`/<slug>/images/...`), so they resolve from any locale page.
1345
+ *
1346
+ * @param html - The rendered article HTML.
1347
+ * @param slug - Article directory name.
1348
+ * @returns The HTML with image `src`s rewritten to absolute paths.
1349
+ * @example
1350
+ * ```ts
1351
+ * rewriteImageUrls('<img src="./images/a.webp">', "post"); // '<img src="/post/images/a.webp">'
1352
+ * ```
1353
+ */
1354
+ function rewriteImageUrls(html, slug) {
1355
+ return html.replaceAll(RELATIVE_IMAGE_SRC, `$1/${slug}/images/`);
1356
+ }
1338
1357
  /**
1339
1358
  * Build the canonical "article not found" error for {@link createContentApi.load}.
1340
1359
  * Centralised so the null-resolve path and the production draft-suppression path
@@ -1450,7 +1469,7 @@ async function readArticle(ctx, slug, fileLocale, outLocale, isFallback) {
1450
1469
  ctx.state.dirtyPaths.delete(filePath);
1451
1470
  const { frontmatter, body } = parseFrontmatter(raw, ctx.config);
1452
1471
  const processor = ensureProcessor(ctx.state, ctx.config);
1453
- const html = String(await processor.process(body));
1472
+ const html = rewriteImageUrls(String(await processor.process(body)), slug);
1454
1473
  const { readingTime, wordCount } = calculateReadingTime(body);
1455
1474
  return {
1456
1475
  frontmatter,
@@ -1662,6 +1681,18 @@ function createContentApi(ctx) {
1662
1681
  */
1663
1682
  articleToCard(article) {
1664
1683
  return toCard(article);
1684
+ },
1685
+ /**
1686
+ * The configured content source directory (e.g. `"./content"`).
1687
+ *
1688
+ * @returns The content directory path from config.
1689
+ * @example
1690
+ * ```ts
1691
+ * api.contentDir(); // "./content"
1692
+ * ```
1693
+ */
1694
+ contentDir() {
1695
+ return ctx.config.contentDir;
1665
1696
  }
1666
1697
  };
1667
1698
  }
@@ -1785,7 +1816,7 @@ const contentPlugin = createPlugin$1("content", {
1785
1816
  //#endregion
1786
1817
  //#region src/plugins/site/api.ts
1787
1818
  /** Error prefix for all site lifecycle/validation failures. */
1788
- const ERROR_PREFIX$11 = "[web]";
1819
+ const ERROR_PREFIX$12 = "[web]";
1789
1820
  /**
1790
1821
  * Joins a relative path against an absolute base URL, normalizing the slash
1791
1822
  * boundary to exactly one "/". Returns the base unchanged for an empty or
@@ -1853,8 +1884,8 @@ function isAbsoluteUrl(value) {
1853
1884
  * ```
1854
1885
  */
1855
1886
  function validateSiteConfig(ctx) {
1856
- if (!isNonEmpty(ctx.config.name)) throw new Error(`${ERROR_PREFIX$11} site.name is required.\n Provide a non-empty site name in pluginConfigs.site.name.`);
1857
- if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$11} site.url must be a valid absolute URL (http/https), received ${JSON.stringify(ctx.config.url)}.\n Provide an absolute URL in pluginConfigs.site.url, e.g. "https://blog.dev".`);
1887
+ if (!isNonEmpty(ctx.config.name)) throw new Error(`${ERROR_PREFIX$12} site.name is required.\n Provide a non-empty site name in pluginConfigs.site.name.`);
1888
+ if (!isAbsoluteUrl(ctx.config.url)) throw new Error(`${ERROR_PREFIX$12} site.url must be a valid absolute URL (http/https), received ${JSON.stringify(ctx.config.url)}.\n Provide an absolute URL in pluginConfigs.site.url, e.g. "https://blog.dev".`);
1858
1889
  }
1859
1890
  /**
1860
1891
  * Creates the site plugin API surface — read-only accessors over frozen config
@@ -2044,7 +2075,7 @@ function matchRoute(compiled, pathname) {
2044
2075
  * `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
2045
2076
  */
2046
2077
  /** Error prefix for router API failures. */
2047
- const ERROR_PREFIX$10 = "[web] router";
2078
+ const ERROR_PREFIX$11 = "[web] router";
2048
2079
  /**
2049
2080
  * Read the compiled matcher table, throwing if `onInit` has not run yet. This
2050
2081
  * `null` cannot occur in practice post-`onInit`; the guard documents the invariant.
@@ -2058,7 +2089,7 @@ const ERROR_PREFIX$10 = "[web] router";
2058
2089
  * ```
2059
2090
  */
2060
2091
  function readTable(state) {
2061
- if (state.table === null) throw new Error(`${ERROR_PREFIX$10}: matcher table accessed before onInit compiled it.`);
2092
+ if (state.table === null) throw new Error(`${ERROR_PREFIX$11}: matcher table accessed before onInit compiled it.`);
2062
2093
  return state.table;
2063
2094
  }
2064
2095
  /**
@@ -2112,7 +2143,7 @@ function toClientRoute(entry) {
2112
2143
  * api.match("/en/hello/");
2113
2144
  * ```
2114
2145
  */
2115
- function createApi$4(ctx) {
2146
+ function createApi$5(ctx) {
2116
2147
  const { state } = ctx;
2117
2148
  return {
2118
2149
  /**
@@ -2142,7 +2173,7 @@ function createApi$4(ctx) {
2142
2173
  */
2143
2174
  toUrl(routeName, params) {
2144
2175
  const entry = readTable(state).byName.get(routeName);
2145
- if (!entry) throw new Error(`${ERROR_PREFIX$10}: unknown route name "${routeName}".`);
2176
+ if (!entry) throw new Error(`${ERROR_PREFIX$11}: unknown route name "${routeName}".`);
2146
2177
  return entry.toUrl(params);
2147
2178
  },
2148
2179
  /**
@@ -2277,7 +2308,7 @@ function bySpecificity(a, b) {
2277
2308
  * only (`CompileInput`) — never the plugin ctx.
2278
2309
  */
2279
2310
  /** Shared `[web]` error prefix for router validation failures. */
2280
- const ERROR_PREFIX$9 = "[web] router";
2311
+ const ERROR_PREFIX$10 = "[web] router";
2281
2312
  /**
2282
2313
  * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
2283
2314
  * naming the offending route/pattern on any failure: empty map, a pattern not
@@ -2292,12 +2323,12 @@ const ERROR_PREFIX$9 = "[web] router";
2292
2323
  */
2293
2324
  function validateRoutes(routes) {
2294
2325
  const names = Object.keys(routes);
2295
- if (names.length === 0) throw new Error(`${ERROR_PREFIX$9}: route map is empty — provide at least one route via pluginConfigs.router.routes.`);
2326
+ if (names.length === 0) throw new Error(`${ERROR_PREFIX$10}: route map is empty — provide at least one route via pluginConfigs.router.routes.`);
2296
2327
  for (const name of names) {
2297
2328
  const pattern = routes[name]?.pattern ?? "";
2298
- if (!pattern.startsWith("/")) throw new Error(`${ERROR_PREFIX$9}: route "${name}" pattern must start with "/" (got "${pattern}").`);
2299
- if ((pattern.match(/\{/g) ?? []).length !== (pattern.match(/\}/g) ?? []).length) throw new Error(`${ERROR_PREFIX$9}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
2300
- if ((pattern.match(/\{lang:\?\}/g) ?? []).length > 1) throw new Error(`${ERROR_PREFIX$9}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
2329
+ if (!pattern.startsWith("/")) throw new Error(`${ERROR_PREFIX$10}: route "${name}" pattern must start with "/" (got "${pattern}").`);
2330
+ if ((pattern.match(/\{/g) ?? []).length !== (pattern.match(/\}/g) ?? []).length) throw new Error(`${ERROR_PREFIX$10}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
2331
+ if ((pattern.match(/\{lang:\?\}/g) ?? []).length > 1) throw new Error(`${ERROR_PREFIX$10}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
2301
2332
  }
2302
2333
  }
2303
2334
  /**
@@ -2702,7 +2733,7 @@ function defineRoutes(routes) {
2702
2733
  * const state = createState({ global: {}, config: { routes: {} } });
2703
2734
  * ```
2704
2735
  */
2705
- function createState$4(_ctx) {
2736
+ function createState$5(_ctx) {
2706
2737
  return {
2707
2738
  table: null,
2708
2739
  mode: _ctx.config.mode ?? "hybrid"
@@ -2738,8 +2769,8 @@ const routerPlugin = createPlugin$1("router", {
2738
2769
  routes: {},
2739
2770
  mode: "hybrid"
2740
2771
  },
2741
- createState: createState$4,
2742
- api: createApi$4,
2772
+ createState: createState$5,
2773
+ api: createApi$5,
2743
2774
  onInit(ctx) {
2744
2775
  const i18n = ctx.require(i18nPlugin);
2745
2776
  const baseUrl = ctx.require(sitePlugin).url();
@@ -3079,7 +3110,7 @@ function serializeHead(elements) {
3079
3110
  * it to a string. It holds no resource and caches no subscription.
3080
3111
  */
3081
3112
  /** Error prefix for head API invariant failures. */
3082
- const ERROR_PREFIX$8 = "[head]";
3113
+ const ERROR_PREFIX$9 = "[head]";
3083
3114
  /**
3084
3115
  * Read the normalized defaults, asserting the post-`onInit` invariant (the slot is
3085
3116
  * `null` only before `onInit` assigns it, which cannot occur at render time).
@@ -3093,7 +3124,7 @@ const ERROR_PREFIX$8 = "[head]";
3093
3124
  * ```
3094
3125
  */
3095
3126
  function readDefaults(state) {
3096
- if (state.defaults === null) throw new Error(`${ERROR_PREFIX$8}: defaults accessed before onInit normalized them.`);
3127
+ if (state.defaults === null) throw new Error(`${ERROR_PREFIX$9}: defaults accessed before onInit normalized them.`);
3097
3128
  return state.defaults;
3098
3129
  }
3099
3130
  /**
@@ -3109,7 +3140,7 @@ function readDefaults(state) {
3109
3140
  * api.render(route, data);
3110
3141
  * ```
3111
3142
  */
3112
- function createApi$3(ctx) {
3143
+ function createApi$4(ctx) {
3113
3144
  return {
3114
3145
  /**
3115
3146
  * Compose the final `<head>` inner HTML for a route (pulled by `build`).
@@ -3136,7 +3167,7 @@ render(route, data) {
3136
3167
  //#endregion
3137
3168
  //#region src/plugins/head/config.ts
3138
3169
  /** Error prefix for all head config-validation failures. */
3139
- const ERROR_PREFIX$7 = "[head] config:";
3170
+ const ERROR_PREFIX$8 = "[head] config:";
3140
3171
  /** The allowed `twitterCard` literals (also the runtime guard set). */
3141
3172
  const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
3142
3173
  /**
@@ -3149,7 +3180,7 @@ const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
3149
3180
  * createPlugin("head", { config: defaultConfig });
3150
3181
  * ```
3151
3182
  */
3152
- const defaultConfig$2 = { twitterCard: "summary_large_image" };
3183
+ const defaultConfig$3 = { twitterCard: "summary_large_image" };
3153
3184
  /**
3154
3185
  * Structurally validate the resolved head config (no I/O). Throws a standard
3155
3186
  * `[head] config: …` error when `titleTemplate` is provided without the `%s`
@@ -3163,8 +3194,8 @@ const defaultConfig$2 = { twitterCard: "summary_large_image" };
3163
3194
  * ```
3164
3195
  */
3165
3196
  function validateHeadConfig(config) {
3166
- if (config.titleTemplate !== void 0 && !config.titleTemplate.includes("%s")) throw new Error(`${ERROR_PREFIX$7} titleTemplate must contain the "%s" token (replaced by the route title), received ${JSON.stringify(config.titleTemplate)}.`);
3167
- if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$7} twitterCard must be one of [${VALID_TWITTER_CARDS.join(", ")}], received ${JSON.stringify(config.twitterCard)}.`);
3197
+ if (config.titleTemplate !== void 0 && !config.titleTemplate.includes("%s")) throw new Error(`${ERROR_PREFIX$8} titleTemplate must contain the "%s" token (replaced by the route title), received ${JSON.stringify(config.titleTemplate)}.`);
3198
+ if (config.twitterCard !== void 0 && !VALID_TWITTER_CARDS.includes(config.twitterCard)) throw new Error(`${ERROR_PREFIX$8} twitterCard must be one of [${VALID_TWITTER_CARDS.join(", ")}], received ${JSON.stringify(config.twitterCard)}.`);
3168
3199
  }
3169
3200
  /**
3170
3201
  * Validate then build the frozen, normalized {@link HeadDefaults} snapshot read by
@@ -3232,7 +3263,7 @@ const headHelpers = {
3232
3263
  * const state = createState({ global: {}, config: {} });
3233
3264
  * ```
3234
3265
  */
3235
- function createState$3(_ctx) {
3266
+ function createState$4(_ctx) {
3236
3267
  return { defaults: null };
3237
3268
  }
3238
3269
  //#endregion
@@ -3267,9 +3298,9 @@ const headPlugin = createPlugin$1("head", {
3267
3298
  routerPlugin
3268
3299
  ],
3269
3300
  helpers: headHelpers,
3270
- config: defaultConfig$2,
3271
- createState: createState$3,
3272
- api: createApi$3,
3301
+ config: defaultConfig$3,
3302
+ createState: createState$4,
3303
+ api: createApi$4,
3273
3304
  onInit(ctx) {
3274
3305
  ctx.state.defaults = normalizeHeadConfig(ctx.config);
3275
3306
  }
@@ -3445,6 +3476,52 @@ function readCachedContent(ctx) {
3445
3476
  return cached instanceof Map ? cached : /* @__PURE__ */ new Map();
3446
3477
  }
3447
3478
  //#endregion
3479
+ //#region src/plugins/build/phases/content-images.ts
3480
+ /**
3481
+ * @file build phase — content-images. Copies each article's co-located image directory
3482
+ * (`<contentDir>/<slug>/images/`) to a single shared output dir (`<outDir>/<slug>/images/`) reused by
3483
+ * every locale, matching the absolute `/<slug>/images/...` URLs the content renderer emits. Gated by
3484
+ * `config.images`.
3485
+ */
3486
+ /** Conventional per-article image subdirectory name (alongside `<slug>/<locale>.md`). */
3487
+ const ARTICLE_IMAGE_DIR = "images";
3488
+ /**
3489
+ * Copy every article's co-located `images/` directory to `<outDir>/<slug>/images/`. No-op when
3490
+ * `config.images` is false or the content directory does not exist.
3491
+ *
3492
+ * @param ctx - Plugin context (provides `config`, `log`, `require`).
3493
+ * @returns The number of directories copied (one per article that has an `images/` dir).
3494
+ * @example
3495
+ * ```ts
3496
+ * const copied = await copyContentImages(ctx);
3497
+ * ```
3498
+ */
3499
+ async function copyContentImages(ctx) {
3500
+ if (!ctx.config.images) {
3501
+ ctx.log.debug("build:content-images", { skipped: true });
3502
+ return 0;
3503
+ }
3504
+ const contentDir = ctx.require(contentPlugin).contentDir();
3505
+ if (!(0, node_fs.existsSync)(contentDir)) {
3506
+ ctx.log.debug("build:content-images", {
3507
+ skipped: true,
3508
+ reason: "no content dir"
3509
+ });
3510
+ return 0;
3511
+ }
3512
+ const entries = await (0, node_fs_promises.readdir)(contentDir, { withFileTypes: true });
3513
+ let copied = 0;
3514
+ for (const entry of entries) {
3515
+ if (!entry.isDirectory()) continue;
3516
+ const source = node_path$1.default.join(contentDir, entry.name, ARTICLE_IMAGE_DIR);
3517
+ if (!(0, node_fs.existsSync)(source)) continue;
3518
+ await (0, node_fs_promises.cp)(source, node_path$1.default.join(ctx.config.outDir, entry.name, ARTICLE_IMAGE_DIR), { recursive: true });
3519
+ copied += 1;
3520
+ }
3521
+ ctx.log.debug("build:content-images", { copied });
3522
+ return copied;
3523
+ }
3524
+ //#endregion
3448
3525
  //#region src/plugins/build/phases/feeds.ts
3449
3526
  /**
3450
3527
  * @file build phase 4 — feeds. Generates RSS/Atom/JSON from cached content plus
@@ -4690,6 +4767,7 @@ const PHASE_ORDER = [
4690
4767
  "content",
4691
4768
  "images",
4692
4769
  "pages",
4770
+ "content-images",
4693
4771
  "feeds",
4694
4772
  "sitemap",
4695
4773
  "og-images",
@@ -4796,6 +4874,7 @@ async function runPipeline(ctx, options) {
4796
4874
  await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
4797
4875
  await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext)), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
4798
4876
  const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext));
4877
+ await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
4799
4878
  await runOutputs(phaseContext);
4800
4879
  await withPhase(phaseContext, "root-index", async () => {
4801
4880
  if (pages.rootHtml !== null) await (0, node_fs_promises.writeFile)(node_path$1.default.join(outDir, "index.html"), pages.rootHtml, "utf8");
@@ -4814,7 +4893,7 @@ async function runPipeline(ctx, options) {
4814
4893
  * @file build plugin — API factory (run + phases), cross-plugin wiring, and onInit config validation.
4815
4894
  */
4816
4895
  /** Error prefix for build config/validation failures (spec/11 Part-3). */
4817
- const ERROR_PREFIX$6 = "[web] build";
4896
+ const ERROR_PREFIX$7 = "[web] build";
4818
4897
  /** Recognized font file extensions for OG-image validation. */
4819
4898
  const FONT_EXTENSIONS = [
4820
4899
  ".ttf",
@@ -4822,7 +4901,7 @@ const FONT_EXTENSIONS = [
4822
4901
  ".woff"
4823
4902
  ];
4824
4903
  /** Typed default `build` config (R6: no inline `as`). `ogImage: false` disables OG generation. */
4825
- const defaultConfig$1 = {
4904
+ const defaultConfig$2 = {
4826
4905
  outDir: "./dist",
4827
4906
  minify: true,
4828
4907
  feeds: true,
@@ -4844,7 +4923,7 @@ const defaultConfig$1 = {
4844
4923
  * await api.run({ outDir: "./preview" });
4845
4924
  * ```
4846
4925
  */
4847
- function createApi$2(ctx) {
4926
+ function createApi$3(ctx) {
4848
4927
  return {
4849
4928
  /**
4850
4929
  * Run the full SSG pipeline and write the site to disk.
@@ -4885,8 +4964,8 @@ function createApi$2(ctx) {
4885
4964
  * ```
4886
4965
  */
4887
4966
  function validateFonts(og) {
4888
- if (typeof og.fontDir !== "string" || og.fontDir.length === 0 || !(0, node_fs.existsSync)(og.fontDir)) throw new Error(`${ERROR_PREFIX$6}.ogImage: fontDir "${og.fontDir}" does not exist — provide a directory with at least one font.`);
4889
- if (!(0, node_fs.readdirSync)(og.fontDir).some((name) => FONT_EXTENSIONS.some((extension) => name.endsWith(extension)))) throw new Error(`${ERROR_PREFIX$6}.ogImage: fontDir "${og.fontDir}" contains no .ttf/.otf/.woff font files.`);
4967
+ if (typeof og.fontDir !== "string" || og.fontDir.length === 0 || !(0, node_fs.existsSync)(og.fontDir)) throw new Error(`${ERROR_PREFIX$7}.ogImage: fontDir "${og.fontDir}" does not exist — provide a directory with at least one font.`);
4968
+ if (!(0, node_fs.readdirSync)(og.fontDir).some((name) => FONT_EXTENSIONS.some((extension) => name.endsWith(extension)))) throw new Error(`${ERROR_PREFIX$7}.ogImage: fontDir "${og.fontDir}" contains no .ttf/.otf/.woff font files.`);
4890
4969
  }
4891
4970
  /**
4892
4971
  * Validates `build` config synchronously in `onInit` (return value discarded).
@@ -4899,11 +4978,11 @@ function validateFonts(og) {
4899
4978
  * validateConfig(ctx.config);
4900
4979
  * ```
4901
4980
  */
4902
- function validateConfig$1(config) {
4903
- if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$6}.outDir: must be a non-empty string.`);
4904
- if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$6}.publicDir: must be a string when set.`);
4905
- if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$6}.template: must be a string path when set.`);
4906
- if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$6}.clientEntry: must be a string path when set.`);
4981
+ function validateConfig$2(config) {
4982
+ if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$7}.outDir: must be a non-empty string.`);
4983
+ if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$7}.publicDir: must be a string when set.`);
4984
+ if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$7}.template: must be a string path when set.`);
4985
+ if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$7}.clientEntry: must be a string path when set.`);
4907
4986
  if (config.ogImage) validateFonts(config.ogImage);
4908
4987
  }
4909
4988
  //#endregion
@@ -4942,7 +5021,7 @@ function createEvents(register) {
4942
5021
  * const state = createState({ global: {}, config });
4943
5022
  * ```
4944
5023
  */
4945
- function createState$2(ctx) {
5024
+ function createState$3(ctx) {
4946
5025
  return {
4947
5026
  config: ctx.config,
4948
5027
  manifest: null,
@@ -4986,11 +5065,11 @@ const buildPlugin = createPlugin$1("build", {
4986
5065
  routerPlugin,
4987
5066
  headPlugin
4988
5067
  ],
4989
- config: defaultConfig$1,
4990
- createState: createState$2,
5068
+ config: defaultConfig$2,
5069
+ createState: createState$3,
4991
5070
  events: createEvents,
4992
- api: createApi$2,
4993
- onInit: (ctx) => validateConfig$1(ctx.config)
5071
+ api: createApi$3,
5072
+ onInit: (ctx) => validateConfig$2(ctx.config)
4994
5073
  });
4995
5074
  //#endregion
4996
5075
  //#region src/plugins/deploy/wrangler.ts
@@ -5005,7 +5084,7 @@ const buildPlugin = createPlugin$1("build", {
5005
5084
  */
5006
5085
  const MOKU_WRANGLER_VERSION = "4.34.0";
5007
5086
  /** Error prefix for deploy runtime failures (spec/11 Part-3). */
5008
- const ERROR_PREFIX$5 = "[web] deploy";
5087
+ const ERROR_PREFIX$6 = "[web] deploy";
5009
5088
  /** Mask substituted for a detected secret-like token. */
5010
5089
  const MASK = "***";
5011
5090
  /** Minimum token length eligible for entropy-gated scrubbing. */
@@ -5083,7 +5162,7 @@ function scrubSecrets(text, allowlist) {
5083
5162
  * guardBranch("preview/landing"); // "preview/landing"
5084
5163
  */
5085
5164
  function guardBranch(branch) {
5086
- if (!BRANCH_REGEX.test(branch) || branch.startsWith("-")) throw deployError("ERR_DEPLOY_INVALID_BRANCH", `${ERROR_PREFIX$5}: branch ${JSON.stringify(branch)} is invalid.\n Branches must match /^[a-zA-Z0-9/_.-]+$/ so they cannot inject wrangler flags.`);
5165
+ if (!BRANCH_REGEX.test(branch) || branch.startsWith("-")) throw deployError("ERR_DEPLOY_INVALID_BRANCH", `${ERROR_PREFIX$6}: branch ${JSON.stringify(branch)} is invalid.\n Branches must match /^[a-zA-Z0-9/_.-]+$/ so they cannot inject wrangler flags.`);
5087
5166
  return branch;
5088
5167
  }
5089
5168
  /**
@@ -5100,7 +5179,7 @@ function guardBranch(branch) {
5100
5179
  function assertWithinRoot(outDir, root) {
5101
5180
  const resolved = node_path$1.default.isAbsolute(outDir) ? node_path$1.default.resolve(outDir) : node_path$1.default.resolve(root, outDir);
5102
5181
  const rootResolved = node_path$1.default.resolve(root);
5103
- if (resolved !== rootResolved && !resolved.startsWith(rootResolved + node_path$1.default.sep)) throw deployError("ERR_DEPLOY_PATH_TRAVERSAL", `${ERROR_PREFIX$5}: outDir ${JSON.stringify(outDir)} resolves outside the project root.\n Point outDir at a directory inside ${JSON.stringify(rootResolved)}.`);
5182
+ if (resolved !== rootResolved && !resolved.startsWith(rootResolved + node_path$1.default.sep)) throw deployError("ERR_DEPLOY_PATH_TRAVERSAL", `${ERROR_PREFIX$6}: outDir ${JSON.stringify(outDir)} resolves outside the project root.\n Point outDir at a directory inside ${JSON.stringify(rootResolved)}.`);
5104
5183
  return resolved;
5105
5184
  }
5106
5185
  /**
@@ -5185,11 +5264,11 @@ function classifyWranglerError(exitCode, scrubbedStderr) {
5185
5264
  const haystack = scrubbedStderr.toLowerCase();
5186
5265
  for (const signature of ERROR_SIGNATURES) if (signature.match.some((needle) => haystack.includes(needle))) return {
5187
5266
  code: signature.kind,
5188
- message: `${ERROR_PREFIX$5}: wrangler failed (exit ${exitCode}).\n ${signature.advice}`
5267
+ message: `${ERROR_PREFIX$6}: wrangler failed (exit ${exitCode}).\n ${signature.advice}`
5189
5268
  };
5190
5269
  return {
5191
5270
  code: "ERR_DEPLOY_WRANGLER_FAILED",
5192
- message: `${ERROR_PREFIX$5}: wrangler failed (exit ${exitCode}).\n ${scrubbedStderr.trim().slice(-500)}`
5271
+ message: `${ERROR_PREFIX$6}: wrangler failed (exit ${exitCode}).\n ${scrubbedStderr.trim().slice(-500)}`
5193
5272
  };
5194
5273
  }
5195
5274
  /**
@@ -5455,7 +5534,7 @@ async function reconcile(input) {
5455
5534
  * and short-circuiting on the first failure.
5456
5535
  */
5457
5536
  /** Error prefix for deploy preflight failures (spec/11 Part-3). */
5458
- const ERROR_PREFIX$4 = "[web] deploy";
5537
+ const ERROR_PREFIX$5 = "[web] deploy";
5459
5538
  /** Cloudflare Pages free-tier file-count limit. */
5460
5539
  const FREE_TIER_FILE_LIMIT = 2e4;
5461
5540
  /** Cloudflare Pages paid-tier file-count limit (env override target). */
@@ -5531,15 +5610,15 @@ async function runPreflight(config, root, env = process.env) {
5531
5610
  try {
5532
5611
  await (0, node_fs_promises.stat)(wranglerPath);
5533
5612
  } catch {
5534
- throw deployError("ERR_DEPLOY_NO_WRANGLER_CONFIG", `${ERROR_PREFIX$4}: wrangler.jsonc not found.\n Run \`app.deploy.init()\` to scaffold it, then retry.`);
5613
+ throw deployError("ERR_DEPLOY_NO_WRANGLER_CONFIG", `${ERROR_PREFIX$5}: wrangler.jsonc not found.\n Run \`app.deploy.init()\` to scaffold it, then retry.`);
5535
5614
  }
5536
5615
  const stats = await inspectOutdir(node_path$1.default.isAbsolute(config.outDir) ? node_path$1.default.resolve(config.outDir) : node_path$1.default.resolve(root, config.outDir)).catch(() => {
5537
- throw deployError("ERR_DEPLOY_EMPTY_OUTDIR", `${ERROR_PREFIX$4}: outDir ${JSON.stringify(config.outDir)} is missing.\n Run your build first, then retry.`);
5616
+ throw deployError("ERR_DEPLOY_EMPTY_OUTDIR", `${ERROR_PREFIX$5}: outDir ${JSON.stringify(config.outDir)} is missing.\n Run your build first, then retry.`);
5538
5617
  });
5539
- if (stats.fileCount === 0) throw deployError("ERR_DEPLOY_EMPTY_OUTDIR", `${ERROR_PREFIX$4}: outDir ${JSON.stringify(config.outDir)} is empty — nothing to deploy.`);
5618
+ if (stats.fileCount === 0) throw deployError("ERR_DEPLOY_EMPTY_OUTDIR", `${ERROR_PREFIX$5}: outDir ${JSON.stringify(config.outDir)} is empty — nothing to deploy.`);
5540
5619
  const limit = resolveFileLimit(env);
5541
- if (stats.fileCount > limit) throw deployError("ERR_DEPLOY_TOO_MANY_FILES", `${ERROR_PREFIX$4}: outDir contains ${stats.fileCount} files; the limit is ${limit}.\n Raise it with ${MAX_FILES_ENV} (paid tier) or reduce the output.`);
5542
- if (stats.oversizePath !== null) throw deployError("ERR_DEPLOY_FILE_TOO_LARGE", `${ERROR_PREFIX$4}: file ${JSON.stringify(stats.oversizePath)} exceeds the 25 MiB per-file limit.`);
5620
+ if (stats.fileCount > limit) throw deployError("ERR_DEPLOY_TOO_MANY_FILES", `${ERROR_PREFIX$5}: outDir contains ${stats.fileCount} files; the limit is ${limit}.\n Raise it with ${MAX_FILES_ENV} (paid tier) or reduce the output.`);
5621
+ if (stats.oversizePath !== null) throw deployError("ERR_DEPLOY_FILE_TOO_LARGE", `${ERROR_PREFIX$5}: file ${JSON.stringify(stats.oversizePath)} exceeds the 25 MiB per-file limit.`);
5543
5622
  }
5544
5623
  //#endregion
5545
5624
  //#region src/plugins/deploy/slug.ts
@@ -5584,7 +5663,7 @@ function toSlug(name) {
5584
5663
  //#endregion
5585
5664
  //#region src/plugins/deploy/api.ts
5586
5665
  /** Error prefix for deploy config/validation failures (spec/11 Part-3). */
5587
- const ERROR_PREFIX$3 = "[web] deploy";
5666
+ const ERROR_PREFIX$4 = "[web] deploy";
5588
5667
  /** `YYYY-MM-DD` validator for the compatibility date config field. */
5589
5668
  const COMPAT_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
5590
5669
  /**
@@ -5597,12 +5676,12 @@ const COMPAT_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
5597
5676
  * @example
5598
5677
  * createPlugin("deploy", { onInit: validateConfig });
5599
5678
  */
5600
- function validateConfig(ctx) {
5679
+ function validateConfig$1(ctx) {
5601
5680
  const { config } = ctx;
5602
- if (config.target !== "cloudflare-pages") throw deployError("ERR_DEPLOY_CONFIG", `${ERROR_PREFIX$3}: target ${JSON.stringify(config.target)} is unsupported.\n Only "cloudflare-pages" is supported in this version.`);
5603
- if (typeof config.outDir !== "string" || config.outDir.length === 0) throw deployError("ERR_DEPLOY_CONFIG", `${ERROR_PREFIX$3}: outDir must be a non-empty string.\n Set pluginConfigs.deploy.outDir to your build output directory (e.g. "dist").`);
5604
- if (!Array.isArray(config.scrubAllowlist) || !config.scrubAllowlist.every((item) => typeof item === "string")) throw deployError("ERR_DEPLOY_CONFIG", `${ERROR_PREFIX$3}: scrubAllowlist must be an array of strings.`);
5605
- if (config.compatibilityDate !== void 0 && !COMPAT_DATE_REGEX.test(config.compatibilityDate)) throw deployError("ERR_DEPLOY_CONFIG", `${ERROR_PREFIX$3}: compatibilityDate ${JSON.stringify(config.compatibilityDate)} must be in YYYY-MM-DD form.`);
5681
+ if (config.target !== "cloudflare-pages") throw deployError("ERR_DEPLOY_CONFIG", `${ERROR_PREFIX$4}: target ${JSON.stringify(config.target)} is unsupported.\n Only "cloudflare-pages" is supported in this version.`);
5682
+ if (typeof config.outDir !== "string" || config.outDir.length === 0) throw deployError("ERR_DEPLOY_CONFIG", `${ERROR_PREFIX$4}: outDir must be a non-empty string.\n Set pluginConfigs.deploy.outDir to your build output directory (e.g. "dist").`);
5683
+ if (!Array.isArray(config.scrubAllowlist) || !config.scrubAllowlist.every((item) => typeof item === "string")) throw deployError("ERR_DEPLOY_CONFIG", `${ERROR_PREFIX$4}: scrubAllowlist must be an array of strings.`);
5684
+ if (config.compatibilityDate !== void 0 && !COMPAT_DATE_REGEX.test(config.compatibilityDate)) throw deployError("ERR_DEPLOY_CONFIG", `${ERROR_PREFIX$4}: compatibilityDate ${JSON.stringify(config.compatibilityDate)} must be in YYYY-MM-DD form.`);
5606
5685
  ctx.require(sitePlugin);
5607
5686
  }
5608
5687
  /**
@@ -5618,7 +5697,7 @@ function validateConfig(ctx) {
5618
5697
  * const api = createApi(ctx);
5619
5698
  * await api.run({ branch: "preview/landing" });
5620
5699
  */
5621
- function createApi$1(ctx) {
5700
+ function createApi$2(ctx) {
5622
5701
  return {
5623
5702
  /**
5624
5703
  * Deploy the built outDir to Cloudflare Pages via the wrangler subprocess.
@@ -5709,7 +5788,7 @@ function createApi$1(ctx) {
5709
5788
  * createPlugin("deploy", { config: defaultConfig });
5710
5789
  * ```
5711
5790
  */
5712
- const defaultConfig = {
5791
+ const defaultConfig$1 = {
5713
5792
  target: "cloudflare-pages",
5714
5793
  outDir: "dist",
5715
5794
  productionBranch: "main",
@@ -5762,7 +5841,7 @@ const defaultSpawn = (cmd, options) => {
5762
5841
  * @example
5763
5842
  * const state = createState({ global: {}, config });
5764
5843
  */
5765
- function createState$1(_ctx) {
5844
+ function createState$2(_ctx) {
5766
5845
  return {
5767
5846
  lastDeployment: null,
5768
5847
  spawn: defaultSpawn
@@ -5796,146 +5875,1307 @@ function createState$1(_ctx) {
5796
5875
  * ```
5797
5876
  */
5798
5877
  const deployPlugin = createPlugin$1("deploy", {
5799
- config: defaultConfig,
5878
+ config: defaultConfig$1,
5800
5879
  depends: [sitePlugin],
5801
- createState: createState$1,
5880
+ createState: createState$2,
5802
5881
  events: deployEvents,
5803
- onInit: validateConfig,
5804
- api: createApi$1
5882
+ onInit: validateConfig$1,
5883
+ api: createApi$2
5805
5884
  });
5806
5885
  //#endregion
5807
- //#region src/plugins/spa/api.ts
5886
+ //#region src/plugins/cli/errors.ts
5887
+ /** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
5888
+ const ERROR_PREFIX$3 = "[web] cli";
5808
5889
  /**
5809
- * Creates the spa plugin API surface (registration / control). All methods
5810
- * delegate to the single shared kernel stored in `ctx.state.kernel`.
5890
+ * Construct a cli `Error` carrying a taxonomy `code` property. Centralizes the
5891
+ * `Object.assign(new Error(message), { code })` pattern so the `code` is always
5892
+ * preserved on the thrown value (the message is expected to already be prefixed).
5811
5893
  *
5812
- * @param ctx - Plugin context exposing `state` (kernel) and `log`.
5813
- * @returns The {@link SpaApi} surface mounted at `app.spa`.
5894
+ * @param code - The cli error `code` from the taxonomy.
5895
+ * @param message - The actionable error message.
5896
+ * @returns An `Error` whose `code` property is set.
5814
5897
  * @example
5815
- * const api = createApi(ctx);
5816
- * api.register(counter);
5898
+ * throw cliError("ERR_CLI_CONFIG", "[web] cli: port must be 1–65535.");
5817
5899
  */
5818
- function createApi(ctx) {
5900
+ function cliError(code, message) {
5901
+ return Object.assign(new Error(message), { code });
5902
+ }
5903
+ /** The live-reload client snippet injected before `</body>` in served HTML. */
5904
+ const RELOAD_CLIENT = `<script>(()=>{try{const s=new EventSource("/__moku_reload");s.addEventListener("reload",()=>location.reload());}catch{}})();<\/script>`;
5905
+ /**
5906
+ * Inject the live-reload SSE client immediately before the closing `</body>` (or
5907
+ * append it when there is no `</body>`). Pure — unit-testable without a server.
5908
+ *
5909
+ * @param html - The page HTML to augment.
5910
+ * @returns The HTML with the reload client injected.
5911
+ * @example
5912
+ * injectReloadClient("<body>hi</body>"); // "<body>hi<script>…<\/script></body>"
5913
+ */
5914
+ function injectReloadClient(html) {
5915
+ const index = html.lastIndexOf("</body>");
5916
+ return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
5917
+ }
5918
+ /**
5919
+ * Run one rebuild and report the result. Skips re-entrancy via the shared `building`
5920
+ * flag and routes success to `onReloaded`, failure to `onError`.
5921
+ *
5922
+ * @param input - The rebuild dependencies + the changed file.
5923
+ * @param input.runBuild - Runs one build and resolves with its summary.
5924
+ * @param input.onReloaded - Called with the changed file + summary after a rebuild.
5925
+ * @param input.onError - Called when a rebuild throws.
5926
+ * @param input.file - The changed file to report alongside the summary.
5927
+ * @returns Resolves once the rebuild settles (always — errors are routed, not thrown).
5928
+ * @example
5929
+ * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
5930
+ */
5931
+ async function runOneRebuild(input) {
5932
+ try {
5933
+ const summary = await input.runBuild();
5934
+ input.onReloaded({
5935
+ file: input.file,
5936
+ pageCount: summary.pageCount,
5937
+ durationMs: summary.durationMs
5938
+ });
5939
+ } catch (error) {
5940
+ input.onError(error);
5941
+ }
5942
+ }
5943
+ /**
5944
+ * Create a {@link Rebuilder}. The latest changed file within the debounce window
5945
+ * wins; while a rebuild is in flight further notifications are dropped (the watcher
5946
+ * keeps firing, but only one build runs at a time, matching the blog `dev.ts`).
5947
+ *
5948
+ * @param input - The rebuild dependencies.
5949
+ * @param input.debounceMs - Debounce window in milliseconds.
5950
+ * @param input.runBuild - Runs one build and resolves with its summary.
5951
+ * @param input.onReloaded - Called with the changed file + summary after a rebuild.
5952
+ * @param input.onError - Called when a rebuild throws.
5953
+ * @returns The debounced rebuild driver.
5954
+ * @example
5955
+ * createRebuilder({ debounceMs: 150, runBuild, onReloaded, onError });
5956
+ */
5957
+ function createRebuilder(input) {
5958
+ let timer;
5959
+ let pendingFile = "";
5960
+ let building = false;
5961
+ /**
5962
+ * Run the queued rebuild once (skips when one is already in flight), resetting the
5963
+ * in-flight flag when it settles.
5964
+ *
5965
+ * @returns Resolves once the rebuild settles (errors are routed, never thrown).
5966
+ * @example
5967
+ * await fire();
5968
+ */
5969
+ const fire = async () => {
5970
+ timer = void 0;
5971
+ if (building) return;
5972
+ building = true;
5973
+ await runOneRebuild({
5974
+ runBuild: input.runBuild,
5975
+ onReloaded: input.onReloaded,
5976
+ onError: input.onError,
5977
+ file: pendingFile
5978
+ });
5979
+ building = false;
5980
+ };
5819
5981
  return {
5820
5982
  /**
5821
- * Register a component definition (last-registered-wins); warns on collision.
5983
+ * Queue a rebuild for the changed file (debounced + coalesced).
5822
5984
  *
5823
- * @param component - The component definition created via `createComponent`.
5985
+ * @param file - The changed path that triggered the rebuild.
5824
5986
  * @example
5825
- * app.spa.register(counter);
5987
+ * rebuilder.schedule("a.md");
5826
5988
  */
5827
- register(component) {
5828
- if (ctx.state.registeredComponents.has(component.name)) ctx.log.warn("spa:component-collision", { name: component.name });
5829
- ctx.state.kernel?.register(component);
5989
+ schedule(file) {
5990
+ pendingFile = file;
5991
+ if (timer) clearTimeout(timer);
5992
+ timer = setTimeout(fire, input.debounceMs);
5830
5993
  },
5831
5994
  /**
5832
- * Programmatically navigate to a path (client runtime; no-op without a DOM).
5995
+ * Cancel any pending (not-yet-fired) rebuild timer.
5833
5996
  *
5834
- * @param path - Target path (pathname, optionally with search/hash).
5835
5997
  * @example
5836
- * app.spa.navigate("/about");
5998
+ * rebuilder.cancel();
5837
5999
  */
5838
- navigate(path) {
5839
- ctx.state.kernel?.processNav(path);
6000
+ cancel() {
6001
+ if (timer) clearTimeout(timer);
6002
+ timer = void 0;
6003
+ }
6004
+ };
6005
+ }
6006
+ /**
6007
+ * Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
6008
+ * promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
6009
+ * Ctrl-C / termination and detaches its own listeners. Used by both servers.
6010
+ *
6011
+ * @param teardown - Cleanup to run on the first termination signal.
6012
+ * @returns A promise that resolves once a termination signal has been handled.
6013
+ * @example
6014
+ * await installSignalTeardown(() => server.stop());
6015
+ */
6016
+ function installSignalTeardown(teardown) {
6017
+ return new Promise((resolve) => {
6018
+ /**
6019
+ * Detach both signal listeners, run teardown, and resolve the wait (once).
6020
+ *
6021
+ * @example
6022
+ * onSignal();
6023
+ */
6024
+ const onSignal = () => {
6025
+ process.off("SIGINT", onSignal);
6026
+ process.off("SIGTERM", onSignal);
6027
+ teardown();
6028
+ resolve();
6029
+ };
6030
+ process.on("SIGINT", onSignal);
6031
+ process.on("SIGTERM", onSignal);
6032
+ });
6033
+ }
6034
+ /** The SSE comment line sent on connect to open the stream. */
6035
+ const SSE_OPEN = ": connected\n\n";
6036
+ /** The SSE frame pushed to reload a connected browser. */
6037
+ const SSE_RELOAD = "event: reload\ndata: 1\n\n";
6038
+ /**
6039
+ * Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
6040
+ * enqueues into a new stream; `reloadAll()` writes the reload frame to every live
6041
+ * controller (dropping any that have closed).
6042
+ *
6043
+ * @returns The reload hub.
6044
+ * @example
6045
+ * const hub = createReloadHub();
6046
+ */
6047
+ function createReloadHub() {
6048
+ const encoder = new TextEncoder();
6049
+ const clients = /* @__PURE__ */ new Set();
6050
+ return {
6051
+ /**
6052
+ * Open one SSE connection, register its controller, and return the streaming
6053
+ * `Response` (a connect comment is sent immediately to open the stream).
6054
+ *
6055
+ * @returns A `text/event-stream` response wired to this hub.
6056
+ * @example
6057
+ * return hub.connect();
6058
+ */
6059
+ connect() {
6060
+ let owned;
6061
+ const stream = new ReadableStream({
6062
+ /**
6063
+ * Register the stream's controller and send the opening comment.
6064
+ *
6065
+ * @param controller - The new stream's controller.
6066
+ * @example
6067
+ * start(controller);
6068
+ */
6069
+ start(controller) {
6070
+ owned = controller;
6071
+ clients.add(controller);
6072
+ controller.enqueue(encoder.encode(SSE_OPEN));
6073
+ },
6074
+ /**
6075
+ * Drop this client's controller when the browser disconnects.
6076
+ *
6077
+ * @example
6078
+ * cancel();
6079
+ */
6080
+ cancel() {
6081
+ if (owned) clients.delete(owned);
6082
+ }
6083
+ });
6084
+ return new Response(stream, { headers: {
6085
+ "content-type": "text/event-stream",
6086
+ "cache-control": "no-cache",
6087
+ connection: "keep-alive"
6088
+ } });
5840
6089
  },
5841
6090
  /**
5842
- * Read the current resolved URL.
6091
+ * Push a `reload` frame to every connected client, dropping any closed ones.
5843
6092
  *
5844
- * @returns The current pathname + search.
5845
6093
  * @example
5846
- * app.spa.current();
6094
+ * hub.reloadAll();
5847
6095
  */
5848
- current() {
5849
- return ctx.state.currentUrl;
6096
+ reloadAll() {
6097
+ for (const controller of clients) try {
6098
+ controller.enqueue(encoder.encode(SSE_RELOAD));
6099
+ } catch {
6100
+ clients.delete(controller);
6101
+ }
6102
+ },
6103
+ /**
6104
+ * The number of currently-connected clients.
6105
+ *
6106
+ * @returns The live client count.
6107
+ * @example
6108
+ * hub.size();
6109
+ */
6110
+ size() {
6111
+ return clients.size;
5850
6112
  }
5851
6113
  };
5852
6114
  }
5853
- //#endregion
5854
- //#region src/plugins/spa/events.ts
5855
6115
  /**
5856
- * Declares the spa plugin's events. Extracted from index.ts to keep the wiring
5857
- * file under the line budget.
6116
+ * Build the live-reload-aware request handler for the dev server: serves the SSE
6117
+ * stream at {@link RELOAD_PATH}, injects the reload client into HTML responses (when
6118
+ * `liveReload`), and falls through to the {@link resolveCleanUrl} static resolver for
6119
+ * everything else.
6120
+ *
6121
+ * @param ctx - The cli plugin context (provides `config` + `state.fileResponse`).
6122
+ * @param hub - The reload hub the SSE endpoint connects to.
6123
+ * @returns The `fetch` handler passed to the static server.
6124
+ * @example
6125
+ * const handler = createDevHandler(ctx, hub);
6126
+ */
6127
+ function createDevHandler(ctx, hub) {
6128
+ return async (request) => {
6129
+ const pathname = decodeURIComponent(new URL(request.url).pathname);
6130
+ if (pathname === "/__moku_reload") return hub.connect();
6131
+ const resolved = resolveCleanUrl(ctx.config.outDir, pathname);
6132
+ if (resolved.file === null) return new Response("Not Found", { status: 404 });
6133
+ const response = ctx.state.fileResponse(resolved.file, resolved.status);
6134
+ if (ctx.config.liveReload && resolved.file.endsWith(".html")) {
6135
+ const html = injectReloadClient(await response.text());
6136
+ return new Response(html, {
6137
+ status: resolved.status,
6138
+ headers: { "content-type": "text/html; charset=utf-8" }
6139
+ });
6140
+ }
6141
+ return response;
6142
+ };
6143
+ }
6144
+ /**
6145
+ * Run the dev loop: an initial build, an in-process static server that injects the
6146
+ * live-reload client, a recursive watcher over `config.watchDirs`, and a debounced
6147
+ * rebuild that re-renders and pushes a browser reload. Resolves on SIGINT/SIGTERM,
6148
+ * which stops the server, closes the watchers, and cancels any pending rebuild.
5858
6149
  *
5859
- * @param register - The event registration function supplied by the kernel.
5860
- * @returns The map of spa event descriptors.
6150
+ * @param ctx - The cli plugin context (config, state seams, `require`).
6151
+ * @param port - The port to bind the dev server to.
6152
+ * @returns Resolves once the server has been torn down by a termination signal.
5861
6153
  * @example
5862
- * const events = spaEvents(register);
6154
+ * await runDevServer(ctx, 4173);
5863
6155
  */
5864
- function spaEvents(register) {
5865
- return {
5866
- "spa:navigate": register("A navigation has been intercepted and is starting."),
5867
- "spa:navigated": register("The swap completed and the new URL is active."),
5868
- "spa:component-mount": register("A component instance attached to an element."),
5869
- "spa:component-unmount": register("A component instance detached from an element.")
5870
- };
6156
+ async function runDevServer(ctx, port) {
6157
+ await ctx.require(buildPlugin).run();
6158
+ const hub = createReloadHub();
6159
+ const server = ctx.state.serveStatic({
6160
+ port,
6161
+ fetch: createDevHandler(ctx, hub)
6162
+ });
6163
+ const rebuilder = createRebuilder({
6164
+ debounceMs: ctx.config.debounceMs,
6165
+ /**
6166
+ * Re-run the SSG build for a rebuild.
6167
+ *
6168
+ * @returns The rebuild summary.
6169
+ * @example
6170
+ * await runBuild();
6171
+ */
6172
+ runBuild() {
6173
+ return ctx.require(buildPlugin).run();
6174
+ },
6175
+ /**
6176
+ * Render the reload line and push a browser reload after a rebuild.
6177
+ *
6178
+ * @param info - The changed file plus the rebuild's page count and duration.
6179
+ * @example
6180
+ * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 });
6181
+ */
6182
+ onReloaded(info) {
6183
+ ctx.state.render.reload(info);
6184
+ hub.reloadAll();
6185
+ },
6186
+ /**
6187
+ * Render a rebuild failure (the dev loop keeps running).
6188
+ *
6189
+ * @param error - The thrown rebuild error.
6190
+ * @example
6191
+ * onError(new Error("boom"));
6192
+ */
6193
+ onError(error) {
6194
+ ctx.state.render.error("rebuild failed", error);
6195
+ }
6196
+ });
6197
+ const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, () => rebuilder.schedule(dir)));
6198
+ ctx.state.render.serverReady({
6199
+ local: `http://localhost:${port}`,
6200
+ network: ctx.state.networkUrl(port),
6201
+ watching: ctx.config.watchDirs
6202
+ });
6203
+ return installSignalTeardown(() => {
6204
+ rebuilder.cancel();
6205
+ for (const watcher of watchers) watcher.close();
6206
+ server.stop();
6207
+ });
5871
6208
  }
5872
6209
  //#endregion
5873
- //#region src/plugins/spa/types.ts
5874
- var types_exports$8 = /* @__PURE__ */ require_convention.__exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5875
- /** Allowed hook names — single source of truth for fail-fast validation. */
5876
- const COMPONENT_HOOK_NAMES = [
5877
- "onCreate",
5878
- "onMount",
5879
- "onNavStart",
5880
- "onNavEnd",
5881
- "onUnMount",
5882
- "onDestroy"
5883
- ];
5884
- //#endregion
5885
- //#region src/plugins/spa/components.ts
5886
- /** Error prefix for spa fail-fast failures (spec/11 Part-3). */
5887
- const ERROR_PREFIX$2 = "[web]";
5888
- /** The set of legal hook names, frozen for O(1) membership checks. */
5889
- const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
6210
+ //#region src/plugins/cli/preview.ts
5890
6211
  /**
5891
- * Create a validated component definition. Validates hook names at registration
5892
- * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
5893
- * each provided hook is a function.
6212
+ * @file cli plugin static preview server for the built `dist/`. Exposes a PURE,
6213
+ * server-agnostic clean-URL resolver (`resolveCleanUrl`) unit-tested without a
6214
+ * socket plus `runPreviewServer`, which serves the resolved files via `Bun.serve`
6215
+ * the way Cloudflare Pages does (trailing slash → `index.html`, extensionless →
6216
+ * `<path>/index.html`, a miss → the nearest `404.html`). No reload injection.
6217
+ */
6218
+ /**
6219
+ * Strip leading `../` segments (after `normalize`) so a request can never escape the
6220
+ * served root via path traversal.
5894
6221
  *
5895
- * @param name - Unique component name.
5896
- * @param hooks - Lifecycle hook implementations.
5897
- * @returns A `ComponentDef` ready to `register`.
5898
- * @throws {Error} If `name` is empty, any hook key is not in
5899
- * `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
6222
+ * @param pathname - The decoded request pathname.
6223
+ * @returns The traversal-safe relative path.
5900
6224
  * @example
5901
- * const counter = createComponent("counter", {
5902
- * onMount({ el }) { el.textContent = "0"; }
5903
- * });
6225
+ * safePath("../../etc/passwd"); // "etc/passwd"
5904
6226
  */
5905
- function createComponent(name, hooks) {
5906
- if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} component name must be a non-empty string\n → pass a unique name to createComponent("name", hooks)`);
5907
- for (const key of Object.keys(hooks)) {
5908
- if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${name}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
5909
- if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${name}" must be a function\n → provide a function or omit the hook`);
5910
- }
5911
- return {
5912
- name,
5913
- hooks
5914
- };
6227
+ function safePath(pathname) {
6228
+ return node_path$1.default.normalize(pathname).replace(/^(\.\.(?:[/\\]|$))+/, "");
5915
6229
  }
5916
6230
  /**
5917
- * Extracts the page data payload from the inline `script#__DATA__` element.
5918
- * Returns an empty object when the script is absent, empty, or invalid JSON.
6231
+ * The default {@link FileProbe} backed by `node:fs.statSync` — a single stat (no
6232
+ * exists+stat race) that swallows the ENOENT thrown for a missing path.
5919
6233
  *
5920
- * @param doc - The document to read the data script from.
5921
- * @returns The parsed page data, or `{}` when unavailable.
6234
+ * @param filePath - Candidate on-disk path.
6235
+ * @returns Whether it resolves to a regular file.
5922
6236
  * @example
5923
- * const data = extractPageData(document);
6237
+ * statIsFile("/dist/index.html");
5924
6238
  */
5925
- function extractPageData(doc) {
5926
- const text = doc.querySelector("script#__DATA__")?.textContent;
5927
- if (!text) return {};
6239
+ function statIsFile(filePath) {
5928
6240
  try {
5929
- return JSON.parse(text);
6241
+ return (0, node_fs.statSync)(filePath).isFile();
5930
6242
  } catch {
5931
- return {};
6243
+ return false;
5932
6244
  }
5933
6245
  }
5934
6246
  /**
5935
- * Builds a live component instance bound to an element.
6247
+ * Resolve a request pathname to a real file under `rootDir`, mirroring Cloudflare
6248
+ * Pages clean URLs. Pure and server-agnostic: it touches the filesystem only through
6249
+ * the injected {@link FileProbe}, so it is unit-tested without a server. A trailing
6250
+ * slash maps to `index.html`; an extensionless path tries the file then
6251
+ * `<path>/index.html`; a miss climbs toward the root for the nearest `404.html`
6252
+ * (served with status `404`).
5936
6253
  *
5937
- * @param definition - The component definition.
5938
- * @param element - The element the instance binds to.
6254
+ * @param rootDir - The absolute (or cwd-relative) build output directory.
6255
+ * @param pathname - The decoded request pathname (always starts with `/`).
6256
+ * @param isFile - File-existence probe (defaults to {@link statIsFile}).
6257
+ * @returns The resolved file + status (file `null` when not even a `404.html` exists).
6258
+ * @example
6259
+ * resolveCleanUrl("dist", "/about/", path => set.has(path));
6260
+ */
6261
+ function resolveCleanUrl(rootDir, pathname, isFile = statIsFile) {
6262
+ const relative = safePath(pathname);
6263
+ const base = node_path$1.default.join(rootDir, relative);
6264
+ const candidates = pathname.endsWith("/") ? [node_path$1.default.join(base, "index.html")] : [base, node_path$1.default.join(base, "index.html")];
6265
+ for (const candidate of candidates) if (isFile(candidate)) return {
6266
+ file: candidate,
6267
+ status: 200
6268
+ };
6269
+ const segments = node_path$1.default.join(rootDir, relative).split(node_path$1.default.sep).filter(Boolean);
6270
+ for (let depth = segments.length; depth >= 1; depth--) {
6271
+ const candidate = node_path$1.default.join(segments.slice(0, depth).join(node_path$1.default.sep), "404.html");
6272
+ if (isFile(candidate)) return {
6273
+ file: candidate,
6274
+ status: 404
6275
+ };
6276
+ }
6277
+ const root = node_path$1.default.join(rootDir, "404.html");
6278
+ return isFile(root) ? {
6279
+ file: root,
6280
+ status: 404
6281
+ } : {
6282
+ file: null,
6283
+ status: 404
6284
+ };
6285
+ }
6286
+ /**
6287
+ * Build the request handler for the preview server: resolves each request via
6288
+ * {@link resolveCleanUrl} and serves the file (no reload injection, mirroring prod).
6289
+ *
6290
+ * @param ctx - The cli plugin context (provides `config` + `state.fileResponse`).
6291
+ * @returns The `fetch` handler passed to the static server.
6292
+ * @example
6293
+ * const handler = createPreviewHandler(ctx);
6294
+ */
6295
+ function createPreviewHandler(ctx) {
6296
+ return (request) => {
6297
+ const pathname = decodeURIComponent(new URL(request.url).pathname);
6298
+ const resolved = resolveCleanUrl(ctx.config.outDir, pathname);
6299
+ if (resolved.file === null) return new Response("Not Found", { status: 404 });
6300
+ return ctx.state.fileResponse(resolved.file, resolved.status);
6301
+ };
6302
+ }
6303
+ /**
6304
+ * Run the static preview server for the built `dist/`. Serves files resolved by
6305
+ * {@link resolveCleanUrl} via the injectable static-server seam — with no live-reload
6306
+ * injection, mirroring production. Renders the server-ready panel and resolves on
6307
+ * SIGINT/SIGTERM.
6308
+ *
6309
+ * @param ctx - The cli plugin context (provides `config` + `state` seams).
6310
+ * @param port - The port to bind to.
6311
+ * @returns Resolves once the server has been torn down by a termination signal.
6312
+ * @example
6313
+ * await runPreviewServer(ctx, 4173);
6314
+ */
6315
+ function runPreviewServer(ctx, port) {
6316
+ const server = ctx.state.serveStatic({
6317
+ port,
6318
+ fetch: createPreviewHandler(ctx)
6319
+ });
6320
+ ctx.state.render.serverReady({
6321
+ local: `http://localhost:${port}`,
6322
+ network: ctx.state.networkUrl(port)
6323
+ });
6324
+ return installSignalTeardown(() => server.stop());
6325
+ }
6326
+ //#endregion
6327
+ //#region src/plugins/cli/api.ts
6328
+ /**
6329
+ * @file cli plugin — API factory (build · serve · preview · deploy), the cli plugin
6330
+ * context type, and config-only `validateConfig`. The four closures are wiring-thin:
6331
+ * each renders the Panel header, then delegates to `build`/`deploy` (via `require`)
6332
+ * or to the server modules. Live build/deploy progress arrives through hooks (in
6333
+ * `index.ts`), so the methods' return values come from the awaited `run()` results.
6334
+ */
6335
+ /** Lowest valid TCP port. */
6336
+ const MIN_PORT = 1;
6337
+ /** Highest valid TCP port. */
6338
+ const MAX_PORT = 65535;
6339
+ /**
6340
+ * Validate the resolved cli config during `onInit` (config-only — no resource
6341
+ * allocation, per spec/06 §2). Throws `ERR_CLI_CONFIG` (`[web] cli: …`) when `port`
6342
+ * is not an integer in 1–65535, `outDir`/`notFoundFile` are not non-empty strings,
6343
+ * `watchDirs` is not a non-empty string array, or `debounceMs` is negative.
6344
+ *
6345
+ * @param config - The resolved cli configuration to validate.
6346
+ * @throws {Error} `ERR_CLI_CONFIG` when any field is invalid.
6347
+ * @example
6348
+ * validateConfig({ outDir: "dist", port: 4173, watchDirs: ["content"], debounceMs: 150, notFoundFile: "404.html", liveReload: true });
6349
+ */
6350
+ function validateConfig(config) {
6351
+ if (!Number.isInteger(config.port) || config.port < MIN_PORT || config.port > MAX_PORT) throw cliError("ERR_CLI_CONFIG", `${ERROR_PREFIX$3}: port must be an integer in ${MIN_PORT}–${MAX_PORT}.\n Set pluginConfigs.cli.port to a valid TCP port (e.g. 4173).`);
6352
+ if (typeof config.outDir !== "string" || config.outDir.length === 0) throw cliError("ERR_CLI_CONFIG", `${ERROR_PREFIX$3}: outDir must be a non-empty string.\n Set pluginConfigs.cli.outDir to your build output directory (e.g. "dist").`);
6353
+ if (typeof config.notFoundFile !== "string" || config.notFoundFile.length === 0) throw cliError("ERR_CLI_CONFIG", `${ERROR_PREFIX$3}: notFoundFile must be a non-empty string.\n Set pluginConfigs.cli.notFoundFile to the not-found page filename (e.g. "404.html").`);
6354
+ if (!Array.isArray(config.watchDirs) || config.watchDirs.length === 0 || !config.watchDirs.every((dir) => typeof dir === "string" && dir.length > 0)) throw cliError("ERR_CLI_CONFIG", `${ERROR_PREFIX$3}: watchDirs must be a non-empty array of non-empty strings.\n Set pluginConfigs.cli.watchDirs to the directories serve() should watch (e.g. ["content", "src"]).`);
6355
+ if (typeof config.debounceMs !== "number" || config.debounceMs < 0) throw cliError("ERR_CLI_CONFIG", `${ERROR_PREFIX$3}: debounceMs must be a number >= 0.\n Set pluginConfigs.cli.debounceMs to the rebuild debounce window in milliseconds (e.g. 150).`);
6356
+ }
6357
+ /**
6358
+ * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
6359
+ * Each method renders `state.render.header(<command>)` first, then does its work;
6360
+ * live progress is rendered by the hooks wired in `index.ts`, so each method's
6361
+ * return value comes from the awaited `build.run()` / `deploy.run()` result.
6362
+ *
6363
+ * @param ctx - Plugin context (provides `require`, `state`, `config`).
6364
+ * @returns The {@link Api} surface mounted at `app.cli`.
6365
+ * @example
6366
+ * const api = createApi(ctx);
6367
+ * await api.build();
6368
+ */
6369
+ function createApi$1(ctx) {
6370
+ return {
6371
+ /**
6372
+ * Run the SSG build and (by default) assert the not-found page exists.
6373
+ *
6374
+ * @param options - Optional `assertNotFound` toggle (default `true`).
6375
+ * @returns The build summary (`outDir`, `pageCount`, `durationMs`).
6376
+ * @throws {Error} `ERR_CLI_NOT_FOUND` when the not-found page is missing and asserted.
6377
+ * @example
6378
+ * await api.build();
6379
+ */
6380
+ async build(options = {}) {
6381
+ const { assertNotFound = true } = options;
6382
+ ctx.state.render.header("build");
6383
+ const result = await ctx.require(buildPlugin).run();
6384
+ const page = node_path$1.default.join(ctx.config.outDir, ctx.config.notFoundFile);
6385
+ if (assertNotFound && !(0, node_fs.existsSync)(page)) {
6386
+ ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
6387
+ throw cliError("ERR_CLI_NOT_FOUND", `${ERROR_PREFIX$3}: ${page} missing after build.\n Set build.notFound so the SSG emits it (CF Pages flips to SPA mode without a top-level 404), or pass { assertNotFound: false } to skip this check.`);
6388
+ }
6389
+ return result;
6390
+ },
6391
+ /**
6392
+ * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
6393
+ * `watchDirs`, debounced rebuild + reload. Resolves on SIGINT/SIGTERM.
6394
+ *
6395
+ * @param options - Optional port override (defaults to `config.port`).
6396
+ * @returns Resolves once the server has been torn down.
6397
+ * @example
6398
+ * await api.serve({ port: 3000 });
6399
+ */
6400
+ serve(options = {}) {
6401
+ const { port = ctx.config.port } = options;
6402
+ ctx.state.render.header("serve");
6403
+ return runDevServer(ctx, port);
6404
+ },
6405
+ /**
6406
+ * Static preview of the built `dist/` with CF-Pages clean-URL resolution.
6407
+ *
6408
+ * @param options - Optional port override (defaults to `config.port`).
6409
+ * @returns Resolves once the server has been torn down.
6410
+ * @example
6411
+ * await api.preview();
6412
+ */
6413
+ preview(options = {}) {
6414
+ const { port = ctx.config.port } = options;
6415
+ ctx.state.render.header("preview");
6416
+ return runPreviewServer(ctx, port);
6417
+ },
6418
+ /**
6419
+ * Scaffold, then deploy. A y/N confirm is shown only when a human is present (an
6420
+ * interactive TTY, with `CI` unset). Non-interactive runs (CI, or any non-TTY)
6421
+ * skip the prompt and deploy, so the consumer scripts never hang a pipeline.
6422
+ * `options.yes` forces the skip anywhere. An interactive "no" returns
6423
+ * `{ deployed: false, reason: "declined" }`.
6424
+ *
6425
+ * @param options - Optional branch override and `yes` flag.
6426
+ * @returns The deploy outcome (completed details, or `declined` if a TTY user says no).
6427
+ * @example
6428
+ * await api.deploy({ branch: "preview/x", yes: true });
6429
+ */
6430
+ async deploy(options = {}) {
6431
+ const { branch, yes = false } = options;
6432
+ ctx.state.render.header("deploy");
6433
+ await ctx.require(deployPlugin).init({ ci: true });
6434
+ if (process.stdout.isTTY === true && process.env.CI === void 0 && !yes) {
6435
+ if (!await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to cloudflare-pages?`)) {
6436
+ ctx.state.render.warn("deploy skipped");
6437
+ return {
6438
+ deployed: false,
6439
+ reason: "declined"
6440
+ };
6441
+ }
6442
+ } else if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
6443
+ return {
6444
+ deployed: true,
6445
+ ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
6446
+ };
6447
+ }
6448
+ };
6449
+ }
6450
+ //#endregion
6451
+ //#region src/plugins/cli/defaults.ts
6452
+ /**
6453
+ * Default cli configuration. Consumers override individual fields via
6454
+ * `pluginConfigs.cli`. Declared as a typed const (no inline `as` assertion).
6455
+ *
6456
+ * @example
6457
+ * ```ts
6458
+ * createPlugin("cli", { config: defaultConfig });
6459
+ * ```
6460
+ */
6461
+ const defaultConfig = {
6462
+ outDir: "dist",
6463
+ port: 4173,
6464
+ watchDirs: ["content", "src"],
6465
+ debounceMs: 150,
6466
+ notFoundFile: "404.html",
6467
+ liveReload: true
6468
+ };
6469
+ //#endregion
6470
+ //#region src/plugins/cli/network.ts
6471
+ /**
6472
+ * @file cli plugin — LAN network-URL derivation. Picks the first non-internal IPv4
6473
+ * from `node:os` `networkInterfaces()` to render the "Network" URL in the
6474
+ * server-ready panel. The interface source is injectable so it is unit-testable.
6475
+ */
6476
+ /**
6477
+ * Whether an interface entry is a usable, non-internal IPv4 address.
6478
+ *
6479
+ * @param entry - One interface address entry.
6480
+ * @returns `true` when it is an external IPv4 address.
6481
+ * @example
6482
+ * isExternalIPv4({ address: "10.0.0.2", family: "IPv4", internal: false }); // true
6483
+ */
6484
+ function isExternalIPv4(entry) {
6485
+ return !entry.internal && (entry.family === "IPv4" || entry.family === 4);
6486
+ }
6487
+ /**
6488
+ * Pick the first non-internal IPv4 address from the interface source, or `null` when
6489
+ * none exists (offline / loopback-only).
6490
+ *
6491
+ * @param source - Interface source (defaults to `node:os` `networkInterfaces`).
6492
+ * @returns The first external IPv4 address string, or `null`.
6493
+ * @example
6494
+ * const ip = lanAddress();
6495
+ */
6496
+ function lanAddress(source = node_os.networkInterfaces) {
6497
+ for (const entries of Object.values(source())) for (const entry of entries ?? []) if (isExternalIPv4(entry)) return entry.address;
6498
+ return null;
6499
+ }
6500
+ /**
6501
+ * Build the LAN URL (`http://<ip>:<port>`) for the server-ready panel, or `null`
6502
+ * when no non-internal IPv4 is available.
6503
+ *
6504
+ * @param port - The port the server is bound to.
6505
+ * @param source - Interface source (defaults to `node:os` `networkInterfaces`).
6506
+ * @returns The `http://<ip>:<port>` URL, or `null` when offline.
6507
+ * @example
6508
+ * networkUrl(4173); // "http://192.168.1.10:4173" or null
6509
+ */
6510
+ function networkUrl(port, source = node_os.networkInterfaces) {
6511
+ const ip = lanAddress(source);
6512
+ return ip === null ? null : `http://${ip}:${port}`;
6513
+ }
6514
+ //#endregion
6515
+ //#region src/plugins/cli/render/ansi.ts
6516
+ /**
6517
+ * @file cli plugin — TTY/NO_COLOR-aware ANSI color + box-drawing helpers shared by
6518
+ * the Panel renderer. Modeled on the legacy `scripts/_log.ts`: color and box glyphs
6519
+ * are emitted only on a real TTY with `NO_COLOR` unset; otherwise plain ASCII so
6520
+ * CI logs and pipes stay readable.
6521
+ */
6522
+ /** The ANSI escape byte (ESC, `0x1b`), built so no literal control char is in source. */
6523
+ const ESC = String.fromCodePoint(27);
6524
+ /** ANSI SGR codes used by the Panel renderer (each prefixed with the ESC byte). */
6525
+ const ANSI = {
6526
+ reset: `${ESC}[0m`,
6527
+ bold: `${ESC}[1m`,
6528
+ dim: `${ESC}[2m`,
6529
+ red: `${ESC}[31m`,
6530
+ green: `${ESC}[32m`,
6531
+ yellow: `${ESC}[33m`,
6532
+ blue: `${ESC}[34m`,
6533
+ magenta: `${ESC}[35m`,
6534
+ cyan: `${ESC}[36m`,
6535
+ gray: `${ESC}[90m`
6536
+ };
6537
+ /** Unicode rounded box glyphs used when output is a color-capable TTY. */
6538
+ const UNICODE_BOX = {
6539
+ topLeft: "╭",
6540
+ topRight: "╮",
6541
+ bottomLeft: "╰",
6542
+ bottomRight: "╯",
6543
+ horizontal: "─",
6544
+ vertical: "│"
6545
+ };
6546
+ /** ASCII box glyphs used when output is piped/CI (plain mode). */
6547
+ const ASCII_BOX = {
6548
+ topLeft: "+",
6549
+ topRight: "+",
6550
+ bottomLeft: "+",
6551
+ bottomRight: "+",
6552
+ horizontal: "-",
6553
+ vertical: "|"
6554
+ };
6555
+ /**
6556
+ * Matches every ANSI SGR escape sequence (used to measure visible width). Built from
6557
+ * the {@link ESC} byte so no literal control character appears in the source regex.
6558
+ */
6559
+ const ANSI_PATTERN = new RegExp(String.raw`${ESC}\[[0-9;]*m`, "g");
6560
+ /**
6561
+ * Whether ANSI color/box glyphs should be emitted: a TTY stream with `NO_COLOR`
6562
+ * unset. Reads `process.stdout.isTTY` and `process.env.NO_COLOR` by default so the
6563
+ * renderer auto-degrades in CI and pipes, exactly like the legacy logger.
6564
+ *
6565
+ * @param stream - Stream to probe for `isTTY` (defaults to `process.stdout`).
6566
+ * @param noColor - The `NO_COLOR` value (defaults to `process.env.NO_COLOR`).
6567
+ * @returns `true` when color should be used.
6568
+ * @example
6569
+ * supportsColor(); // true in an interactive terminal
6570
+ */
6571
+ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR) {
6572
+ return stream.isTTY === true && noColor === void 0;
6573
+ }
6574
+ /**
6575
+ * Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
6576
+ *
6577
+ * @param color - Whether color/Unicode output is enabled.
6578
+ * @returns The matching {@link BoxGlyphs} set.
6579
+ * @example
6580
+ * const glyphs = boxGlyphs(supportsColor());
6581
+ */
6582
+ function boxGlyphs(color) {
6583
+ return color ? UNICODE_BOX : ASCII_BOX;
6584
+ }
6585
+ /**
6586
+ * The visible width of a string, ignoring any ANSI escape sequences it contains.
6587
+ *
6588
+ * @param text - The (possibly colorized) text to measure.
6589
+ * @returns The number of visible characters.
6590
+ * @example
6591
+ * visibleWidth(`${ANSI.red}hi${ANSI.reset}`); // 2
6592
+ */
6593
+ function visibleWidth(text) {
6594
+ return text.replaceAll(ANSI_PATTERN, "").length;
6595
+ }
6596
+ /**
6597
+ * Build a {@link Palette} bound to a fixed color mode. When `color` is `false` every
6598
+ * helper returns its input unchanged, so the same render code path produces plain
6599
+ * output in CI/pipes.
6600
+ *
6601
+ * @param color - Whether color is enabled (typically `supportsColor()`).
6602
+ * @returns The bound color palette.
6603
+ * @example
6604
+ * const palette = makePalette(supportsColor());
6605
+ * const line = palette.green("done");
6606
+ */
6607
+ function makePalette(color) {
6608
+ return {
6609
+ enabled: color,
6610
+ /**
6611
+ * Wrap text in the given ANSI code (returns it unchanged when color is off).
6612
+ *
6613
+ * @param code - The ANSI SGR code to apply.
6614
+ * @param text - The text to colorize.
6615
+ * @returns The colorized (or unchanged) text.
6616
+ * @example
6617
+ * palette.paint(ANSI.green, "ok");
6618
+ */
6619
+ paint(code, text) {
6620
+ return color ? `${code}${text}${ANSI.reset}` : text;
6621
+ },
6622
+ /**
6623
+ * Bold the given text (no-op in plain mode).
6624
+ *
6625
+ * @param text - The text to embolden.
6626
+ * @returns The bold (or unchanged) text.
6627
+ * @example
6628
+ * palette.bold("title");
6629
+ */
6630
+ bold(text) {
6631
+ return this.paint(ANSI.bold, text);
6632
+ },
6633
+ /**
6634
+ * Dim the given text (no-op in plain mode).
6635
+ *
6636
+ * @param text - The text to dim.
6637
+ * @returns The dim (or unchanged) text.
6638
+ * @example
6639
+ * palette.dim("· 84ms");
6640
+ */
6641
+ dim(text) {
6642
+ return this.paint(ANSI.dim, text);
6643
+ },
6644
+ /**
6645
+ * Color the given text green (no-op in plain mode).
6646
+ *
6647
+ * @param text - The text to colorize.
6648
+ * @returns The green (or unchanged) text.
6649
+ * @example
6650
+ * palette.green("✓");
6651
+ */
6652
+ green(text) {
6653
+ return this.paint(ANSI.green, text);
6654
+ },
6655
+ /**
6656
+ * Color the given text yellow (no-op in plain mode).
6657
+ *
6658
+ * @param text - The text to colorize.
6659
+ * @returns The yellow (or unchanged) text.
6660
+ * @example
6661
+ * palette.yellow("~");
6662
+ */
6663
+ yellow(text) {
6664
+ return this.paint(ANSI.yellow, text);
6665
+ },
6666
+ /**
6667
+ * Color the given text red (no-op in plain mode).
6668
+ *
6669
+ * @param text - The text to colorize.
6670
+ * @returns The red (or unchanged) text.
6671
+ * @example
6672
+ * palette.red("✗");
6673
+ */
6674
+ red(text) {
6675
+ return this.paint(ANSI.red, text);
6676
+ },
6677
+ /**
6678
+ * Color the given text cyan (no-op in plain mode).
6679
+ *
6680
+ * @param text - The text to colorize.
6681
+ * @returns The cyan (or unchanged) text.
6682
+ * @example
6683
+ * palette.cyan("http://localhost:4173");
6684
+ */
6685
+ cyan(text) {
6686
+ return this.paint(ANSI.cyan, text);
6687
+ }
6688
+ };
6689
+ }
6690
+ /**
6691
+ * Frame a list of already-rendered content lines in a box, padding each line to the
6692
+ * width of the widest visible line. Uses Unicode borders when `color` is enabled and
6693
+ * ASCII otherwise. Visible width ignores embedded ANSI so colored lines align.
6694
+ *
6695
+ * @param lines - The content lines (may contain ANSI color codes).
6696
+ * @param color - Whether to use Unicode borders (and assume color-capable output).
6697
+ * @returns The boxed lines (top border, content rows, bottom border).
6698
+ * @example
6699
+ * box(["Local: http://localhost:4173"], true);
6700
+ */
6701
+ function box(lines, color) {
6702
+ const glyphs = boxGlyphs(color);
6703
+ const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
6704
+ const horizontal = glyphs.horizontal.repeat(inner + 2);
6705
+ const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
6706
+ const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
6707
+ return [
6708
+ top,
6709
+ ...lines.map((line) => {
6710
+ const pad = " ".repeat(inner - visibleWidth(line));
6711
+ return `${glyphs.vertical} ${line}${pad} ${glyphs.vertical}`;
6712
+ }),
6713
+ bottom
6714
+ ];
6715
+ }
6716
+ //#endregion
6717
+ //#region src/plugins/cli/render/panel.ts
6718
+ /** Per-command label shown in the header badge beside the logo. */
6719
+ const COMMAND_LABEL = {
6720
+ build: "build",
6721
+ serve: "serve · dev",
6722
+ preview: "preview",
6723
+ deploy: "deploy"
6724
+ };
6725
+ /**
6726
+ * Render one human-readable duration suffix (e.g. `· 84ms`).
6727
+ *
6728
+ * @param palette - The active color palette.
6729
+ * @param durationMs - Duration in milliseconds (omitted → empty string).
6730
+ * @returns The dim `· Nms` suffix, or `""` when no duration is given.
6731
+ * @example
6732
+ * durationSuffix(palette, 84); // " · 84ms" (dim)
6733
+ */
6734
+ function durationSuffix(palette, durationMs) {
6735
+ if (durationMs === void 0) return "";
6736
+ return ` ${palette.dim(`· ${durationMs}ms`)}`;
6737
+ }
6738
+ /**
6739
+ * Create the Panel {@link CliRenderer}. Output is written through the injected sink
6740
+ * (default `console.log`/`console.error`) and colorized only when color is enabled,
6741
+ * so the identical render path yields box-drawn color panels on a TTY and plain
6742
+ * ASCII lines in CI/pipes.
6743
+ *
6744
+ * @param options - Optional sinks + a color override (see {@link PanelOptions}).
6745
+ * @returns The renderer mounted on `state.render` and driven by the API + hooks.
6746
+ * @example
6747
+ * const render = createPanelRenderer();
6748
+ * render.header("build");
6749
+ */
6750
+ function createPanelRenderer(options = {}) {
6751
+ const write = options.write ?? ((line) => console.log(line));
6752
+ const writeError = options.writeError ?? ((line) => console.error(line));
6753
+ const color = options.color ?? supportsColor();
6754
+ const palette = makePalette(color);
6755
+ /**
6756
+ * Write each line of a multi-line block through the stdout sink.
6757
+ *
6758
+ * @param lines - The rendered lines to write in order.
6759
+ * @example
6760
+ * writeBlock(["a", "b"]);
6761
+ */
6762
+ const writeBlock = (lines) => {
6763
+ for (const line of lines) write(line);
6764
+ };
6765
+ return {
6766
+ /**
6767
+ * Render the boxed `MOKU WEB` logo + command label.
6768
+ *
6769
+ * @param command - The command being run, shown beside the logo.
6770
+ * @example
6771
+ * render.header("serve");
6772
+ */
6773
+ header(command) {
6774
+ writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
6775
+ },
6776
+ /**
6777
+ * Render a live per-phase row from a `build:phase` event.
6778
+ *
6779
+ * @param phase - The `build:phase` payload.
6780
+ * @example
6781
+ * render.phase({ phase: "pages", status: "done", durationMs: 12 });
6782
+ */
6783
+ phase(phase) {
6784
+ const done = phase.status === "done";
6785
+ write(` ${done ? palette.green("✓") : palette.dim("•")} ${done ? phase.phase : palette.dim(phase.phase)}${durationSuffix(palette, phase.durationMs)}`);
6786
+ },
6787
+ /**
6788
+ * Render the BUILD summary block from a `build:complete` event.
6789
+ *
6790
+ * @param summary - The `build:complete` payload.
6791
+ * @example
6792
+ * render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
6793
+ */
6794
+ built(summary) {
6795
+ const pages = palette.bold(String(summary.pageCount));
6796
+ writeBlock(box([
6797
+ `${palette.green("✓")} ${palette.bold("BUILD")} complete`,
6798
+ `${palette.dim("pages")} ${pages}`,
6799
+ `${palette.dim("time")} ${summary.durationMs}ms`,
6800
+ `${palette.dim("out")} ${summary.outDir}/`
6801
+ ], color));
6802
+ },
6803
+ /**
6804
+ * Render the bordered server-ready panel (Local / Network URLs + watched dirs).
6805
+ *
6806
+ * @param info - Local/Network URLs and optionally the watched directories.
6807
+ * @example
6808
+ * render.serverReady({ local: "http://localhost:4173", network: null });
6809
+ */
6810
+ serverReady(info) {
6811
+ const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${info.network ? palette.cyan(info.network) : palette.dim("unavailable")}`];
6812
+ if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
6813
+ writeBlock(box(lines, color));
6814
+ },
6815
+ /**
6816
+ * Render the post-rebuild line ("~ file" + "✓ rebuilt N pages · Xms · reloaded").
6817
+ *
6818
+ * @param info - The changed file plus the rebuild's page count and duration.
6819
+ * @example
6820
+ * render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
6821
+ */
6822
+ reload(info) {
6823
+ write(` ${palette.yellow("~")} ${info.file}`);
6824
+ write(` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · browser reloaded`)}`);
6825
+ },
6826
+ /**
6827
+ * Render the deploy result panel from a `deploy:complete` event.
6828
+ *
6829
+ * @param result - The `deploy:complete` payload.
6830
+ * @example
6831
+ * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
6832
+ */
6833
+ deployed(result) {
6834
+ writeBlock(box([
6835
+ `${palette.green("✓")} ${palette.bold("DEPLOYED")}`,
6836
+ `${palette.dim("url")} ${palette.cyan(result.url)}`,
6837
+ `${palette.dim("branch")} ${result.branch}`,
6838
+ `${palette.dim("id")} ${result.deploymentId}`,
6839
+ `${palette.dim("time")} ${result.durationMs}ms`
6840
+ ], color));
6841
+ },
6842
+ /**
6843
+ * Render a neutral informational line.
6844
+ *
6845
+ * @param message - The line to print.
6846
+ * @example
6847
+ * render.info("watching for changes…");
6848
+ */
6849
+ info(message) {
6850
+ write(` ${palette.cyan("›")} ${message}`);
6851
+ },
6852
+ /**
6853
+ * Render a warning line (to stderr).
6854
+ *
6855
+ * @param message - The warning to print.
6856
+ * @example
6857
+ * render.warn("deploy skipped");
6858
+ */
6859
+ warn(message) {
6860
+ writeError(` ${palette.yellow("⚠")} ${message}`);
6861
+ },
6862
+ /**
6863
+ * Render an error line (to stderr), optionally with a cause.
6864
+ *
6865
+ * @param message - The error summary to print.
6866
+ * @param cause - Optional underlying error/value to print beneath the summary.
6867
+ * @example
6868
+ * render.error("build failed", err);
6869
+ */
6870
+ error(message, cause) {
6871
+ writeError(` ${palette.red("✗")} ${message}`);
6872
+ if (cause !== void 0) writeError(String(cause));
6873
+ }
6874
+ };
6875
+ }
6876
+ //#endregion
6877
+ //#region src/plugins/cli/state.ts
6878
+ /**
6879
+ * @file cli plugin — state factory. Wires the default injectable seams: the Panel
6880
+ * renderer, a stdin y/N `confirm`, the `Date.now` clock, a recursive `node:fs.watch`
6881
+ * wrapper, and the `Bun.serve`/`Bun.file` static-server seams (resolved lazily, like
6882
+ * deploy's `defaultSpawn`, so a non-Bun runtime fails coded rather than as a raw
6883
+ * `TypeError` and tests can inject fakes). Unit tests swap any of these.
6884
+ */
6885
+ /**
6886
+ * Resolve the `Bun` runtime global, or `undefined` when not running under Bun.
6887
+ *
6888
+ * @returns The Bun runtime, or `undefined`.
6889
+ * @example
6890
+ * const bun = bunRuntime();
6891
+ */
6892
+ function bunRuntime() {
6893
+ return globalThis.Bun;
6894
+ }
6895
+ /**
6896
+ * Default static-server factory — resolves `Bun.serve` lazily at call time so the
6897
+ * server is only required when a long-running command actually starts one.
6898
+ *
6899
+ * @param options - Port + `fetch` handler (see {@link ServeStaticFunction}).
6900
+ * @returns The running server handle.
6901
+ * @throws {Error} When no Bun runtime is available to serve.
6902
+ * @example
6903
+ * defaultServeStatic({ port: 4173, fetch: () => new Response("ok") });
6904
+ */
6905
+ const defaultServeStatic = (options) => {
6906
+ const runtime = bunRuntime();
6907
+ if (runtime === void 0) throw new Error("[web] cli: no Bun runtime available to start the server.\n Run serve()/preview() under Bun, or inject state.serveStatic in tests.");
6908
+ return runtime.serve(options);
6909
+ };
6910
+ /**
6911
+ * Default file-response factory — `new Response(Bun.file(path), { status })`. Resolves
6912
+ * `Bun.file` lazily so it is only required when a request is actually served.
6913
+ *
6914
+ * @param path - Absolute on-disk path to stream.
6915
+ * @param status - HTTP status for the response.
6916
+ * @returns The file `Response`.
6917
+ * @throws {Error} When no Bun runtime is available to read the file.
6918
+ * @example
6919
+ * defaultFileResponse("/dist/index.html", 200);
6920
+ */
6921
+ const defaultFileResponse = (path, status) => {
6922
+ const runtime = bunRuntime();
6923
+ if (runtime === void 0) throw new Error("[web] cli: no Bun runtime available to read files.\n Run serve()/preview() under Bun, or inject state.fileResponse in tests.");
6924
+ return new Response(runtime.file(path), { status });
6925
+ };
6926
+ /**
6927
+ * Default stdin y/N prompt. Reads a single line from `process.stdin` via
6928
+ * `node:readline` and resolves `true` only on an explicit `y`/`yes` (default `No`).
6929
+ * Tests inject a canned answer so no real TTY interaction occurs.
6930
+ *
6931
+ * @param question - The yes/no question to display.
6932
+ * @returns Resolves `true` when the user answered yes.
6933
+ * @example
6934
+ * await defaultConfirm("Deploy dist/?");
6935
+ */
6936
+ function defaultConfirm(question) {
6937
+ return new Promise((resolve) => {
6938
+ const readline = (0, node_readline.createInterface)({
6939
+ input: process.stdin,
6940
+ output: process.stdout
6941
+ });
6942
+ readline.question(`${question} [y/N] `, (answer) => {
6943
+ readline.close();
6944
+ resolve(/^y(es)?$/i.test(answer.trim()));
6945
+ });
6946
+ });
6947
+ }
6948
+ /**
6949
+ * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
6950
+ * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
6951
+ * FS watch is registered.
6952
+ *
6953
+ * @param dir - The directory to watch recursively.
6954
+ * @param onChange - Invoked on any change beneath `dir`.
6955
+ * @returns A handle whose `close()` detaches the watcher.
6956
+ * @example
6957
+ * const handle = defaultWatch("content", () => rebuild());
6958
+ */
6959
+ function defaultWatch(dir, onChange) {
6960
+ const watcher = (0, node_fs.watch)(dir, { recursive: true }, () => onChange());
6961
+ return {
6962
+ /**
6963
+ * Detach the underlying `node:fs.watch` listener.
6964
+ *
6965
+ * @example
6966
+ * handle.close();
6967
+ */
6968
+ close() {
6969
+ watcher.close();
6970
+ } };
6971
+ }
6972
+ /**
6973
+ * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
6974
+ * reads `node:os` interfaces while tests can inject a deterministic value.
6975
+ *
6976
+ * @param port - The port the server is bound to.
6977
+ * @returns The `http://<ip>:<port>` URL, or `null` when offline.
6978
+ * @example
6979
+ * defaultNetworkUrl(4173);
6980
+ */
6981
+ function defaultNetworkUrl(port) {
6982
+ return networkUrl(port);
6983
+ }
6984
+ /**
6985
+ * Create the initial cli plugin state with the production seams wired. Every field is
6986
+ * an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
6987
+ * and `networkUrl`) so commands run under unit tests without real sockets/FS/TTY.
6988
+ *
6989
+ * @param _ctx - Minimal context with global + config (unused — state is static).
6990
+ * @param _ctx.global - Global plugin registry.
6991
+ * @param _ctx.config - Resolved plugin configuration.
6992
+ * @returns The initial cli state.
6993
+ * @example
6994
+ * const state = createState({ global: {}, config });
6995
+ */
6996
+ function createState$1(_ctx) {
6997
+ return {
6998
+ render: createPanelRenderer(),
6999
+ confirm: defaultConfirm,
7000
+ clock: Date.now,
7001
+ watch: defaultWatch,
7002
+ serveStatic: defaultServeStatic,
7003
+ fileResponse: defaultFileResponse,
7004
+ networkUrl: defaultNetworkUrl
7005
+ };
7006
+ }
7007
+ //#endregion
7008
+ //#region src/plugins/cli/index.ts
7009
+ /**
7010
+ * @file cli — Complex plugin (wiring harness only). Developer CLI:
7011
+ * build · serve · preview · deploy, with the boxed Panel renderer.
7012
+ * Depends: build, deploy. Listens: build:phase, build:complete, deploy:complete.
7013
+ * @see README.md
7014
+ */
7015
+ /**
7016
+ * cli plugin — the node-only developer CLI for `@moku-labs/web`. Mounts exactly four
7017
+ * methods at `app.cli` (`build`/`serve`/`preview`/`deploy`), each rendering through
7018
+ * the boxed Panel UI. Live build/deploy progress rides on hooks over the `build` and
7019
+ * `deploy` plugins' events; there is no argv parser and no `run()` dispatcher — the
7020
+ * consumer drives it from one thin script per command.
7021
+ *
7022
+ * @example Compose the CLI in a consumer app (node-only)
7023
+ * ```ts
7024
+ * import { buildPlugin, cliPlugin, createApp, deployPlugin } from "@moku-labs/web";
7025
+ *
7026
+ * const app = createApp({
7027
+ * plugins: [buildPlugin, deployPlugin, cliPlugin],
7028
+ * pluginConfigs: { cli: { outDir: "dist", port: 4173, watchDirs: ["content", "src"] } }
7029
+ * });
7030
+ * await app.start();
7031
+ * await app.cli.build();
7032
+ * ```
7033
+ */
7034
+ const cliPlugin = createPlugin$1("cli", {
7035
+ config: defaultConfig,
7036
+ depends: [buildPlugin, deployPlugin],
7037
+ createState: createState$1,
7038
+ onInit: (ctx) => validateConfig(ctx.config),
7039
+ hooks: (ctx) => ({
7040
+ "build:phase": (p) => ctx.state.render.phase(p),
7041
+ "build:complete": (p) => ctx.state.render.built(p),
7042
+ "deploy:complete": (p) => ctx.state.render.deployed(p)
7043
+ }),
7044
+ api: createApi$1
7045
+ });
7046
+ //#endregion
7047
+ //#region src/plugins/spa/api.ts
7048
+ /**
7049
+ * Creates the spa plugin API surface (registration / control). All methods
7050
+ * delegate to the single shared kernel stored in `ctx.state.kernel`.
7051
+ *
7052
+ * @param ctx - Plugin context exposing `state` (kernel) and `log`.
7053
+ * @returns The {@link SpaApi} surface mounted at `app.spa`.
7054
+ * @example
7055
+ * const api = createApi(ctx);
7056
+ * api.register(counter);
7057
+ */
7058
+ function createApi(ctx) {
7059
+ return {
7060
+ /**
7061
+ * Register a component definition (last-registered-wins); warns on collision.
7062
+ *
7063
+ * @param component - The component definition created via `createComponent`.
7064
+ * @example
7065
+ * app.spa.register(counter);
7066
+ */
7067
+ register(component) {
7068
+ if (ctx.state.registeredComponents.has(component.name)) ctx.log.warn("spa:component-collision", { name: component.name });
7069
+ ctx.state.kernel?.register(component);
7070
+ },
7071
+ /**
7072
+ * Programmatically navigate to a path (client runtime; no-op without a DOM).
7073
+ *
7074
+ * @param path - Target path (pathname, optionally with search/hash).
7075
+ * @example
7076
+ * app.spa.navigate("/about");
7077
+ */
7078
+ navigate(path) {
7079
+ ctx.state.kernel?.processNav(path);
7080
+ },
7081
+ /**
7082
+ * Read the current resolved URL.
7083
+ *
7084
+ * @returns The current pathname + search.
7085
+ * @example
7086
+ * app.spa.current();
7087
+ */
7088
+ current() {
7089
+ return ctx.state.currentUrl;
7090
+ }
7091
+ };
7092
+ }
7093
+ //#endregion
7094
+ //#region src/plugins/spa/events.ts
7095
+ /**
7096
+ * Declares the spa plugin's events. Extracted from index.ts to keep the wiring
7097
+ * file under the line budget.
7098
+ *
7099
+ * @param register - The event registration function supplied by the kernel.
7100
+ * @returns The map of spa event descriptors.
7101
+ * @example
7102
+ * const events = spaEvents(register);
7103
+ */
7104
+ function spaEvents(register) {
7105
+ return {
7106
+ "spa:navigate": register("A navigation has been intercepted and is starting."),
7107
+ "spa:navigated": register("The swap completed and the new URL is active."),
7108
+ "spa:component-mount": register("A component instance attached to an element."),
7109
+ "spa:component-unmount": register("A component instance detached from an element.")
7110
+ };
7111
+ }
7112
+ //#endregion
7113
+ //#region src/plugins/spa/types.ts
7114
+ var types_exports$9 = /* @__PURE__ */ require_convention.__exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
7115
+ /** Allowed hook names — single source of truth for fail-fast validation. */
7116
+ const COMPONENT_HOOK_NAMES = [
7117
+ "onCreate",
7118
+ "onMount",
7119
+ "onNavStart",
7120
+ "onNavEnd",
7121
+ "onUnMount",
7122
+ "onDestroy"
7123
+ ];
7124
+ //#endregion
7125
+ //#region src/plugins/spa/components.ts
7126
+ /** Error prefix for spa fail-fast failures (spec/11 Part-3). */
7127
+ const ERROR_PREFIX$2 = "[web]";
7128
+ /** The set of legal hook names, frozen for O(1) membership checks. */
7129
+ const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
7130
+ /**
7131
+ * Create a validated component definition. Validates hook names at registration
7132
+ * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
7133
+ * each provided hook is a function.
7134
+ *
7135
+ * @param name - Unique component name.
7136
+ * @param hooks - Lifecycle hook implementations.
7137
+ * @returns A `ComponentDef` ready to `register`.
7138
+ * @throws {Error} If `name` is empty, any hook key is not in
7139
+ * `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
7140
+ * @example
7141
+ * const counter = createComponent("counter", {
7142
+ * onMount({ el }) { el.textContent = "0"; }
7143
+ * });
7144
+ */
7145
+ function createComponent(name, hooks) {
7146
+ if (name.trim() === "") throw new Error(`${ERROR_PREFIX$2} component name must be a non-empty string\n → pass a unique name to createComponent("name", hooks)`);
7147
+ for (const key of Object.keys(hooks)) {
7148
+ if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${name}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
7149
+ if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${name}" must be a function\n → provide a function or omit the hook`);
7150
+ }
7151
+ return {
7152
+ name,
7153
+ hooks
7154
+ };
7155
+ }
7156
+ /**
7157
+ * Extracts the page data payload from the inline `script#__DATA__` element.
7158
+ * Returns an empty object when the script is absent, empty, or invalid JSON.
7159
+ *
7160
+ * @param doc - The document to read the data script from.
7161
+ * @returns The parsed page data, or `{}` when unavailable.
7162
+ * @example
7163
+ * const data = extractPageData(document);
7164
+ */
7165
+ function extractPageData(doc) {
7166
+ const text = doc.querySelector("script#__DATA__")?.textContent;
7167
+ if (!text) return {};
7168
+ try {
7169
+ return JSON.parse(text);
7170
+ } catch {
7171
+ return {};
7172
+ }
7173
+ }
7174
+ /**
7175
+ * Builds a live component instance bound to an element.
7176
+ *
7177
+ * @param definition - The component definition.
7178
+ * @param element - The element the instance binds to.
5939
7179
  * @param persistent - Whether the instance survives navigation.
5940
7180
  * @returns The constructed (not-yet-mounted) instance.
5941
7181
  * @example
@@ -6937,27 +8177,30 @@ const spaPlugin = createPlugin$1("spa", {
6937
8177
  //#region src/plugins/build/types.ts
6938
8178
  var types_exports = /* @__PURE__ */ require_convention.__exportAll({});
6939
8179
  //#endregion
6940
- //#region src/plugins/content/types.ts
8180
+ //#region src/plugins/cli/types.ts
6941
8181
  var types_exports$1 = /* @__PURE__ */ require_convention.__exportAll({});
6942
8182
  //#endregion
6943
- //#region src/plugins/data/types.ts
8183
+ //#region src/plugins/content/types.ts
6944
8184
  var types_exports$2 = /* @__PURE__ */ require_convention.__exportAll({});
6945
8185
  //#endregion
6946
- //#region src/plugins/deploy/types.ts
8186
+ //#region src/plugins/data/types.ts
6947
8187
  var types_exports$3 = /* @__PURE__ */ require_convention.__exportAll({});
6948
8188
  //#endregion
6949
- //#region src/plugins/env/types.ts
8189
+ //#region src/plugins/deploy/types.ts
6950
8190
  var types_exports$4 = /* @__PURE__ */ require_convention.__exportAll({});
6951
8191
  //#endregion
6952
- //#region src/plugins/head/types.ts
8192
+ //#region src/plugins/env/types.ts
6953
8193
  var types_exports$5 = /* @__PURE__ */ require_convention.__exportAll({});
6954
8194
  //#endregion
6955
- //#region src/plugins/log/types.ts
8195
+ //#region src/plugins/head/types.ts
6956
8196
  var types_exports$6 = /* @__PURE__ */ require_convention.__exportAll({});
6957
8197
  //#endregion
6958
- //#region src/plugins/router/types.ts
8198
+ //#region src/plugins/log/types.ts
6959
8199
  var types_exports$7 = /* @__PURE__ */ require_convention.__exportAll({});
6960
8200
  //#endregion
8201
+ //#region src/plugins/router/types.ts
8202
+ var types_exports$8 = /* @__PURE__ */ require_convention.__exportAll({});
8203
+ //#endregion
6961
8204
  //#region src/plugins/env/providers.ts
6962
8205
  /**
6963
8206
  * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
@@ -7180,58 +8423,65 @@ Object.defineProperty(exports, "Build", {
7180
8423
  return types_exports;
7181
8424
  }
7182
8425
  });
7183
- Object.defineProperty(exports, "Content", {
8426
+ Object.defineProperty(exports, "Cli", {
7184
8427
  enumerable: true,
7185
8428
  get: function() {
7186
8429
  return types_exports$1;
7187
8430
  }
7188
8431
  });
7189
- Object.defineProperty(exports, "Data", {
8432
+ Object.defineProperty(exports, "Content", {
7190
8433
  enumerable: true,
7191
8434
  get: function() {
7192
8435
  return types_exports$2;
7193
8436
  }
7194
8437
  });
7195
- Object.defineProperty(exports, "Deploy", {
8438
+ Object.defineProperty(exports, "Data", {
7196
8439
  enumerable: true,
7197
8440
  get: function() {
7198
8441
  return types_exports$3;
7199
8442
  }
7200
8443
  });
7201
- Object.defineProperty(exports, "Env", {
8444
+ Object.defineProperty(exports, "Deploy", {
7202
8445
  enumerable: true,
7203
8446
  get: function() {
7204
8447
  return types_exports$4;
7205
8448
  }
7206
8449
  });
7207
- Object.defineProperty(exports, "Head", {
8450
+ Object.defineProperty(exports, "Env", {
7208
8451
  enumerable: true,
7209
8452
  get: function() {
7210
8453
  return types_exports$5;
7211
8454
  }
7212
8455
  });
7213
- Object.defineProperty(exports, "Log", {
8456
+ Object.defineProperty(exports, "Head", {
7214
8457
  enumerable: true,
7215
8458
  get: function() {
7216
8459
  return types_exports$6;
7217
8460
  }
7218
8461
  });
7219
- Object.defineProperty(exports, "Router", {
8462
+ Object.defineProperty(exports, "Log", {
7220
8463
  enumerable: true,
7221
8464
  get: function() {
7222
8465
  return types_exports$7;
7223
8466
  }
7224
8467
  });
7225
- Object.defineProperty(exports, "Spa", {
8468
+ Object.defineProperty(exports, "Router", {
7226
8469
  enumerable: true,
7227
8470
  get: function() {
7228
8471
  return types_exports$8;
7229
8472
  }
7230
8473
  });
8474
+ Object.defineProperty(exports, "Spa", {
8475
+ enumerable: true,
8476
+ get: function() {
8477
+ return types_exports$9;
8478
+ }
8479
+ });
7231
8480
  exports.browserEnv = browserEnv;
7232
8481
  exports.buildArticleHead = buildArticleHead;
7233
8482
  exports.buildPlugin = buildPlugin;
7234
8483
  exports.canonical = canonical;
8484
+ exports.cliPlugin = cliPlugin;
7235
8485
  exports.cloudflareBindings = cloudflareBindings;
7236
8486
  exports.contentPlugin = contentPlugin;
7237
8487
  exports.createApp = createApp;