@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.mjs CHANGED
@@ -17,14 +17,16 @@ import remarkRehype from "remark-rehype";
17
17
  import { visit } from "unist-util-visit";
18
18
  import { defaultSchema } from "hast-util-sanitize";
19
19
  import readingTime from "reading-time";
20
- import { existsSync, readFileSync, readdirSync } from "node:fs";
20
+ import { existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
21
21
  import { createHash, randomUUID } from "node:crypto";
22
22
  import { Feed } from "feed";
23
23
  import { h } from "preact";
24
24
  import { renderToString } from "preact-render-to-string";
25
+ import { createInterface } from "node:readline";
26
+ import { networkInterfaces } from "node:os";
25
27
  //#region src/plugins/env/api.ts
26
28
  /** Error prefix for all env API failures. */
27
- const ERROR_PREFIX$14 = "[web]";
29
+ const ERROR_PREFIX$15 = "[web]";
28
30
  /**
29
31
  * Creates the env plugin API surface mounted at `ctx.env`. Closes over
30
32
  * `ctx.state` ({@link EnvState}) and reads the frozen `resolved` / `publicMap`
@@ -68,7 +70,7 @@ function createEnvApi(ctx) {
68
70
  */
69
71
  require(key) {
70
72
  const value = resolved.get(key);
71
- if (value === void 0) throw new Error(`${ERROR_PREFIX$14} env: required variable "${key}" is not defined.`);
73
+ if (value === void 0) throw new Error(`${ERROR_PREFIX$15} env: required variable "${key}" is not defined.`);
72
74
  return value;
73
75
  },
74
76
  /**
@@ -135,7 +137,7 @@ function createEnvState() {
135
137
  /** Error message thrown by every frozen-map mutator. */
136
138
  const FROZEN_MESSAGE = "env: map is frozen and cannot be mutated";
137
139
  /** Error prefix for all resolution-pipeline failures. */
138
- const ERROR_PREFIX$13 = "[web]";
140
+ const ERROR_PREFIX$14 = "[web]";
139
141
  /**
140
142
  * Throws the canonical frozen-map error; installed as a map's `set`/`clear`/`delete`.
141
143
  *
@@ -186,8 +188,8 @@ function crossCheckPublicPrefix(config) {
186
188
  const { schema, publicPrefix } = config;
187
189
  for (const [key, spec] of Object.entries(schema)) {
188
190
  const hasPrefix = key.startsWith(publicPrefix);
189
- if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$13} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
190
- if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$13} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
191
+ if (spec.public === true && !hasPrefix) throw new Error(`${ERROR_PREFIX$14} env: "${key}" is marked public but does not start with "${publicPrefix}".`);
192
+ if (hasPrefix && spec.public !== true) throw new Error(`${ERROR_PREFIX$14} env: "${key}" starts with "${publicPrefix}" but is not marked public:true.`);
191
193
  }
192
194
  }
193
195
  /**
@@ -238,7 +240,7 @@ function validateSchema(ctx) {
238
240
  crossCheckPublicPrefix(config);
239
241
  for (const [key, spec] of Object.entries(schema)) {
240
242
  if (merged[key] === void 0 && spec.default !== void 0) merged[key] = spec.default;
241
- 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.`);
243
+ 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.`);
242
244
  }
243
245
  for (const [key, spec] of Object.entries(schema)) {
244
246
  const value = merged[key];
@@ -780,7 +782,7 @@ const createCore = coreConfig.createCore;
780
782
  //#endregion
781
783
  //#region src/plugins/i18n/api.ts
782
784
  /** Error prefix for all i18n lifecycle failures. */
783
- const ERROR_PREFIX$12 = "[web]";
785
+ const ERROR_PREFIX$13 = "[web]";
784
786
  /**
785
787
  * Validates the resolved i18n config (fail-fast at `createApp`). Throws when
786
788
  * `locales` is empty or when `defaultLocale` is not a member of `locales`.
@@ -796,8 +798,8 @@ const ERROR_PREFIX$12 = "[web]";
796
798
  */
797
799
  function validateI18nConfig(ctx) {
798
800
  const { locales, defaultLocale } = ctx.config;
799
- 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"].`);
800
- 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.`);
801
+ 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"].`);
802
+ 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.`);
801
803
  }
802
804
  /**
803
805
  * Creates the i18n plugin API surface — locale registry accessors plus the
@@ -1322,6 +1324,23 @@ function calculateReadingTime(text) {
1322
1324
  function articleToUrl(locale, slug) {
1323
1325
  return `/${locale}/${slug}/`;
1324
1326
  }
1327
+ /** Matches an `<img>` `src` that points at the co-located `images/` dir (relative or root-relative). */
1328
+ const RELATIVE_IMAGE_SRC = /(<img\b[^>]*?\bsrc=")(?:\.?\/)?images\//g;
1329
+ /**
1330
+ * Rewrite relative co-located image URLs (`./images/x.webp`) in rendered article HTML to the shared
1331
+ * absolute path the build copies them to (`/<slug>/images/...`), so they resolve from any locale page.
1332
+ *
1333
+ * @param html - The rendered article HTML.
1334
+ * @param slug - Article directory name.
1335
+ * @returns The HTML with image `src`s rewritten to absolute paths.
1336
+ * @example
1337
+ * ```ts
1338
+ * rewriteImageUrls('<img src="./images/a.webp">', "post"); // '<img src="/post/images/a.webp">'
1339
+ * ```
1340
+ */
1341
+ function rewriteImageUrls(html, slug) {
1342
+ return html.replaceAll(RELATIVE_IMAGE_SRC, `$1/${slug}/images/`);
1343
+ }
1325
1344
  /**
1326
1345
  * Build the canonical "article not found" error for {@link createContentApi.load}.
1327
1346
  * Centralised so the null-resolve path and the production draft-suppression path
@@ -1437,7 +1456,7 @@ async function readArticle(ctx, slug, fileLocale, outLocale, isFallback) {
1437
1456
  ctx.state.dirtyPaths.delete(filePath);
1438
1457
  const { frontmatter, body } = parseFrontmatter(raw, ctx.config);
1439
1458
  const processor = ensureProcessor(ctx.state, ctx.config);
1440
- const html = String(await processor.process(body));
1459
+ const html = rewriteImageUrls(String(await processor.process(body)), slug);
1441
1460
  const { readingTime, wordCount } = calculateReadingTime(body);
1442
1461
  return {
1443
1462
  frontmatter,
@@ -1649,6 +1668,18 @@ function createContentApi(ctx) {
1649
1668
  */
1650
1669
  articleToCard(article) {
1651
1670
  return toCard(article);
1671
+ },
1672
+ /**
1673
+ * The configured content source directory (e.g. `"./content"`).
1674
+ *
1675
+ * @returns The content directory path from config.
1676
+ * @example
1677
+ * ```ts
1678
+ * api.contentDir(); // "./content"
1679
+ * ```
1680
+ */
1681
+ contentDir() {
1682
+ return ctx.config.contentDir;
1652
1683
  }
1653
1684
  };
1654
1685
  }
@@ -1772,7 +1803,7 @@ const contentPlugin = createPlugin$1("content", {
1772
1803
  //#endregion
1773
1804
  //#region src/plugins/site/api.ts
1774
1805
  /** Error prefix for all site lifecycle/validation failures. */
1775
- const ERROR_PREFIX$11 = "[web]";
1806
+ const ERROR_PREFIX$12 = "[web]";
1776
1807
  /**
1777
1808
  * Joins a relative path against an absolute base URL, normalizing the slash
1778
1809
  * boundary to exactly one "/". Returns the base unchanged for an empty or
@@ -1840,8 +1871,8 @@ function isAbsoluteUrl(value) {
1840
1871
  * ```
1841
1872
  */
1842
1873
  function validateSiteConfig(ctx) {
1843
- 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.`);
1844
- 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".`);
1874
+ 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.`);
1875
+ 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".`);
1845
1876
  }
1846
1877
  /**
1847
1878
  * Creates the site plugin API surface — read-only accessors over frozen config
@@ -2031,7 +2062,7 @@ function matchRoute(compiled, pathname) {
2031
2062
  * `manifest`. Returns values/copies, never the raw `ctx.state` reference (spec/11 §2.4).
2032
2063
  */
2033
2064
  /** Error prefix for router API failures. */
2034
- const ERROR_PREFIX$10 = "[web] router";
2065
+ const ERROR_PREFIX$11 = "[web] router";
2035
2066
  /**
2036
2067
  * Read the compiled matcher table, throwing if `onInit` has not run yet. This
2037
2068
  * `null` cannot occur in practice post-`onInit`; the guard documents the invariant.
@@ -2045,7 +2076,7 @@ const ERROR_PREFIX$10 = "[web] router";
2045
2076
  * ```
2046
2077
  */
2047
2078
  function readTable(state) {
2048
- if (state.table === null) throw new Error(`${ERROR_PREFIX$10}: matcher table accessed before onInit compiled it.`);
2079
+ if (state.table === null) throw new Error(`${ERROR_PREFIX$11}: matcher table accessed before onInit compiled it.`);
2049
2080
  return state.table;
2050
2081
  }
2051
2082
  /**
@@ -2099,7 +2130,7 @@ function toClientRoute(entry) {
2099
2130
  * api.match("/en/hello/");
2100
2131
  * ```
2101
2132
  */
2102
- function createApi$4(ctx) {
2133
+ function createApi$5(ctx) {
2103
2134
  const { state } = ctx;
2104
2135
  return {
2105
2136
  /**
@@ -2129,7 +2160,7 @@ function createApi$4(ctx) {
2129
2160
  */
2130
2161
  toUrl(routeName, params) {
2131
2162
  const entry = readTable(state).byName.get(routeName);
2132
- if (!entry) throw new Error(`${ERROR_PREFIX$10}: unknown route name "${routeName}".`);
2163
+ if (!entry) throw new Error(`${ERROR_PREFIX$11}: unknown route name "${routeName}".`);
2133
2164
  return entry.toUrl(params);
2134
2165
  },
2135
2166
  /**
@@ -2264,7 +2295,7 @@ function bySpecificity(a, b) {
2264
2295
  * only (`CompileInput`) — never the plugin ctx.
2265
2296
  */
2266
2297
  /** Shared `[web]` error prefix for router validation failures. */
2267
- const ERROR_PREFIX$9 = "[web] router";
2298
+ const ERROR_PREFIX$10 = "[web] router";
2268
2299
  /**
2269
2300
  * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
2270
2301
  * naming the offending route/pattern on any failure: empty map, a pattern not
@@ -2279,12 +2310,12 @@ const ERROR_PREFIX$9 = "[web] router";
2279
2310
  */
2280
2311
  function validateRoutes(routes) {
2281
2312
  const names = Object.keys(routes);
2282
- if (names.length === 0) throw new Error(`${ERROR_PREFIX$9}: route map is empty — provide at least one route via pluginConfigs.router.routes.`);
2313
+ if (names.length === 0) throw new Error(`${ERROR_PREFIX$10}: route map is empty — provide at least one route via pluginConfigs.router.routes.`);
2283
2314
  for (const name of names) {
2284
2315
  const pattern = routes[name]?.pattern ?? "";
2285
- if (!pattern.startsWith("/")) throw new Error(`${ERROR_PREFIX$9}: route "${name}" pattern must start with "/" (got "${pattern}").`);
2286
- if ((pattern.match(/\{/g) ?? []).length !== (pattern.match(/\}/g) ?? []).length) throw new Error(`${ERROR_PREFIX$9}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
2287
- if ((pattern.match(/\{lang:\?\}/g) ?? []).length > 1) throw new Error(`${ERROR_PREFIX$9}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
2316
+ if (!pattern.startsWith("/")) throw new Error(`${ERROR_PREFIX$10}: route "${name}" pattern must start with "/" (got "${pattern}").`);
2317
+ if ((pattern.match(/\{/g) ?? []).length !== (pattern.match(/\}/g) ?? []).length) throw new Error(`${ERROR_PREFIX$10}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
2318
+ if ((pattern.match(/\{lang:\?\}/g) ?? []).length > 1) throw new Error(`${ERROR_PREFIX$10}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
2288
2319
  }
2289
2320
  }
2290
2321
  /**
@@ -2689,7 +2720,7 @@ function defineRoutes(routes) {
2689
2720
  * const state = createState({ global: {}, config: { routes: {} } });
2690
2721
  * ```
2691
2722
  */
2692
- function createState$4(_ctx) {
2723
+ function createState$5(_ctx) {
2693
2724
  return {
2694
2725
  table: null,
2695
2726
  mode: _ctx.config.mode ?? "hybrid"
@@ -2725,8 +2756,8 @@ const routerPlugin = createPlugin$1("router", {
2725
2756
  routes: {},
2726
2757
  mode: "hybrid"
2727
2758
  },
2728
- createState: createState$4,
2729
- api: createApi$4,
2759
+ createState: createState$5,
2760
+ api: createApi$5,
2730
2761
  onInit(ctx) {
2731
2762
  const i18n = ctx.require(i18nPlugin);
2732
2763
  const baseUrl = ctx.require(sitePlugin).url();
@@ -3066,7 +3097,7 @@ function serializeHead(elements) {
3066
3097
  * it to a string. It holds no resource and caches no subscription.
3067
3098
  */
3068
3099
  /** Error prefix for head API invariant failures. */
3069
- const ERROR_PREFIX$8 = "[head]";
3100
+ const ERROR_PREFIX$9 = "[head]";
3070
3101
  /**
3071
3102
  * Read the normalized defaults, asserting the post-`onInit` invariant (the slot is
3072
3103
  * `null` only before `onInit` assigns it, which cannot occur at render time).
@@ -3080,7 +3111,7 @@ const ERROR_PREFIX$8 = "[head]";
3080
3111
  * ```
3081
3112
  */
3082
3113
  function readDefaults(state) {
3083
- if (state.defaults === null) throw new Error(`${ERROR_PREFIX$8}: defaults accessed before onInit normalized them.`);
3114
+ if (state.defaults === null) throw new Error(`${ERROR_PREFIX$9}: defaults accessed before onInit normalized them.`);
3084
3115
  return state.defaults;
3085
3116
  }
3086
3117
  /**
@@ -3096,7 +3127,7 @@ function readDefaults(state) {
3096
3127
  * api.render(route, data);
3097
3128
  * ```
3098
3129
  */
3099
- function createApi$3(ctx) {
3130
+ function createApi$4(ctx) {
3100
3131
  return {
3101
3132
  /**
3102
3133
  * Compose the final `<head>` inner HTML for a route (pulled by `build`).
@@ -3123,7 +3154,7 @@ render(route, data) {
3123
3154
  //#endregion
3124
3155
  //#region src/plugins/head/config.ts
3125
3156
  /** Error prefix for all head config-validation failures. */
3126
- const ERROR_PREFIX$7 = "[head] config:";
3157
+ const ERROR_PREFIX$8 = "[head] config:";
3127
3158
  /** The allowed `twitterCard` literals (also the runtime guard set). */
3128
3159
  const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
3129
3160
  /**
@@ -3136,7 +3167,7 @@ const VALID_TWITTER_CARDS = ["summary", "summary_large_image"];
3136
3167
  * createPlugin("head", { config: defaultConfig });
3137
3168
  * ```
3138
3169
  */
3139
- const defaultConfig$2 = { twitterCard: "summary_large_image" };
3170
+ const defaultConfig$3 = { twitterCard: "summary_large_image" };
3140
3171
  /**
3141
3172
  * Structurally validate the resolved head config (no I/O). Throws a standard
3142
3173
  * `[head] config: …` error when `titleTemplate` is provided without the `%s`
@@ -3150,8 +3181,8 @@ const defaultConfig$2 = { twitterCard: "summary_large_image" };
3150
3181
  * ```
3151
3182
  */
3152
3183
  function validateHeadConfig(config) {
3153
- 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)}.`);
3154
- 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)}.`);
3184
+ 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)}.`);
3185
+ 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)}.`);
3155
3186
  }
3156
3187
  /**
3157
3188
  * Validate then build the frozen, normalized {@link HeadDefaults} snapshot read by
@@ -3219,7 +3250,7 @@ const headHelpers = {
3219
3250
  * const state = createState({ global: {}, config: {} });
3220
3251
  * ```
3221
3252
  */
3222
- function createState$3(_ctx) {
3253
+ function createState$4(_ctx) {
3223
3254
  return { defaults: null };
3224
3255
  }
3225
3256
  //#endregion
@@ -3254,9 +3285,9 @@ const headPlugin = createPlugin$1("head", {
3254
3285
  routerPlugin
3255
3286
  ],
3256
3287
  helpers: headHelpers,
3257
- config: defaultConfig$2,
3258
- createState: createState$3,
3259
- api: createApi$3,
3288
+ config: defaultConfig$3,
3289
+ createState: createState$4,
3290
+ api: createApi$4,
3260
3291
  onInit(ctx) {
3261
3292
  ctx.state.defaults = normalizeHeadConfig(ctx.config);
3262
3293
  }
@@ -3432,6 +3463,52 @@ function readCachedContent(ctx) {
3432
3463
  return cached instanceof Map ? cached : /* @__PURE__ */ new Map();
3433
3464
  }
3434
3465
  //#endregion
3466
+ //#region src/plugins/build/phases/content-images.ts
3467
+ /**
3468
+ * @file build phase — content-images. Copies each article's co-located image directory
3469
+ * (`<contentDir>/<slug>/images/`) to a single shared output dir (`<outDir>/<slug>/images/`) reused by
3470
+ * every locale, matching the absolute `/<slug>/images/...` URLs the content renderer emits. Gated by
3471
+ * `config.images`.
3472
+ */
3473
+ /** Conventional per-article image subdirectory name (alongside `<slug>/<locale>.md`). */
3474
+ const ARTICLE_IMAGE_DIR = "images";
3475
+ /**
3476
+ * Copy every article's co-located `images/` directory to `<outDir>/<slug>/images/`. No-op when
3477
+ * `config.images` is false or the content directory does not exist.
3478
+ *
3479
+ * @param ctx - Plugin context (provides `config`, `log`, `require`).
3480
+ * @returns The number of directories copied (one per article that has an `images/` dir).
3481
+ * @example
3482
+ * ```ts
3483
+ * const copied = await copyContentImages(ctx);
3484
+ * ```
3485
+ */
3486
+ async function copyContentImages(ctx) {
3487
+ if (!ctx.config.images) {
3488
+ ctx.log.debug("build:content-images", { skipped: true });
3489
+ return 0;
3490
+ }
3491
+ const contentDir = ctx.require(contentPlugin).contentDir();
3492
+ if (!existsSync(contentDir)) {
3493
+ ctx.log.debug("build:content-images", {
3494
+ skipped: true,
3495
+ reason: "no content dir"
3496
+ });
3497
+ return 0;
3498
+ }
3499
+ const entries = await readdir(contentDir, { withFileTypes: true });
3500
+ let copied = 0;
3501
+ for (const entry of entries) {
3502
+ if (!entry.isDirectory()) continue;
3503
+ const source = path.join(contentDir, entry.name, ARTICLE_IMAGE_DIR);
3504
+ if (!existsSync(source)) continue;
3505
+ await cp(source, path.join(ctx.config.outDir, entry.name, ARTICLE_IMAGE_DIR), { recursive: true });
3506
+ copied += 1;
3507
+ }
3508
+ ctx.log.debug("build:content-images", { copied });
3509
+ return copied;
3510
+ }
3511
+ //#endregion
3435
3512
  //#region src/plugins/build/phases/feeds.ts
3436
3513
  /**
3437
3514
  * @file build phase 4 — feeds. Generates RSS/Atom/JSON from cached content plus
@@ -4677,6 +4754,7 @@ const PHASE_ORDER = [
4677
4754
  "content",
4678
4755
  "images",
4679
4756
  "pages",
4757
+ "content-images",
4680
4758
  "feeds",
4681
4759
  "sitemap",
4682
4760
  "og-images",
@@ -4783,6 +4861,7 @@ async function runPipeline(ctx, options) {
4783
4861
  await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
4784
4862
  await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext)), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
4785
4863
  const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext));
4864
+ await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
4786
4865
  await runOutputs(phaseContext);
4787
4866
  await withPhase(phaseContext, "root-index", async () => {
4788
4867
  if (pages.rootHtml !== null) await writeFile(path.join(outDir, "index.html"), pages.rootHtml, "utf8");
@@ -4801,7 +4880,7 @@ async function runPipeline(ctx, options) {
4801
4880
  * @file build plugin — API factory (run + phases), cross-plugin wiring, and onInit config validation.
4802
4881
  */
4803
4882
  /** Error prefix for build config/validation failures (spec/11 Part-3). */
4804
- const ERROR_PREFIX$6 = "[web] build";
4883
+ const ERROR_PREFIX$7 = "[web] build";
4805
4884
  /** Recognized font file extensions for OG-image validation. */
4806
4885
  const FONT_EXTENSIONS = [
4807
4886
  ".ttf",
@@ -4809,7 +4888,7 @@ const FONT_EXTENSIONS = [
4809
4888
  ".woff"
4810
4889
  ];
4811
4890
  /** Typed default `build` config (R6: no inline `as`). `ogImage: false` disables OG generation. */
4812
- const defaultConfig$1 = {
4891
+ const defaultConfig$2 = {
4813
4892
  outDir: "./dist",
4814
4893
  minify: true,
4815
4894
  feeds: true,
@@ -4831,7 +4910,7 @@ const defaultConfig$1 = {
4831
4910
  * await api.run({ outDir: "./preview" });
4832
4911
  * ```
4833
4912
  */
4834
- function createApi$2(ctx) {
4913
+ function createApi$3(ctx) {
4835
4914
  return {
4836
4915
  /**
4837
4916
  * Run the full SSG pipeline and write the site to disk.
@@ -4872,8 +4951,8 @@ function createApi$2(ctx) {
4872
4951
  * ```
4873
4952
  */
4874
4953
  function validateFonts(og) {
4875
- if (typeof og.fontDir !== "string" || og.fontDir.length === 0 || !existsSync(og.fontDir)) throw new Error(`${ERROR_PREFIX$6}.ogImage: fontDir "${og.fontDir}" does not exist — provide a directory with at least one font.`);
4876
- if (!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.`);
4954
+ if (typeof og.fontDir !== "string" || og.fontDir.length === 0 || !existsSync(og.fontDir)) throw new Error(`${ERROR_PREFIX$7}.ogImage: fontDir "${og.fontDir}" does not exist — provide a directory with at least one font.`);
4955
+ if (!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.`);
4877
4956
  }
4878
4957
  /**
4879
4958
  * Validates `build` config synchronously in `onInit` (return value discarded).
@@ -4886,11 +4965,11 @@ function validateFonts(og) {
4886
4965
  * validateConfig(ctx.config);
4887
4966
  * ```
4888
4967
  */
4889
- function validateConfig$1(config) {
4890
- if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$6}.outDir: must be a non-empty string.`);
4891
- if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$6}.publicDir: must be a string when set.`);
4892
- if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$6}.template: must be a string path when set.`);
4893
- if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$6}.clientEntry: must be a string path when set.`);
4968
+ function validateConfig$2(config) {
4969
+ if (typeof config.outDir !== "string" || config.outDir.trim().length === 0) throw new Error(`${ERROR_PREFIX$7}.outDir: must be a non-empty string.`);
4970
+ if (config.publicDir !== void 0 && typeof config.publicDir !== "string") throw new Error(`${ERROR_PREFIX$7}.publicDir: must be a string when set.`);
4971
+ if (config.template !== void 0 && typeof config.template !== "string") throw new Error(`${ERROR_PREFIX$7}.template: must be a string path when set.`);
4972
+ if (config.clientEntry !== void 0 && typeof config.clientEntry !== "string") throw new Error(`${ERROR_PREFIX$7}.clientEntry: must be a string path when set.`);
4894
4973
  if (config.ogImage) validateFonts(config.ogImage);
4895
4974
  }
4896
4975
  //#endregion
@@ -4929,7 +5008,7 @@ function createEvents(register) {
4929
5008
  * const state = createState({ global: {}, config });
4930
5009
  * ```
4931
5010
  */
4932
- function createState$2(ctx) {
5011
+ function createState$3(ctx) {
4933
5012
  return {
4934
5013
  config: ctx.config,
4935
5014
  manifest: null,
@@ -4973,11 +5052,11 @@ const buildPlugin = createPlugin$1("build", {
4973
5052
  routerPlugin,
4974
5053
  headPlugin
4975
5054
  ],
4976
- config: defaultConfig$1,
4977
- createState: createState$2,
5055
+ config: defaultConfig$2,
5056
+ createState: createState$3,
4978
5057
  events: createEvents,
4979
- api: createApi$2,
4980
- onInit: (ctx) => validateConfig$1(ctx.config)
5058
+ api: createApi$3,
5059
+ onInit: (ctx) => validateConfig$2(ctx.config)
4981
5060
  });
4982
5061
  //#endregion
4983
5062
  //#region src/plugins/deploy/wrangler.ts
@@ -4992,7 +5071,7 @@ const buildPlugin = createPlugin$1("build", {
4992
5071
  */
4993
5072
  const MOKU_WRANGLER_VERSION = "4.34.0";
4994
5073
  /** Error prefix for deploy runtime failures (spec/11 Part-3). */
4995
- const ERROR_PREFIX$5 = "[web] deploy";
5074
+ const ERROR_PREFIX$6 = "[web] deploy";
4996
5075
  /** Mask substituted for a detected secret-like token. */
4997
5076
  const MASK = "***";
4998
5077
  /** Minimum token length eligible for entropy-gated scrubbing. */
@@ -5070,7 +5149,7 @@ function scrubSecrets(text, allowlist) {
5070
5149
  * guardBranch("preview/landing"); // "preview/landing"
5071
5150
  */
5072
5151
  function guardBranch(branch) {
5073
- 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.`);
5152
+ 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.`);
5074
5153
  return branch;
5075
5154
  }
5076
5155
  /**
@@ -5087,7 +5166,7 @@ function guardBranch(branch) {
5087
5166
  function assertWithinRoot(outDir, root) {
5088
5167
  const resolved = path.isAbsolute(outDir) ? path.resolve(outDir) : path.resolve(root, outDir);
5089
5168
  const rootResolved = path.resolve(root);
5090
- if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.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)}.`);
5169
+ if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.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)}.`);
5091
5170
  return resolved;
5092
5171
  }
5093
5172
  /**
@@ -5172,11 +5251,11 @@ function classifyWranglerError(exitCode, scrubbedStderr) {
5172
5251
  const haystack = scrubbedStderr.toLowerCase();
5173
5252
  for (const signature of ERROR_SIGNATURES) if (signature.match.some((needle) => haystack.includes(needle))) return {
5174
5253
  code: signature.kind,
5175
- message: `${ERROR_PREFIX$5}: wrangler failed (exit ${exitCode}).\n ${signature.advice}`
5254
+ message: `${ERROR_PREFIX$6}: wrangler failed (exit ${exitCode}).\n ${signature.advice}`
5176
5255
  };
5177
5256
  return {
5178
5257
  code: "ERR_DEPLOY_WRANGLER_FAILED",
5179
- message: `${ERROR_PREFIX$5}: wrangler failed (exit ${exitCode}).\n ${scrubbedStderr.trim().slice(-500)}`
5258
+ message: `${ERROR_PREFIX$6}: wrangler failed (exit ${exitCode}).\n ${scrubbedStderr.trim().slice(-500)}`
5180
5259
  };
5181
5260
  }
5182
5261
  /**
@@ -5442,7 +5521,7 @@ async function reconcile(input) {
5442
5521
  * and short-circuiting on the first failure.
5443
5522
  */
5444
5523
  /** Error prefix for deploy preflight failures (spec/11 Part-3). */
5445
- const ERROR_PREFIX$4 = "[web] deploy";
5524
+ const ERROR_PREFIX$5 = "[web] deploy";
5446
5525
  /** Cloudflare Pages free-tier file-count limit. */
5447
5526
  const FREE_TIER_FILE_LIMIT = 2e4;
5448
5527
  /** Cloudflare Pages paid-tier file-count limit (env override target). */
@@ -5518,15 +5597,15 @@ async function runPreflight(config, root, env = process.env) {
5518
5597
  try {
5519
5598
  await stat(wranglerPath);
5520
5599
  } catch {
5521
- throw deployError("ERR_DEPLOY_NO_WRANGLER_CONFIG", `${ERROR_PREFIX$4}: wrangler.jsonc not found.\n Run \`app.deploy.init()\` to scaffold it, then retry.`);
5600
+ throw deployError("ERR_DEPLOY_NO_WRANGLER_CONFIG", `${ERROR_PREFIX$5}: wrangler.jsonc not found.\n Run \`app.deploy.init()\` to scaffold it, then retry.`);
5522
5601
  }
5523
5602
  const stats = await inspectOutdir(path.isAbsolute(config.outDir) ? path.resolve(config.outDir) : path.resolve(root, config.outDir)).catch(() => {
5524
- throw deployError("ERR_DEPLOY_EMPTY_OUTDIR", `${ERROR_PREFIX$4}: outDir ${JSON.stringify(config.outDir)} is missing.\n Run your build first, then retry.`);
5603
+ throw deployError("ERR_DEPLOY_EMPTY_OUTDIR", `${ERROR_PREFIX$5}: outDir ${JSON.stringify(config.outDir)} is missing.\n Run your build first, then retry.`);
5525
5604
  });
5526
- if (stats.fileCount === 0) throw deployError("ERR_DEPLOY_EMPTY_OUTDIR", `${ERROR_PREFIX$4}: outDir ${JSON.stringify(config.outDir)} is empty — nothing to deploy.`);
5605
+ if (stats.fileCount === 0) throw deployError("ERR_DEPLOY_EMPTY_OUTDIR", `${ERROR_PREFIX$5}: outDir ${JSON.stringify(config.outDir)} is empty — nothing to deploy.`);
5527
5606
  const limit = resolveFileLimit(env);
5528
- 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.`);
5529
- 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.`);
5607
+ 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.`);
5608
+ 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.`);
5530
5609
  }
5531
5610
  //#endregion
5532
5611
  //#region src/plugins/deploy/slug.ts
@@ -5571,7 +5650,7 @@ function toSlug(name) {
5571
5650
  //#endregion
5572
5651
  //#region src/plugins/deploy/api.ts
5573
5652
  /** Error prefix for deploy config/validation failures (spec/11 Part-3). */
5574
- const ERROR_PREFIX$3 = "[web] deploy";
5653
+ const ERROR_PREFIX$4 = "[web] deploy";
5575
5654
  /** `YYYY-MM-DD` validator for the compatibility date config field. */
5576
5655
  const COMPAT_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
5577
5656
  /**
@@ -5584,12 +5663,12 @@ const COMPAT_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
5584
5663
  * @example
5585
5664
  * createPlugin("deploy", { onInit: validateConfig });
5586
5665
  */
5587
- function validateConfig(ctx) {
5666
+ function validateConfig$1(ctx) {
5588
5667
  const { config } = ctx;
5589
- 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.`);
5590
- 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").`);
5591
- 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.`);
5592
- 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.`);
5668
+ 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.`);
5669
+ 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").`);
5670
+ 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.`);
5671
+ 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.`);
5593
5672
  ctx.require(sitePlugin);
5594
5673
  }
5595
5674
  /**
@@ -5605,7 +5684,7 @@ function validateConfig(ctx) {
5605
5684
  * const api = createApi(ctx);
5606
5685
  * await api.run({ branch: "preview/landing" });
5607
5686
  */
5608
- function createApi$1(ctx) {
5687
+ function createApi$2(ctx) {
5609
5688
  return {
5610
5689
  /**
5611
5690
  * Deploy the built outDir to Cloudflare Pages via the wrangler subprocess.
@@ -5696,7 +5775,7 @@ function createApi$1(ctx) {
5696
5775
  * createPlugin("deploy", { config: defaultConfig });
5697
5776
  * ```
5698
5777
  */
5699
- const defaultConfig = {
5778
+ const defaultConfig$1 = {
5700
5779
  target: "cloudflare-pages",
5701
5780
  outDir: "dist",
5702
5781
  productionBranch: "main",
@@ -5749,7 +5828,7 @@ const defaultSpawn = (cmd, options) => {
5749
5828
  * @example
5750
5829
  * const state = createState({ global: {}, config });
5751
5830
  */
5752
- function createState$1(_ctx) {
5831
+ function createState$2(_ctx) {
5753
5832
  return {
5754
5833
  lastDeployment: null,
5755
5834
  spawn: defaultSpawn
@@ -5783,146 +5862,1307 @@ function createState$1(_ctx) {
5783
5862
  * ```
5784
5863
  */
5785
5864
  const deployPlugin = createPlugin$1("deploy", {
5786
- config: defaultConfig,
5865
+ config: defaultConfig$1,
5787
5866
  depends: [sitePlugin],
5788
- createState: createState$1,
5867
+ createState: createState$2,
5789
5868
  events: deployEvents,
5790
- onInit: validateConfig,
5791
- api: createApi$1
5869
+ onInit: validateConfig$1,
5870
+ api: createApi$2
5792
5871
  });
5793
5872
  //#endregion
5794
- //#region src/plugins/spa/api.ts
5873
+ //#region src/plugins/cli/errors.ts
5874
+ /** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
5875
+ const ERROR_PREFIX$3 = "[web] cli";
5795
5876
  /**
5796
- * Creates the spa plugin API surface (registration / control). All methods
5797
- * delegate to the single shared kernel stored in `ctx.state.kernel`.
5877
+ * Construct a cli `Error` carrying a taxonomy `code` property. Centralizes the
5878
+ * `Object.assign(new Error(message), { code })` pattern so the `code` is always
5879
+ * preserved on the thrown value (the message is expected to already be prefixed).
5798
5880
  *
5799
- * @param ctx - Plugin context exposing `state` (kernel) and `log`.
5800
- * @returns The {@link SpaApi} surface mounted at `app.spa`.
5881
+ * @param code - The cli error `code` from the taxonomy.
5882
+ * @param message - The actionable error message.
5883
+ * @returns An `Error` whose `code` property is set.
5801
5884
  * @example
5802
- * const api = createApi(ctx);
5803
- * api.register(counter);
5885
+ * throw cliError("ERR_CLI_CONFIG", "[web] cli: port must be 1–65535.");
5804
5886
  */
5805
- function createApi(ctx) {
5887
+ function cliError(code, message) {
5888
+ return Object.assign(new Error(message), { code });
5889
+ }
5890
+ /** The live-reload client snippet injected before `</body>` in served HTML. */
5891
+ const RELOAD_CLIENT = `<script>(()=>{try{const s=new EventSource("/__moku_reload");s.addEventListener("reload",()=>location.reload());}catch{}})();<\/script>`;
5892
+ /**
5893
+ * Inject the live-reload SSE client immediately before the closing `</body>` (or
5894
+ * append it when there is no `</body>`). Pure — unit-testable without a server.
5895
+ *
5896
+ * @param html - The page HTML to augment.
5897
+ * @returns The HTML with the reload client injected.
5898
+ * @example
5899
+ * injectReloadClient("<body>hi</body>"); // "<body>hi<script>…<\/script></body>"
5900
+ */
5901
+ function injectReloadClient(html) {
5902
+ const index = html.lastIndexOf("</body>");
5903
+ return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
5904
+ }
5905
+ /**
5906
+ * Run one rebuild and report the result. Skips re-entrancy via the shared `building`
5907
+ * flag and routes success to `onReloaded`, failure to `onError`.
5908
+ *
5909
+ * @param input - The rebuild dependencies + the changed file.
5910
+ * @param input.runBuild - Runs one build and resolves with its summary.
5911
+ * @param input.onReloaded - Called with the changed file + summary after a rebuild.
5912
+ * @param input.onError - Called when a rebuild throws.
5913
+ * @param input.file - The changed file to report alongside the summary.
5914
+ * @returns Resolves once the rebuild settles (always — errors are routed, not thrown).
5915
+ * @example
5916
+ * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
5917
+ */
5918
+ async function runOneRebuild(input) {
5919
+ try {
5920
+ const summary = await input.runBuild();
5921
+ input.onReloaded({
5922
+ file: input.file,
5923
+ pageCount: summary.pageCount,
5924
+ durationMs: summary.durationMs
5925
+ });
5926
+ } catch (error) {
5927
+ input.onError(error);
5928
+ }
5929
+ }
5930
+ /**
5931
+ * Create a {@link Rebuilder}. The latest changed file within the debounce window
5932
+ * wins; while a rebuild is in flight further notifications are dropped (the watcher
5933
+ * keeps firing, but only one build runs at a time, matching the blog `dev.ts`).
5934
+ *
5935
+ * @param input - The rebuild dependencies.
5936
+ * @param input.debounceMs - Debounce window in milliseconds.
5937
+ * @param input.runBuild - Runs one build and resolves with its summary.
5938
+ * @param input.onReloaded - Called with the changed file + summary after a rebuild.
5939
+ * @param input.onError - Called when a rebuild throws.
5940
+ * @returns The debounced rebuild driver.
5941
+ * @example
5942
+ * createRebuilder({ debounceMs: 150, runBuild, onReloaded, onError });
5943
+ */
5944
+ function createRebuilder(input) {
5945
+ let timer;
5946
+ let pendingFile = "";
5947
+ let building = false;
5948
+ /**
5949
+ * Run the queued rebuild once (skips when one is already in flight), resetting the
5950
+ * in-flight flag when it settles.
5951
+ *
5952
+ * @returns Resolves once the rebuild settles (errors are routed, never thrown).
5953
+ * @example
5954
+ * await fire();
5955
+ */
5956
+ const fire = async () => {
5957
+ timer = void 0;
5958
+ if (building) return;
5959
+ building = true;
5960
+ await runOneRebuild({
5961
+ runBuild: input.runBuild,
5962
+ onReloaded: input.onReloaded,
5963
+ onError: input.onError,
5964
+ file: pendingFile
5965
+ });
5966
+ building = false;
5967
+ };
5806
5968
  return {
5807
5969
  /**
5808
- * Register a component definition (last-registered-wins); warns on collision.
5970
+ * Queue a rebuild for the changed file (debounced + coalesced).
5809
5971
  *
5810
- * @param component - The component definition created via `createComponent`.
5972
+ * @param file - The changed path that triggered the rebuild.
5811
5973
  * @example
5812
- * app.spa.register(counter);
5974
+ * rebuilder.schedule("a.md");
5813
5975
  */
5814
- register(component) {
5815
- if (ctx.state.registeredComponents.has(component.name)) ctx.log.warn("spa:component-collision", { name: component.name });
5816
- ctx.state.kernel?.register(component);
5976
+ schedule(file) {
5977
+ pendingFile = file;
5978
+ if (timer) clearTimeout(timer);
5979
+ timer = setTimeout(fire, input.debounceMs);
5817
5980
  },
5818
5981
  /**
5819
- * Programmatically navigate to a path (client runtime; no-op without a DOM).
5982
+ * Cancel any pending (not-yet-fired) rebuild timer.
5820
5983
  *
5821
- * @param path - Target path (pathname, optionally with search/hash).
5822
5984
  * @example
5823
- * app.spa.navigate("/about");
5985
+ * rebuilder.cancel();
5824
5986
  */
5825
- navigate(path) {
5826
- ctx.state.kernel?.processNav(path);
5987
+ cancel() {
5988
+ if (timer) clearTimeout(timer);
5989
+ timer = void 0;
5990
+ }
5991
+ };
5992
+ }
5993
+ /**
5994
+ * Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
5995
+ * promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
5996
+ * Ctrl-C / termination and detaches its own listeners. Used by both servers.
5997
+ *
5998
+ * @param teardown - Cleanup to run on the first termination signal.
5999
+ * @returns A promise that resolves once a termination signal has been handled.
6000
+ * @example
6001
+ * await installSignalTeardown(() => server.stop());
6002
+ */
6003
+ function installSignalTeardown(teardown) {
6004
+ return new Promise((resolve) => {
6005
+ /**
6006
+ * Detach both signal listeners, run teardown, and resolve the wait (once).
6007
+ *
6008
+ * @example
6009
+ * onSignal();
6010
+ */
6011
+ const onSignal = () => {
6012
+ process.off("SIGINT", onSignal);
6013
+ process.off("SIGTERM", onSignal);
6014
+ teardown();
6015
+ resolve();
6016
+ };
6017
+ process.on("SIGINT", onSignal);
6018
+ process.on("SIGTERM", onSignal);
6019
+ });
6020
+ }
6021
+ /** The SSE comment line sent on connect to open the stream. */
6022
+ const SSE_OPEN = ": connected\n\n";
6023
+ /** The SSE frame pushed to reload a connected browser. */
6024
+ const SSE_RELOAD = "event: reload\ndata: 1\n\n";
6025
+ /**
6026
+ * Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
6027
+ * enqueues into a new stream; `reloadAll()` writes the reload frame to every live
6028
+ * controller (dropping any that have closed).
6029
+ *
6030
+ * @returns The reload hub.
6031
+ * @example
6032
+ * const hub = createReloadHub();
6033
+ */
6034
+ function createReloadHub() {
6035
+ const encoder = new TextEncoder();
6036
+ const clients = /* @__PURE__ */ new Set();
6037
+ return {
6038
+ /**
6039
+ * Open one SSE connection, register its controller, and return the streaming
6040
+ * `Response` (a connect comment is sent immediately to open the stream).
6041
+ *
6042
+ * @returns A `text/event-stream` response wired to this hub.
6043
+ * @example
6044
+ * return hub.connect();
6045
+ */
6046
+ connect() {
6047
+ let owned;
6048
+ const stream = new ReadableStream({
6049
+ /**
6050
+ * Register the stream's controller and send the opening comment.
6051
+ *
6052
+ * @param controller - The new stream's controller.
6053
+ * @example
6054
+ * start(controller);
6055
+ */
6056
+ start(controller) {
6057
+ owned = controller;
6058
+ clients.add(controller);
6059
+ controller.enqueue(encoder.encode(SSE_OPEN));
6060
+ },
6061
+ /**
6062
+ * Drop this client's controller when the browser disconnects.
6063
+ *
6064
+ * @example
6065
+ * cancel();
6066
+ */
6067
+ cancel() {
6068
+ if (owned) clients.delete(owned);
6069
+ }
6070
+ });
6071
+ return new Response(stream, { headers: {
6072
+ "content-type": "text/event-stream",
6073
+ "cache-control": "no-cache",
6074
+ connection: "keep-alive"
6075
+ } });
5827
6076
  },
5828
6077
  /**
5829
- * Read the current resolved URL.
6078
+ * Push a `reload` frame to every connected client, dropping any closed ones.
5830
6079
  *
5831
- * @returns The current pathname + search.
5832
6080
  * @example
5833
- * app.spa.current();
6081
+ * hub.reloadAll();
5834
6082
  */
5835
- current() {
5836
- return ctx.state.currentUrl;
6083
+ reloadAll() {
6084
+ for (const controller of clients) try {
6085
+ controller.enqueue(encoder.encode(SSE_RELOAD));
6086
+ } catch {
6087
+ clients.delete(controller);
6088
+ }
6089
+ },
6090
+ /**
6091
+ * The number of currently-connected clients.
6092
+ *
6093
+ * @returns The live client count.
6094
+ * @example
6095
+ * hub.size();
6096
+ */
6097
+ size() {
6098
+ return clients.size;
5837
6099
  }
5838
6100
  };
5839
6101
  }
5840
- //#endregion
5841
- //#region src/plugins/spa/events.ts
5842
6102
  /**
5843
- * Declares the spa plugin's events. Extracted from index.ts to keep the wiring
5844
- * file under the line budget.
6103
+ * Build the live-reload-aware request handler for the dev server: serves the SSE
6104
+ * stream at {@link RELOAD_PATH}, injects the reload client into HTML responses (when
6105
+ * `liveReload`), and falls through to the {@link resolveCleanUrl} static resolver for
6106
+ * everything else.
6107
+ *
6108
+ * @param ctx - The cli plugin context (provides `config` + `state.fileResponse`).
6109
+ * @param hub - The reload hub the SSE endpoint connects to.
6110
+ * @returns The `fetch` handler passed to the static server.
6111
+ * @example
6112
+ * const handler = createDevHandler(ctx, hub);
6113
+ */
6114
+ function createDevHandler(ctx, hub) {
6115
+ return async (request) => {
6116
+ const pathname = decodeURIComponent(new URL(request.url).pathname);
6117
+ if (pathname === "/__moku_reload") return hub.connect();
6118
+ const resolved = resolveCleanUrl(ctx.config.outDir, pathname);
6119
+ if (resolved.file === null) return new Response("Not Found", { status: 404 });
6120
+ const response = ctx.state.fileResponse(resolved.file, resolved.status);
6121
+ if (ctx.config.liveReload && resolved.file.endsWith(".html")) {
6122
+ const html = injectReloadClient(await response.text());
6123
+ return new Response(html, {
6124
+ status: resolved.status,
6125
+ headers: { "content-type": "text/html; charset=utf-8" }
6126
+ });
6127
+ }
6128
+ return response;
6129
+ };
6130
+ }
6131
+ /**
6132
+ * Run the dev loop: an initial build, an in-process static server that injects the
6133
+ * live-reload client, a recursive watcher over `config.watchDirs`, and a debounced
6134
+ * rebuild that re-renders and pushes a browser reload. Resolves on SIGINT/SIGTERM,
6135
+ * which stops the server, closes the watchers, and cancels any pending rebuild.
5845
6136
  *
5846
- * @param register - The event registration function supplied by the kernel.
5847
- * @returns The map of spa event descriptors.
6137
+ * @param ctx - The cli plugin context (config, state seams, `require`).
6138
+ * @param port - The port to bind the dev server to.
6139
+ * @returns Resolves once the server has been torn down by a termination signal.
5848
6140
  * @example
5849
- * const events = spaEvents(register);
6141
+ * await runDevServer(ctx, 4173);
5850
6142
  */
5851
- function spaEvents(register) {
5852
- return {
5853
- "spa:navigate": register("A navigation has been intercepted and is starting."),
5854
- "spa:navigated": register("The swap completed and the new URL is active."),
5855
- "spa:component-mount": register("A component instance attached to an element."),
5856
- "spa:component-unmount": register("A component instance detached from an element.")
5857
- };
6143
+ async function runDevServer(ctx, port) {
6144
+ await ctx.require(buildPlugin).run();
6145
+ const hub = createReloadHub();
6146
+ const server = ctx.state.serveStatic({
6147
+ port,
6148
+ fetch: createDevHandler(ctx, hub)
6149
+ });
6150
+ const rebuilder = createRebuilder({
6151
+ debounceMs: ctx.config.debounceMs,
6152
+ /**
6153
+ * Re-run the SSG build for a rebuild.
6154
+ *
6155
+ * @returns The rebuild summary.
6156
+ * @example
6157
+ * await runBuild();
6158
+ */
6159
+ runBuild() {
6160
+ return ctx.require(buildPlugin).run();
6161
+ },
6162
+ /**
6163
+ * Render the reload line and push a browser reload after a rebuild.
6164
+ *
6165
+ * @param info - The changed file plus the rebuild's page count and duration.
6166
+ * @example
6167
+ * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 });
6168
+ */
6169
+ onReloaded(info) {
6170
+ ctx.state.render.reload(info);
6171
+ hub.reloadAll();
6172
+ },
6173
+ /**
6174
+ * Render a rebuild failure (the dev loop keeps running).
6175
+ *
6176
+ * @param error - The thrown rebuild error.
6177
+ * @example
6178
+ * onError(new Error("boom"));
6179
+ */
6180
+ onError(error) {
6181
+ ctx.state.render.error("rebuild failed", error);
6182
+ }
6183
+ });
6184
+ const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, () => rebuilder.schedule(dir)));
6185
+ ctx.state.render.serverReady({
6186
+ local: `http://localhost:${port}`,
6187
+ network: ctx.state.networkUrl(port),
6188
+ watching: ctx.config.watchDirs
6189
+ });
6190
+ return installSignalTeardown(() => {
6191
+ rebuilder.cancel();
6192
+ for (const watcher of watchers) watcher.close();
6193
+ server.stop();
6194
+ });
5858
6195
  }
5859
6196
  //#endregion
5860
- //#region src/plugins/spa/types.ts
5861
- var types_exports$8 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
5862
- /** Allowed hook names — single source of truth for fail-fast validation. */
5863
- const COMPONENT_HOOK_NAMES = [
5864
- "onCreate",
5865
- "onMount",
5866
- "onNavStart",
5867
- "onNavEnd",
5868
- "onUnMount",
5869
- "onDestroy"
5870
- ];
5871
- //#endregion
5872
- //#region src/plugins/spa/components.ts
5873
- /** Error prefix for spa fail-fast failures (spec/11 Part-3). */
5874
- const ERROR_PREFIX$2 = "[web]";
5875
- /** The set of legal hook names, frozen for O(1) membership checks. */
5876
- const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
6197
+ //#region src/plugins/cli/preview.ts
5877
6198
  /**
5878
- * Create a validated component definition. Validates hook names at registration
5879
- * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
5880
- * each provided hook is a function.
6199
+ * @file cli plugin static preview server for the built `dist/`. Exposes a PURE,
6200
+ * server-agnostic clean-URL resolver (`resolveCleanUrl`) unit-tested without a
6201
+ * socket plus `runPreviewServer`, which serves the resolved files via `Bun.serve`
6202
+ * the way Cloudflare Pages does (trailing slash → `index.html`, extensionless →
6203
+ * `<path>/index.html`, a miss → the nearest `404.html`). No reload injection.
6204
+ */
6205
+ /**
6206
+ * Strip leading `../` segments (after `normalize`) so a request can never escape the
6207
+ * served root via path traversal.
5881
6208
  *
5882
- * @param name - Unique component name.
5883
- * @param hooks - Lifecycle hook implementations.
5884
- * @returns A `ComponentDef` ready to `register`.
5885
- * @throws {Error} If `name` is empty, any hook key is not in
5886
- * `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
6209
+ * @param pathname - The decoded request pathname.
6210
+ * @returns The traversal-safe relative path.
5887
6211
  * @example
5888
- * const counter = createComponent("counter", {
5889
- * onMount({ el }) { el.textContent = "0"; }
5890
- * });
6212
+ * safePath("../../etc/passwd"); // "etc/passwd"
5891
6213
  */
5892
- function createComponent(name, hooks) {
5893
- 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)`);
5894
- for (const key of Object.keys(hooks)) {
5895
- 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(", ")}`);
5896
- 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`);
5897
- }
5898
- return {
5899
- name,
5900
- hooks
5901
- };
6214
+ function safePath(pathname) {
6215
+ return path.normalize(pathname).replace(/^(\.\.(?:[/\\]|$))+/, "");
5902
6216
  }
5903
6217
  /**
5904
- * Extracts the page data payload from the inline `script#__DATA__` element.
5905
- * Returns an empty object when the script is absent, empty, or invalid JSON.
6218
+ * The default {@link FileProbe} backed by `node:fs.statSync` — a single stat (no
6219
+ * exists+stat race) that swallows the ENOENT thrown for a missing path.
5906
6220
  *
5907
- * @param doc - The document to read the data script from.
5908
- * @returns The parsed page data, or `{}` when unavailable.
6221
+ * @param filePath - Candidate on-disk path.
6222
+ * @returns Whether it resolves to a regular file.
5909
6223
  * @example
5910
- * const data = extractPageData(document);
6224
+ * statIsFile("/dist/index.html");
5911
6225
  */
5912
- function extractPageData(doc) {
5913
- const text = doc.querySelector("script#__DATA__")?.textContent;
5914
- if (!text) return {};
6226
+ function statIsFile(filePath) {
5915
6227
  try {
5916
- return JSON.parse(text);
6228
+ return statSync(filePath).isFile();
5917
6229
  } catch {
5918
- return {};
6230
+ return false;
5919
6231
  }
5920
6232
  }
5921
6233
  /**
5922
- * Builds a live component instance bound to an element.
6234
+ * Resolve a request pathname to a real file under `rootDir`, mirroring Cloudflare
6235
+ * Pages clean URLs. Pure and server-agnostic: it touches the filesystem only through
6236
+ * the injected {@link FileProbe}, so it is unit-tested without a server. A trailing
6237
+ * slash maps to `index.html`; an extensionless path tries the file then
6238
+ * `<path>/index.html`; a miss climbs toward the root for the nearest `404.html`
6239
+ * (served with status `404`).
5923
6240
  *
5924
- * @param definition - The component definition.
5925
- * @param element - The element the instance binds to.
6241
+ * @param rootDir - The absolute (or cwd-relative) build output directory.
6242
+ * @param pathname - The decoded request pathname (always starts with `/`).
6243
+ * @param isFile - File-existence probe (defaults to {@link statIsFile}).
6244
+ * @returns The resolved file + status (file `null` when not even a `404.html` exists).
6245
+ * @example
6246
+ * resolveCleanUrl("dist", "/about/", path => set.has(path));
6247
+ */
6248
+ function resolveCleanUrl(rootDir, pathname, isFile = statIsFile) {
6249
+ const relative = safePath(pathname);
6250
+ const base = path.join(rootDir, relative);
6251
+ const candidates = pathname.endsWith("/") ? [path.join(base, "index.html")] : [base, path.join(base, "index.html")];
6252
+ for (const candidate of candidates) if (isFile(candidate)) return {
6253
+ file: candidate,
6254
+ status: 200
6255
+ };
6256
+ const segments = path.join(rootDir, relative).split(path.sep).filter(Boolean);
6257
+ for (let depth = segments.length; depth >= 1; depth--) {
6258
+ const candidate = path.join(segments.slice(0, depth).join(path.sep), "404.html");
6259
+ if (isFile(candidate)) return {
6260
+ file: candidate,
6261
+ status: 404
6262
+ };
6263
+ }
6264
+ const root = path.join(rootDir, "404.html");
6265
+ return isFile(root) ? {
6266
+ file: root,
6267
+ status: 404
6268
+ } : {
6269
+ file: null,
6270
+ status: 404
6271
+ };
6272
+ }
6273
+ /**
6274
+ * Build the request handler for the preview server: resolves each request via
6275
+ * {@link resolveCleanUrl} and serves the file (no reload injection, mirroring prod).
6276
+ *
6277
+ * @param ctx - The cli plugin context (provides `config` + `state.fileResponse`).
6278
+ * @returns The `fetch` handler passed to the static server.
6279
+ * @example
6280
+ * const handler = createPreviewHandler(ctx);
6281
+ */
6282
+ function createPreviewHandler(ctx) {
6283
+ return (request) => {
6284
+ const pathname = decodeURIComponent(new URL(request.url).pathname);
6285
+ const resolved = resolveCleanUrl(ctx.config.outDir, pathname);
6286
+ if (resolved.file === null) return new Response("Not Found", { status: 404 });
6287
+ return ctx.state.fileResponse(resolved.file, resolved.status);
6288
+ };
6289
+ }
6290
+ /**
6291
+ * Run the static preview server for the built `dist/`. Serves files resolved by
6292
+ * {@link resolveCleanUrl} via the injectable static-server seam — with no live-reload
6293
+ * injection, mirroring production. Renders the server-ready panel and resolves on
6294
+ * SIGINT/SIGTERM.
6295
+ *
6296
+ * @param ctx - The cli plugin context (provides `config` + `state` seams).
6297
+ * @param port - The port to bind to.
6298
+ * @returns Resolves once the server has been torn down by a termination signal.
6299
+ * @example
6300
+ * await runPreviewServer(ctx, 4173);
6301
+ */
6302
+ function runPreviewServer(ctx, port) {
6303
+ const server = ctx.state.serveStatic({
6304
+ port,
6305
+ fetch: createPreviewHandler(ctx)
6306
+ });
6307
+ ctx.state.render.serverReady({
6308
+ local: `http://localhost:${port}`,
6309
+ network: ctx.state.networkUrl(port)
6310
+ });
6311
+ return installSignalTeardown(() => server.stop());
6312
+ }
6313
+ //#endregion
6314
+ //#region src/plugins/cli/api.ts
6315
+ /**
6316
+ * @file cli plugin — API factory (build · serve · preview · deploy), the cli plugin
6317
+ * context type, and config-only `validateConfig`. The four closures are wiring-thin:
6318
+ * each renders the Panel header, then delegates to `build`/`deploy` (via `require`)
6319
+ * or to the server modules. Live build/deploy progress arrives through hooks (in
6320
+ * `index.ts`), so the methods' return values come from the awaited `run()` results.
6321
+ */
6322
+ /** Lowest valid TCP port. */
6323
+ const MIN_PORT = 1;
6324
+ /** Highest valid TCP port. */
6325
+ const MAX_PORT = 65535;
6326
+ /**
6327
+ * Validate the resolved cli config during `onInit` (config-only — no resource
6328
+ * allocation, per spec/06 §2). Throws `ERR_CLI_CONFIG` (`[web] cli: …`) when `port`
6329
+ * is not an integer in 1–65535, `outDir`/`notFoundFile` are not non-empty strings,
6330
+ * `watchDirs` is not a non-empty string array, or `debounceMs` is negative.
6331
+ *
6332
+ * @param config - The resolved cli configuration to validate.
6333
+ * @throws {Error} `ERR_CLI_CONFIG` when any field is invalid.
6334
+ * @example
6335
+ * validateConfig({ outDir: "dist", port: 4173, watchDirs: ["content"], debounceMs: 150, notFoundFile: "404.html", liveReload: true });
6336
+ */
6337
+ function validateConfig(config) {
6338
+ 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).`);
6339
+ 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").`);
6340
+ 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").`);
6341
+ 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"]).`);
6342
+ 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).`);
6343
+ }
6344
+ /**
6345
+ * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
6346
+ * Each method renders `state.render.header(<command>)` first, then does its work;
6347
+ * live progress is rendered by the hooks wired in `index.ts`, so each method's
6348
+ * return value comes from the awaited `build.run()` / `deploy.run()` result.
6349
+ *
6350
+ * @param ctx - Plugin context (provides `require`, `state`, `config`).
6351
+ * @returns The {@link Api} surface mounted at `app.cli`.
6352
+ * @example
6353
+ * const api = createApi(ctx);
6354
+ * await api.build();
6355
+ */
6356
+ function createApi$1(ctx) {
6357
+ return {
6358
+ /**
6359
+ * Run the SSG build and (by default) assert the not-found page exists.
6360
+ *
6361
+ * @param options - Optional `assertNotFound` toggle (default `true`).
6362
+ * @returns The build summary (`outDir`, `pageCount`, `durationMs`).
6363
+ * @throws {Error} `ERR_CLI_NOT_FOUND` when the not-found page is missing and asserted.
6364
+ * @example
6365
+ * await api.build();
6366
+ */
6367
+ async build(options = {}) {
6368
+ const { assertNotFound = true } = options;
6369
+ ctx.state.render.header("build");
6370
+ const result = await ctx.require(buildPlugin).run();
6371
+ const page = path.join(ctx.config.outDir, ctx.config.notFoundFile);
6372
+ if (assertNotFound && !existsSync(page)) {
6373
+ ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
6374
+ 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.`);
6375
+ }
6376
+ return result;
6377
+ },
6378
+ /**
6379
+ * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
6380
+ * `watchDirs`, debounced rebuild + reload. Resolves on SIGINT/SIGTERM.
6381
+ *
6382
+ * @param options - Optional port override (defaults to `config.port`).
6383
+ * @returns Resolves once the server has been torn down.
6384
+ * @example
6385
+ * await api.serve({ port: 3000 });
6386
+ */
6387
+ serve(options = {}) {
6388
+ const { port = ctx.config.port } = options;
6389
+ ctx.state.render.header("serve");
6390
+ return runDevServer(ctx, port);
6391
+ },
6392
+ /**
6393
+ * Static preview of the built `dist/` with CF-Pages clean-URL resolution.
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.preview();
6399
+ */
6400
+ preview(options = {}) {
6401
+ const { port = ctx.config.port } = options;
6402
+ ctx.state.render.header("preview");
6403
+ return runPreviewServer(ctx, port);
6404
+ },
6405
+ /**
6406
+ * Scaffold, then deploy. A y/N confirm is shown only when a human is present (an
6407
+ * interactive TTY, with `CI` unset). Non-interactive runs (CI, or any non-TTY)
6408
+ * skip the prompt and deploy, so the consumer scripts never hang a pipeline.
6409
+ * `options.yes` forces the skip anywhere. An interactive "no" returns
6410
+ * `{ deployed: false, reason: "declined" }`.
6411
+ *
6412
+ * @param options - Optional branch override and `yes` flag.
6413
+ * @returns The deploy outcome (completed details, or `declined` if a TTY user says no).
6414
+ * @example
6415
+ * await api.deploy({ branch: "preview/x", yes: true });
6416
+ */
6417
+ async deploy(options = {}) {
6418
+ const { branch, yes = false } = options;
6419
+ ctx.state.render.header("deploy");
6420
+ await ctx.require(deployPlugin).init({ ci: true });
6421
+ if (process.stdout.isTTY === true && process.env.CI === void 0 && !yes) {
6422
+ if (!await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to cloudflare-pages?`)) {
6423
+ ctx.state.render.warn("deploy skipped");
6424
+ return {
6425
+ deployed: false,
6426
+ reason: "declined"
6427
+ };
6428
+ }
6429
+ } else if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
6430
+ return {
6431
+ deployed: true,
6432
+ ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
6433
+ };
6434
+ }
6435
+ };
6436
+ }
6437
+ //#endregion
6438
+ //#region src/plugins/cli/defaults.ts
6439
+ /**
6440
+ * Default cli configuration. Consumers override individual fields via
6441
+ * `pluginConfigs.cli`. Declared as a typed const (no inline `as` assertion).
6442
+ *
6443
+ * @example
6444
+ * ```ts
6445
+ * createPlugin("cli", { config: defaultConfig });
6446
+ * ```
6447
+ */
6448
+ const defaultConfig = {
6449
+ outDir: "dist",
6450
+ port: 4173,
6451
+ watchDirs: ["content", "src"],
6452
+ debounceMs: 150,
6453
+ notFoundFile: "404.html",
6454
+ liveReload: true
6455
+ };
6456
+ //#endregion
6457
+ //#region src/plugins/cli/network.ts
6458
+ /**
6459
+ * @file cli plugin — LAN network-URL derivation. Picks the first non-internal IPv4
6460
+ * from `node:os` `networkInterfaces()` to render the "Network" URL in the
6461
+ * server-ready panel. The interface source is injectable so it is unit-testable.
6462
+ */
6463
+ /**
6464
+ * Whether an interface entry is a usable, non-internal IPv4 address.
6465
+ *
6466
+ * @param entry - One interface address entry.
6467
+ * @returns `true` when it is an external IPv4 address.
6468
+ * @example
6469
+ * isExternalIPv4({ address: "10.0.0.2", family: "IPv4", internal: false }); // true
6470
+ */
6471
+ function isExternalIPv4(entry) {
6472
+ return !entry.internal && (entry.family === "IPv4" || entry.family === 4);
6473
+ }
6474
+ /**
6475
+ * Pick the first non-internal IPv4 address from the interface source, or `null` when
6476
+ * none exists (offline / loopback-only).
6477
+ *
6478
+ * @param source - Interface source (defaults to `node:os` `networkInterfaces`).
6479
+ * @returns The first external IPv4 address string, or `null`.
6480
+ * @example
6481
+ * const ip = lanAddress();
6482
+ */
6483
+ function lanAddress(source = networkInterfaces) {
6484
+ for (const entries of Object.values(source())) for (const entry of entries ?? []) if (isExternalIPv4(entry)) return entry.address;
6485
+ return null;
6486
+ }
6487
+ /**
6488
+ * Build the LAN URL (`http://<ip>:<port>`) for the server-ready panel, or `null`
6489
+ * when no non-internal IPv4 is available.
6490
+ *
6491
+ * @param port - The port the server is bound to.
6492
+ * @param source - Interface source (defaults to `node:os` `networkInterfaces`).
6493
+ * @returns The `http://<ip>:<port>` URL, or `null` when offline.
6494
+ * @example
6495
+ * networkUrl(4173); // "http://192.168.1.10:4173" or null
6496
+ */
6497
+ function networkUrl(port, source = networkInterfaces) {
6498
+ const ip = lanAddress(source);
6499
+ return ip === null ? null : `http://${ip}:${port}`;
6500
+ }
6501
+ //#endregion
6502
+ //#region src/plugins/cli/render/ansi.ts
6503
+ /**
6504
+ * @file cli plugin — TTY/NO_COLOR-aware ANSI color + box-drawing helpers shared by
6505
+ * the Panel renderer. Modeled on the legacy `scripts/_log.ts`: color and box glyphs
6506
+ * are emitted only on a real TTY with `NO_COLOR` unset; otherwise plain ASCII so
6507
+ * CI logs and pipes stay readable.
6508
+ */
6509
+ /** The ANSI escape byte (ESC, `0x1b`), built so no literal control char is in source. */
6510
+ const ESC = String.fromCodePoint(27);
6511
+ /** ANSI SGR codes used by the Panel renderer (each prefixed with the ESC byte). */
6512
+ const ANSI = {
6513
+ reset: `${ESC}[0m`,
6514
+ bold: `${ESC}[1m`,
6515
+ dim: `${ESC}[2m`,
6516
+ red: `${ESC}[31m`,
6517
+ green: `${ESC}[32m`,
6518
+ yellow: `${ESC}[33m`,
6519
+ blue: `${ESC}[34m`,
6520
+ magenta: `${ESC}[35m`,
6521
+ cyan: `${ESC}[36m`,
6522
+ gray: `${ESC}[90m`
6523
+ };
6524
+ /** Unicode rounded box glyphs used when output is a color-capable TTY. */
6525
+ const UNICODE_BOX = {
6526
+ topLeft: "╭",
6527
+ topRight: "╮",
6528
+ bottomLeft: "╰",
6529
+ bottomRight: "╯",
6530
+ horizontal: "─",
6531
+ vertical: "│"
6532
+ };
6533
+ /** ASCII box glyphs used when output is piped/CI (plain mode). */
6534
+ const ASCII_BOX = {
6535
+ topLeft: "+",
6536
+ topRight: "+",
6537
+ bottomLeft: "+",
6538
+ bottomRight: "+",
6539
+ horizontal: "-",
6540
+ vertical: "|"
6541
+ };
6542
+ /**
6543
+ * Matches every ANSI SGR escape sequence (used to measure visible width). Built from
6544
+ * the {@link ESC} byte so no literal control character appears in the source regex.
6545
+ */
6546
+ const ANSI_PATTERN = new RegExp(String.raw`${ESC}\[[0-9;]*m`, "g");
6547
+ /**
6548
+ * Whether ANSI color/box glyphs should be emitted: a TTY stream with `NO_COLOR`
6549
+ * unset. Reads `process.stdout.isTTY` and `process.env.NO_COLOR` by default so the
6550
+ * renderer auto-degrades in CI and pipes, exactly like the legacy logger.
6551
+ *
6552
+ * @param stream - Stream to probe for `isTTY` (defaults to `process.stdout`).
6553
+ * @param noColor - The `NO_COLOR` value (defaults to `process.env.NO_COLOR`).
6554
+ * @returns `true` when color should be used.
6555
+ * @example
6556
+ * supportsColor(); // true in an interactive terminal
6557
+ */
6558
+ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR) {
6559
+ return stream.isTTY === true && noColor === void 0;
6560
+ }
6561
+ /**
6562
+ * Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
6563
+ *
6564
+ * @param color - Whether color/Unicode output is enabled.
6565
+ * @returns The matching {@link BoxGlyphs} set.
6566
+ * @example
6567
+ * const glyphs = boxGlyphs(supportsColor());
6568
+ */
6569
+ function boxGlyphs(color) {
6570
+ return color ? UNICODE_BOX : ASCII_BOX;
6571
+ }
6572
+ /**
6573
+ * The visible width of a string, ignoring any ANSI escape sequences it contains.
6574
+ *
6575
+ * @param text - The (possibly colorized) text to measure.
6576
+ * @returns The number of visible characters.
6577
+ * @example
6578
+ * visibleWidth(`${ANSI.red}hi${ANSI.reset}`); // 2
6579
+ */
6580
+ function visibleWidth(text) {
6581
+ return text.replaceAll(ANSI_PATTERN, "").length;
6582
+ }
6583
+ /**
6584
+ * Build a {@link Palette} bound to a fixed color mode. When `color` is `false` every
6585
+ * helper returns its input unchanged, so the same render code path produces plain
6586
+ * output in CI/pipes.
6587
+ *
6588
+ * @param color - Whether color is enabled (typically `supportsColor()`).
6589
+ * @returns The bound color palette.
6590
+ * @example
6591
+ * const palette = makePalette(supportsColor());
6592
+ * const line = palette.green("done");
6593
+ */
6594
+ function makePalette(color) {
6595
+ return {
6596
+ enabled: color,
6597
+ /**
6598
+ * Wrap text in the given ANSI code (returns it unchanged when color is off).
6599
+ *
6600
+ * @param code - The ANSI SGR code to apply.
6601
+ * @param text - The text to colorize.
6602
+ * @returns The colorized (or unchanged) text.
6603
+ * @example
6604
+ * palette.paint(ANSI.green, "ok");
6605
+ */
6606
+ paint(code, text) {
6607
+ return color ? `${code}${text}${ANSI.reset}` : text;
6608
+ },
6609
+ /**
6610
+ * Bold the given text (no-op in plain mode).
6611
+ *
6612
+ * @param text - The text to embolden.
6613
+ * @returns The bold (or unchanged) text.
6614
+ * @example
6615
+ * palette.bold("title");
6616
+ */
6617
+ bold(text) {
6618
+ return this.paint(ANSI.bold, text);
6619
+ },
6620
+ /**
6621
+ * Dim the given text (no-op in plain mode).
6622
+ *
6623
+ * @param text - The text to dim.
6624
+ * @returns The dim (or unchanged) text.
6625
+ * @example
6626
+ * palette.dim("· 84ms");
6627
+ */
6628
+ dim(text) {
6629
+ return this.paint(ANSI.dim, text);
6630
+ },
6631
+ /**
6632
+ * Color the given text green (no-op in plain mode).
6633
+ *
6634
+ * @param text - The text to colorize.
6635
+ * @returns The green (or unchanged) text.
6636
+ * @example
6637
+ * palette.green("✓");
6638
+ */
6639
+ green(text) {
6640
+ return this.paint(ANSI.green, text);
6641
+ },
6642
+ /**
6643
+ * Color the given text yellow (no-op in plain mode).
6644
+ *
6645
+ * @param text - The text to colorize.
6646
+ * @returns The yellow (or unchanged) text.
6647
+ * @example
6648
+ * palette.yellow("~");
6649
+ */
6650
+ yellow(text) {
6651
+ return this.paint(ANSI.yellow, text);
6652
+ },
6653
+ /**
6654
+ * Color the given text red (no-op in plain mode).
6655
+ *
6656
+ * @param text - The text to colorize.
6657
+ * @returns The red (or unchanged) text.
6658
+ * @example
6659
+ * palette.red("✗");
6660
+ */
6661
+ red(text) {
6662
+ return this.paint(ANSI.red, text);
6663
+ },
6664
+ /**
6665
+ * Color the given text cyan (no-op in plain mode).
6666
+ *
6667
+ * @param text - The text to colorize.
6668
+ * @returns The cyan (or unchanged) text.
6669
+ * @example
6670
+ * palette.cyan("http://localhost:4173");
6671
+ */
6672
+ cyan(text) {
6673
+ return this.paint(ANSI.cyan, text);
6674
+ }
6675
+ };
6676
+ }
6677
+ /**
6678
+ * Frame a list of already-rendered content lines in a box, padding each line to the
6679
+ * width of the widest visible line. Uses Unicode borders when `color` is enabled and
6680
+ * ASCII otherwise. Visible width ignores embedded ANSI so colored lines align.
6681
+ *
6682
+ * @param lines - The content lines (may contain ANSI color codes).
6683
+ * @param color - Whether to use Unicode borders (and assume color-capable output).
6684
+ * @returns The boxed lines (top border, content rows, bottom border).
6685
+ * @example
6686
+ * box(["Local: http://localhost:4173"], true);
6687
+ */
6688
+ function box(lines, color) {
6689
+ const glyphs = boxGlyphs(color);
6690
+ const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
6691
+ const horizontal = glyphs.horizontal.repeat(inner + 2);
6692
+ const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
6693
+ const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
6694
+ return [
6695
+ top,
6696
+ ...lines.map((line) => {
6697
+ const pad = " ".repeat(inner - visibleWidth(line));
6698
+ return `${glyphs.vertical} ${line}${pad} ${glyphs.vertical}`;
6699
+ }),
6700
+ bottom
6701
+ ];
6702
+ }
6703
+ //#endregion
6704
+ //#region src/plugins/cli/render/panel.ts
6705
+ /** Per-command label shown in the header badge beside the logo. */
6706
+ const COMMAND_LABEL = {
6707
+ build: "build",
6708
+ serve: "serve · dev",
6709
+ preview: "preview",
6710
+ deploy: "deploy"
6711
+ };
6712
+ /**
6713
+ * Render one human-readable duration suffix (e.g. `· 84ms`).
6714
+ *
6715
+ * @param palette - The active color palette.
6716
+ * @param durationMs - Duration in milliseconds (omitted → empty string).
6717
+ * @returns The dim `· Nms` suffix, or `""` when no duration is given.
6718
+ * @example
6719
+ * durationSuffix(palette, 84); // " · 84ms" (dim)
6720
+ */
6721
+ function durationSuffix(palette, durationMs) {
6722
+ if (durationMs === void 0) return "";
6723
+ return ` ${palette.dim(`· ${durationMs}ms`)}`;
6724
+ }
6725
+ /**
6726
+ * Create the Panel {@link CliRenderer}. Output is written through the injected sink
6727
+ * (default `console.log`/`console.error`) and colorized only when color is enabled,
6728
+ * so the identical render path yields box-drawn color panels on a TTY and plain
6729
+ * ASCII lines in CI/pipes.
6730
+ *
6731
+ * @param options - Optional sinks + a color override (see {@link PanelOptions}).
6732
+ * @returns The renderer mounted on `state.render` and driven by the API + hooks.
6733
+ * @example
6734
+ * const render = createPanelRenderer();
6735
+ * render.header("build");
6736
+ */
6737
+ function createPanelRenderer(options = {}) {
6738
+ const write = options.write ?? ((line) => console.log(line));
6739
+ const writeError = options.writeError ?? ((line) => console.error(line));
6740
+ const color = options.color ?? supportsColor();
6741
+ const palette = makePalette(color);
6742
+ /**
6743
+ * Write each line of a multi-line block through the stdout sink.
6744
+ *
6745
+ * @param lines - The rendered lines to write in order.
6746
+ * @example
6747
+ * writeBlock(["a", "b"]);
6748
+ */
6749
+ const writeBlock = (lines) => {
6750
+ for (const line of lines) write(line);
6751
+ };
6752
+ return {
6753
+ /**
6754
+ * Render the boxed `MOKU WEB` logo + command label.
6755
+ *
6756
+ * @param command - The command being run, shown beside the logo.
6757
+ * @example
6758
+ * render.header("serve");
6759
+ */
6760
+ header(command) {
6761
+ writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
6762
+ },
6763
+ /**
6764
+ * Render a live per-phase row from a `build:phase` event.
6765
+ *
6766
+ * @param phase - The `build:phase` payload.
6767
+ * @example
6768
+ * render.phase({ phase: "pages", status: "done", durationMs: 12 });
6769
+ */
6770
+ phase(phase) {
6771
+ const done = phase.status === "done";
6772
+ write(` ${done ? palette.green("✓") : palette.dim("•")} ${done ? phase.phase : palette.dim(phase.phase)}${durationSuffix(palette, phase.durationMs)}`);
6773
+ },
6774
+ /**
6775
+ * Render the BUILD summary block from a `build:complete` event.
6776
+ *
6777
+ * @param summary - The `build:complete` payload.
6778
+ * @example
6779
+ * render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
6780
+ */
6781
+ built(summary) {
6782
+ const pages = palette.bold(String(summary.pageCount));
6783
+ writeBlock(box([
6784
+ `${palette.green("✓")} ${palette.bold("BUILD")} complete`,
6785
+ `${palette.dim("pages")} ${pages}`,
6786
+ `${palette.dim("time")} ${summary.durationMs}ms`,
6787
+ `${palette.dim("out")} ${summary.outDir}/`
6788
+ ], color));
6789
+ },
6790
+ /**
6791
+ * Render the bordered server-ready panel (Local / Network URLs + watched dirs).
6792
+ *
6793
+ * @param info - Local/Network URLs and optionally the watched directories.
6794
+ * @example
6795
+ * render.serverReady({ local: "http://localhost:4173", network: null });
6796
+ */
6797
+ serverReady(info) {
6798
+ 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")}`];
6799
+ if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
6800
+ writeBlock(box(lines, color));
6801
+ },
6802
+ /**
6803
+ * Render the post-rebuild line ("~ file" + "✓ rebuilt N pages · Xms · reloaded").
6804
+ *
6805
+ * @param info - The changed file plus the rebuild's page count and duration.
6806
+ * @example
6807
+ * render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
6808
+ */
6809
+ reload(info) {
6810
+ write(` ${palette.yellow("~")} ${info.file}`);
6811
+ write(` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · browser reloaded`)}`);
6812
+ },
6813
+ /**
6814
+ * Render the deploy result panel from a `deploy:complete` event.
6815
+ *
6816
+ * @param result - The `deploy:complete` payload.
6817
+ * @example
6818
+ * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
6819
+ */
6820
+ deployed(result) {
6821
+ writeBlock(box([
6822
+ `${palette.green("✓")} ${palette.bold("DEPLOYED")}`,
6823
+ `${palette.dim("url")} ${palette.cyan(result.url)}`,
6824
+ `${palette.dim("branch")} ${result.branch}`,
6825
+ `${palette.dim("id")} ${result.deploymentId}`,
6826
+ `${palette.dim("time")} ${result.durationMs}ms`
6827
+ ], color));
6828
+ },
6829
+ /**
6830
+ * Render a neutral informational line.
6831
+ *
6832
+ * @param message - The line to print.
6833
+ * @example
6834
+ * render.info("watching for changes…");
6835
+ */
6836
+ info(message) {
6837
+ write(` ${palette.cyan("›")} ${message}`);
6838
+ },
6839
+ /**
6840
+ * Render a warning line (to stderr).
6841
+ *
6842
+ * @param message - The warning to print.
6843
+ * @example
6844
+ * render.warn("deploy skipped");
6845
+ */
6846
+ warn(message) {
6847
+ writeError(` ${palette.yellow("⚠")} ${message}`);
6848
+ },
6849
+ /**
6850
+ * Render an error line (to stderr), optionally with a cause.
6851
+ *
6852
+ * @param message - The error summary to print.
6853
+ * @param cause - Optional underlying error/value to print beneath the summary.
6854
+ * @example
6855
+ * render.error("build failed", err);
6856
+ */
6857
+ error(message, cause) {
6858
+ writeError(` ${palette.red("✗")} ${message}`);
6859
+ if (cause !== void 0) writeError(String(cause));
6860
+ }
6861
+ };
6862
+ }
6863
+ //#endregion
6864
+ //#region src/plugins/cli/state.ts
6865
+ /**
6866
+ * @file cli plugin — state factory. Wires the default injectable seams: the Panel
6867
+ * renderer, a stdin y/N `confirm`, the `Date.now` clock, a recursive `node:fs.watch`
6868
+ * wrapper, and the `Bun.serve`/`Bun.file` static-server seams (resolved lazily, like
6869
+ * deploy's `defaultSpawn`, so a non-Bun runtime fails coded rather than as a raw
6870
+ * `TypeError` and tests can inject fakes). Unit tests swap any of these.
6871
+ */
6872
+ /**
6873
+ * Resolve the `Bun` runtime global, or `undefined` when not running under Bun.
6874
+ *
6875
+ * @returns The Bun runtime, or `undefined`.
6876
+ * @example
6877
+ * const bun = bunRuntime();
6878
+ */
6879
+ function bunRuntime() {
6880
+ return globalThis.Bun;
6881
+ }
6882
+ /**
6883
+ * Default static-server factory — resolves `Bun.serve` lazily at call time so the
6884
+ * server is only required when a long-running command actually starts one.
6885
+ *
6886
+ * @param options - Port + `fetch` handler (see {@link ServeStaticFunction}).
6887
+ * @returns The running server handle.
6888
+ * @throws {Error} When no Bun runtime is available to serve.
6889
+ * @example
6890
+ * defaultServeStatic({ port: 4173, fetch: () => new Response("ok") });
6891
+ */
6892
+ const defaultServeStatic = (options) => {
6893
+ const runtime = bunRuntime();
6894
+ 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.");
6895
+ return runtime.serve(options);
6896
+ };
6897
+ /**
6898
+ * Default file-response factory — `new Response(Bun.file(path), { status })`. Resolves
6899
+ * `Bun.file` lazily so it is only required when a request is actually served.
6900
+ *
6901
+ * @param path - Absolute on-disk path to stream.
6902
+ * @param status - HTTP status for the response.
6903
+ * @returns The file `Response`.
6904
+ * @throws {Error} When no Bun runtime is available to read the file.
6905
+ * @example
6906
+ * defaultFileResponse("/dist/index.html", 200);
6907
+ */
6908
+ const defaultFileResponse = (path, status) => {
6909
+ const runtime = bunRuntime();
6910
+ 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.");
6911
+ return new Response(runtime.file(path), { status });
6912
+ };
6913
+ /**
6914
+ * Default stdin y/N prompt. Reads a single line from `process.stdin` via
6915
+ * `node:readline` and resolves `true` only on an explicit `y`/`yes` (default `No`).
6916
+ * Tests inject a canned answer so no real TTY interaction occurs.
6917
+ *
6918
+ * @param question - The yes/no question to display.
6919
+ * @returns Resolves `true` when the user answered yes.
6920
+ * @example
6921
+ * await defaultConfirm("Deploy dist/?");
6922
+ */
6923
+ function defaultConfirm(question) {
6924
+ return new Promise((resolve) => {
6925
+ const readline = createInterface({
6926
+ input: process.stdin,
6927
+ output: process.stdout
6928
+ });
6929
+ readline.question(`${question} [y/N] `, (answer) => {
6930
+ readline.close();
6931
+ resolve(/^y(es)?$/i.test(answer.trim()));
6932
+ });
6933
+ });
6934
+ }
6935
+ /**
6936
+ * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
6937
+ * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
6938
+ * FS watch is registered.
6939
+ *
6940
+ * @param dir - The directory to watch recursively.
6941
+ * @param onChange - Invoked on any change beneath `dir`.
6942
+ * @returns A handle whose `close()` detaches the watcher.
6943
+ * @example
6944
+ * const handle = defaultWatch("content", () => rebuild());
6945
+ */
6946
+ function defaultWatch(dir, onChange) {
6947
+ const watcher = watch(dir, { recursive: true }, () => onChange());
6948
+ return {
6949
+ /**
6950
+ * Detach the underlying `node:fs.watch` listener.
6951
+ *
6952
+ * @example
6953
+ * handle.close();
6954
+ */
6955
+ close() {
6956
+ watcher.close();
6957
+ } };
6958
+ }
6959
+ /**
6960
+ * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
6961
+ * reads `node:os` interfaces while tests can inject a deterministic value.
6962
+ *
6963
+ * @param port - The port the server is bound to.
6964
+ * @returns The `http://<ip>:<port>` URL, or `null` when offline.
6965
+ * @example
6966
+ * defaultNetworkUrl(4173);
6967
+ */
6968
+ function defaultNetworkUrl(port) {
6969
+ return networkUrl(port);
6970
+ }
6971
+ /**
6972
+ * Create the initial cli plugin state with the production seams wired. Every field is
6973
+ * an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
6974
+ * and `networkUrl`) so commands run under unit tests without real sockets/FS/TTY.
6975
+ *
6976
+ * @param _ctx - Minimal context with global + config (unused — state is static).
6977
+ * @param _ctx.global - Global plugin registry.
6978
+ * @param _ctx.config - Resolved plugin configuration.
6979
+ * @returns The initial cli state.
6980
+ * @example
6981
+ * const state = createState({ global: {}, config });
6982
+ */
6983
+ function createState$1(_ctx) {
6984
+ return {
6985
+ render: createPanelRenderer(),
6986
+ confirm: defaultConfirm,
6987
+ clock: Date.now,
6988
+ watch: defaultWatch,
6989
+ serveStatic: defaultServeStatic,
6990
+ fileResponse: defaultFileResponse,
6991
+ networkUrl: defaultNetworkUrl
6992
+ };
6993
+ }
6994
+ //#endregion
6995
+ //#region src/plugins/cli/index.ts
6996
+ /**
6997
+ * @file cli — Complex plugin (wiring harness only). Developer CLI:
6998
+ * build · serve · preview · deploy, with the boxed Panel renderer.
6999
+ * Depends: build, deploy. Listens: build:phase, build:complete, deploy:complete.
7000
+ * @see README.md
7001
+ */
7002
+ /**
7003
+ * cli plugin — the node-only developer CLI for `@moku-labs/web`. Mounts exactly four
7004
+ * methods at `app.cli` (`build`/`serve`/`preview`/`deploy`), each rendering through
7005
+ * the boxed Panel UI. Live build/deploy progress rides on hooks over the `build` and
7006
+ * `deploy` plugins' events; there is no argv parser and no `run()` dispatcher — the
7007
+ * consumer drives it from one thin script per command.
7008
+ *
7009
+ * @example Compose the CLI in a consumer app (node-only)
7010
+ * ```ts
7011
+ * import { buildPlugin, cliPlugin, createApp, deployPlugin } from "@moku-labs/web";
7012
+ *
7013
+ * const app = createApp({
7014
+ * plugins: [buildPlugin, deployPlugin, cliPlugin],
7015
+ * pluginConfigs: { cli: { outDir: "dist", port: 4173, watchDirs: ["content", "src"] } }
7016
+ * });
7017
+ * await app.start();
7018
+ * await app.cli.build();
7019
+ * ```
7020
+ */
7021
+ const cliPlugin = createPlugin$1("cli", {
7022
+ config: defaultConfig,
7023
+ depends: [buildPlugin, deployPlugin],
7024
+ createState: createState$1,
7025
+ onInit: (ctx) => validateConfig(ctx.config),
7026
+ hooks: (ctx) => ({
7027
+ "build:phase": (p) => ctx.state.render.phase(p),
7028
+ "build:complete": (p) => ctx.state.render.built(p),
7029
+ "deploy:complete": (p) => ctx.state.render.deployed(p)
7030
+ }),
7031
+ api: createApi$1
7032
+ });
7033
+ //#endregion
7034
+ //#region src/plugins/spa/api.ts
7035
+ /**
7036
+ * Creates the spa plugin API surface (registration / control). All methods
7037
+ * delegate to the single shared kernel stored in `ctx.state.kernel`.
7038
+ *
7039
+ * @param ctx - Plugin context exposing `state` (kernel) and `log`.
7040
+ * @returns The {@link SpaApi} surface mounted at `app.spa`.
7041
+ * @example
7042
+ * const api = createApi(ctx);
7043
+ * api.register(counter);
7044
+ */
7045
+ function createApi(ctx) {
7046
+ return {
7047
+ /**
7048
+ * Register a component definition (last-registered-wins); warns on collision.
7049
+ *
7050
+ * @param component - The component definition created via `createComponent`.
7051
+ * @example
7052
+ * app.spa.register(counter);
7053
+ */
7054
+ register(component) {
7055
+ if (ctx.state.registeredComponents.has(component.name)) ctx.log.warn("spa:component-collision", { name: component.name });
7056
+ ctx.state.kernel?.register(component);
7057
+ },
7058
+ /**
7059
+ * Programmatically navigate to a path (client runtime; no-op without a DOM).
7060
+ *
7061
+ * @param path - Target path (pathname, optionally with search/hash).
7062
+ * @example
7063
+ * app.spa.navigate("/about");
7064
+ */
7065
+ navigate(path) {
7066
+ ctx.state.kernel?.processNav(path);
7067
+ },
7068
+ /**
7069
+ * Read the current resolved URL.
7070
+ *
7071
+ * @returns The current pathname + search.
7072
+ * @example
7073
+ * app.spa.current();
7074
+ */
7075
+ current() {
7076
+ return ctx.state.currentUrl;
7077
+ }
7078
+ };
7079
+ }
7080
+ //#endregion
7081
+ //#region src/plugins/spa/events.ts
7082
+ /**
7083
+ * Declares the spa plugin's events. Extracted from index.ts to keep the wiring
7084
+ * file under the line budget.
7085
+ *
7086
+ * @param register - The event registration function supplied by the kernel.
7087
+ * @returns The map of spa event descriptors.
7088
+ * @example
7089
+ * const events = spaEvents(register);
7090
+ */
7091
+ function spaEvents(register) {
7092
+ return {
7093
+ "spa:navigate": register("A navigation has been intercepted and is starting."),
7094
+ "spa:navigated": register("The swap completed and the new URL is active."),
7095
+ "spa:component-mount": register("A component instance attached to an element."),
7096
+ "spa:component-unmount": register("A component instance detached from an element.")
7097
+ };
7098
+ }
7099
+ //#endregion
7100
+ //#region src/plugins/spa/types.ts
7101
+ var types_exports$9 = /* @__PURE__ */ __exportAll({ COMPONENT_HOOK_NAMES: () => COMPONENT_HOOK_NAMES });
7102
+ /** Allowed hook names — single source of truth for fail-fast validation. */
7103
+ const COMPONENT_HOOK_NAMES = [
7104
+ "onCreate",
7105
+ "onMount",
7106
+ "onNavStart",
7107
+ "onNavEnd",
7108
+ "onUnMount",
7109
+ "onDestroy"
7110
+ ];
7111
+ //#endregion
7112
+ //#region src/plugins/spa/components.ts
7113
+ /** Error prefix for spa fail-fast failures (spec/11 Part-3). */
7114
+ const ERROR_PREFIX$2 = "[web]";
7115
+ /** The set of legal hook names, frozen for O(1) membership checks. */
7116
+ const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
7117
+ /**
7118
+ * Create a validated component definition. Validates hook names at registration
7119
+ * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
7120
+ * each provided hook is a function.
7121
+ *
7122
+ * @param name - Unique component name.
7123
+ * @param hooks - Lifecycle hook implementations.
7124
+ * @returns A `ComponentDef` ready to `register`.
7125
+ * @throws {Error} If `name` is empty, any hook key is not in
7126
+ * `COMPONENT_HOOK_NAMES`, or any provided hook value is not a function.
7127
+ * @example
7128
+ * const counter = createComponent("counter", {
7129
+ * onMount({ el }) { el.textContent = "0"; }
7130
+ * });
7131
+ */
7132
+ function createComponent(name, hooks) {
7133
+ 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)`);
7134
+ for (const key of Object.keys(hooks)) {
7135
+ 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(", ")}`);
7136
+ 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`);
7137
+ }
7138
+ return {
7139
+ name,
7140
+ hooks
7141
+ };
7142
+ }
7143
+ /**
7144
+ * Extracts the page data payload from the inline `script#__DATA__` element.
7145
+ * Returns an empty object when the script is absent, empty, or invalid JSON.
7146
+ *
7147
+ * @param doc - The document to read the data script from.
7148
+ * @returns The parsed page data, or `{}` when unavailable.
7149
+ * @example
7150
+ * const data = extractPageData(document);
7151
+ */
7152
+ function extractPageData(doc) {
7153
+ const text = doc.querySelector("script#__DATA__")?.textContent;
7154
+ if (!text) return {};
7155
+ try {
7156
+ return JSON.parse(text);
7157
+ } catch {
7158
+ return {};
7159
+ }
7160
+ }
7161
+ /**
7162
+ * Builds a live component instance bound to an element.
7163
+ *
7164
+ * @param definition - The component definition.
7165
+ * @param element - The element the instance binds to.
5926
7166
  * @param persistent - Whether the instance survives navigation.
5927
7167
  * @returns The constructed (not-yet-mounted) instance.
5928
7168
  * @example
@@ -6924,27 +8164,30 @@ const spaPlugin = createPlugin$1("spa", {
6924
8164
  //#region src/plugins/build/types.ts
6925
8165
  var types_exports = /* @__PURE__ */ __exportAll({});
6926
8166
  //#endregion
6927
- //#region src/plugins/content/types.ts
8167
+ //#region src/plugins/cli/types.ts
6928
8168
  var types_exports$1 = /* @__PURE__ */ __exportAll({});
6929
8169
  //#endregion
6930
- //#region src/plugins/data/types.ts
8170
+ //#region src/plugins/content/types.ts
6931
8171
  var types_exports$2 = /* @__PURE__ */ __exportAll({});
6932
8172
  //#endregion
6933
- //#region src/plugins/deploy/types.ts
8173
+ //#region src/plugins/data/types.ts
6934
8174
  var types_exports$3 = /* @__PURE__ */ __exportAll({});
6935
8175
  //#endregion
6936
- //#region src/plugins/env/types.ts
8176
+ //#region src/plugins/deploy/types.ts
6937
8177
  var types_exports$4 = /* @__PURE__ */ __exportAll({});
6938
8178
  //#endregion
6939
- //#region src/plugins/head/types.ts
8179
+ //#region src/plugins/env/types.ts
6940
8180
  var types_exports$5 = /* @__PURE__ */ __exportAll({});
6941
8181
  //#endregion
6942
- //#region src/plugins/log/types.ts
8182
+ //#region src/plugins/head/types.ts
6943
8183
  var types_exports$6 = /* @__PURE__ */ __exportAll({});
6944
8184
  //#endregion
6945
- //#region src/plugins/router/types.ts
8185
+ //#region src/plugins/log/types.ts
6946
8186
  var types_exports$7 = /* @__PURE__ */ __exportAll({});
6947
8187
  //#endregion
8188
+ //#region src/plugins/router/types.ts
8189
+ var types_exports$8 = /* @__PURE__ */ __exportAll({});
8190
+ //#endregion
6948
8191
  //#region src/plugins/env/providers.ts
6949
8192
  /**
6950
8193
  * @file env plugin — built-in providers: dotenv, processEnv, cloudflareBindings.
@@ -7161,4 +8404,4 @@ const createApp = core.createApp;
7161
8404
  */
7162
8405
  const createPlugin = core.createPlugin;
7163
8406
  //#endregion
7164
- export { types_exports as Build, types_exports$1 as Content, types_exports$2 as Data, types_exports$3 as Deploy, types_exports$4 as Env, types_exports$5 as Head, types_exports$6 as Log, types_exports$7 as Router, types_exports$8 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };
8407
+ export { types_exports as Build, types_exports$1 as Cli, types_exports$2 as Content, types_exports$3 as Data, types_exports$4 as Deploy, types_exports$5 as Env, types_exports$6 as Head, types_exports$7 as Log, types_exports$8 as Router, types_exports$9 as Spa, browserEnv, buildArticleHead, buildPlugin, canonical, cliPlugin, cloudflareBindings, contentPlugin, createApp, createComponent, createPlugin, dataPlugin, defineRoutes, deployPlugin, dotenv, envPlugin, feedLink, headPlugin, hreflang, i18nPlugin, jsonLd, logPlugin, meta, og, processEnv, route, routerPlugin, sitePlugin, spaPlugin, twitter };