@moku-labs/web 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -411,6 +411,40 @@ function isPlainObject$1(value) {
411
411
  return typeof value === "object" && value !== null && !Array.isArray(value);
412
412
  }
413
413
  /**
414
+ * Tests whether `actual` is an array that recursively matches every element of
415
+ * the `partial` array (element-wise, with equal length).
416
+ *
417
+ * @param actual - The value to test against (must be an array of equal length).
418
+ * @param partial - The expected partial array shape.
419
+ * @returns `true` when `actual` is an equal-length array matching `partial` element-wise.
420
+ * @example
421
+ * ```ts
422
+ * matchesPartialArray([1, 2], [1, 2]); // true
423
+ * matchesPartialArray([1], [1, 2]); // false (length mismatch)
424
+ * ```
425
+ */
426
+ function matchesPartialArray(actual, partial) {
427
+ if (!Array.isArray(actual) || actual.length !== partial.length) return false;
428
+ return partial.every((value, index) => matchesPartial(actual[index], value));
429
+ }
430
+ /**
431
+ * Tests whether `actual` is a plain object in which every `partial` key
432
+ * recursively matches (extra `actual` keys are ignored).
433
+ *
434
+ * @param actual - The value to test against (must be a plain object).
435
+ * @param partial - The expected partial object shape.
436
+ * @returns `true` when every `partial` key exists in `actual` and matches recursively.
437
+ * @example
438
+ * ```ts
439
+ * matchesPartialObject({ a: 1, b: 2 }, { a: 1 }); // true
440
+ * matchesPartialObject({ a: 1 }, { b: 1 }); // false (missing key)
441
+ * ```
442
+ */
443
+ function matchesPartialObject(actual, partial) {
444
+ if (!isPlainObject$1(actual)) return false;
445
+ return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
446
+ }
447
+ /**
414
448
  * Subset-equality matcher: is `partial` a recursive subset of `actual`?
415
449
  *
416
450
  * Fast path via `Object.is` (covers identical primitives/references and
@@ -429,14 +463,8 @@ function isPlainObject$1(value) {
429
463
  */
430
464
  function matchesPartial(actual, partial) {
431
465
  if (Object.is(actual, partial)) return true;
432
- if (Array.isArray(partial)) {
433
- if (!Array.isArray(actual) || actual.length !== partial.length) return false;
434
- return partial.every((value, index) => matchesPartial(actual[index], value));
435
- }
436
- if (isPlainObject$1(partial)) {
437
- if (!isPlainObject$1(actual)) return false;
438
- return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
439
- }
466
+ if (Array.isArray(partial)) return matchesPartialArray(actual, partial);
467
+ if (isPlainObject$1(partial)) return matchesPartialObject(actual, partial);
440
468
  return false;
441
469
  }
442
470
  /**
@@ -1950,6 +1978,25 @@ function hasValidLangCount(pattern) {
1950
1978
  return (pattern.match(/\{lang:\?\}/g) ?? []).length <= MAX_LANG_SEGMENTS;
1951
1979
  }
1952
1980
  /**
1981
+ * Assert a single route's pattern is well-formed, throwing the `[web]`-prefixed
1982
+ * error for the first failure: not rooted at `/`, unbalanced `{…}` braces, or
1983
+ * more than one `{lang:?}` segment. Extracted from {@link validateRoutes} so the
1984
+ * loop body stays flat.
1985
+ *
1986
+ * @param name - The route name key, surfaced in any error message.
1987
+ * @param pattern - The route's user pattern to validate.
1988
+ * @throws {Error} When the pattern is malformed.
1989
+ * @example
1990
+ * ```ts
1991
+ * assertRouteValid("home", "/{slug}/");
1992
+ * ```
1993
+ */
1994
+ function assertRouteValid(name, pattern) {
1995
+ if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1996
+ if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1997
+ if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1998
+ }
1999
+ /**
1953
2000
  * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
1954
2001
  * naming the offending route/pattern on any failure: empty map, a pattern not
1955
2002
  * starting with `/`, unbalanced `{…}` braces, or more than one `{lang:?}` segment.
@@ -1964,12 +2011,7 @@ function hasValidLangCount(pattern) {
1964
2011
  function validateRoutes(routes) {
1965
2012
  const names = Object.keys(routes);
1966
2013
  if (names.length === 0) throw new Error(`${ERROR_PREFIX$11}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
1967
- for (const name of names) {
1968
- const pattern = routes[name]?.pattern ?? "";
1969
- if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1970
- if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1971
- if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1972
- }
2014
+ for (const name of names) assertRouteValid(name, routes[name]?.pattern ?? "");
1973
2015
  }
1974
2016
  /**
1975
2017
  * Convert a user pattern to a `URLPattern` source string, in a `withLang` or
@@ -3551,6 +3593,26 @@ async function generateFeeds(ctx) {
3551
3593
  /** Conventional source directories scanned for static images to copy. */
3552
3594
  const IMAGE_SOURCE_DIRECTORIES = ["public", "static"];
3553
3595
  /**
3596
+ * Copy one source directory into the assets target, skipping it when the
3597
+ * directory is absent or empty. The target is created lazily so an all-empty
3598
+ * build never touches `outDir`.
3599
+ *
3600
+ * @param directory - The candidate source directory to copy.
3601
+ * @param target - The assets directory inside `outDir` to copy into.
3602
+ * @returns `true` when the directory was copied, `false` when skipped.
3603
+ * @example
3604
+ * ```ts
3605
+ * const didCopy = await copyImageDirectory("public", "dist/assets");
3606
+ * ```
3607
+ */
3608
+ async function copyImageDirectory(directory, target) {
3609
+ if (!(0, node_fs.existsSync)(directory)) return false;
3610
+ if ((await (0, node_fs_promises.readdir)(directory)).length === 0) return false;
3611
+ await (0, node_fs_promises.mkdir)(target, { recursive: true });
3612
+ await (0, node_fs_promises.cp)(directory, target, { recursive: true });
3613
+ return true;
3614
+ }
3615
+ /**
3554
3616
  * Copies static image directories into the output directory. No-op when
3555
3617
  * `config.images` is false or no source directory exists. Image bytes are copied
3556
3618
  * verbatim (optimization is a no-op hook point) — build only sequences I/O.
@@ -3571,13 +3633,7 @@ async function processImages(ctx, options = {}) {
3571
3633
  const sourceDirectories = options.sourceDirectories ?? IMAGE_SOURCE_DIRECTORIES;
3572
3634
  const target = node_path$1.default.join(ctx.config.outDir, "assets");
3573
3635
  let copied = 0;
3574
- for (const directory of sourceDirectories) {
3575
- if (!(0, node_fs.existsSync)(directory)) continue;
3576
- if ((await (0, node_fs_promises.readdir)(directory)).length === 0) continue;
3577
- await (0, node_fs_promises.mkdir)(target, { recursive: true });
3578
- await (0, node_fs_promises.cp)(directory, target, { recursive: true });
3579
- copied += 1;
3580
- }
3636
+ for (const directory of sourceDirectories) if (await copyImageDirectory(directory, target)) copied += 1;
3581
3637
  ctx.log.debug("build:images", { copied });
3582
3638
  return copied;
3583
3639
  }
@@ -3625,6 +3681,37 @@ function pairRoutes(router) {
3625
3681
  return pairs;
3626
3682
  }
3627
3683
  /**
3684
+ * Compute the single bare→default redirect job for one generated parameter set, or
3685
+ * `null` when no redirect is needed. The BARE (locale-less) path is derived by
3686
+ * stripping `lang`. `generate()` supplies `lang` (pages need it), so using `params`
3687
+ * as-is makes the "bare" URL already carry the locale → target === bareUrl → NO
3688
+ * redirect is ever emitted. Removing `lang` yields the real lang-less file/URL
3689
+ * (`/`, `/about/`, `/{slug}/`) that must redirect to the default-locale URL.
3690
+ *
3691
+ * @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
3692
+ * @param raw - One raw parameter set from `generate()` (may be `null`/`undefined`).
3693
+ * @param defaultLocale - The default locale to redirect bare paths to.
3694
+ * @returns The `{ file, target }` redirect job, or `null` when no redirect is needed.
3695
+ * @example
3696
+ * ```ts
3697
+ * redirectJobFor(entry, { lang: "en", slug: "hello" }, "en");
3698
+ * ```
3699
+ */
3700
+ function redirectJobFor(entry, raw, defaultLocale) {
3701
+ const bareParams = { ...raw ?? {} };
3702
+ delete bareParams.lang;
3703
+ const file = entry.toFile(bareParams);
3704
+ const target = entry.toUrl({
3705
+ ...bareParams,
3706
+ lang: defaultLocale
3707
+ });
3708
+ if (!(target !== entry.toUrl(bareParams))) return null;
3709
+ return {
3710
+ file,
3711
+ target
3712
+ };
3713
+ }
3714
+ /**
3628
3715
  * Expand one route into bare→default redirect jobs for the default locale. Uses
3629
3716
  * `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
3630
3717
  * only when the bare file path differs from the default-locale URL (i.e. the route
@@ -3649,17 +3736,8 @@ async function expandRedirects(definition, entry, defaultLocale, ctx) {
3649
3736
  const parameterSets = definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
3650
3737
  const jobs = [];
3651
3738
  for (const raw of parameterSets) {
3652
- const bareParams = { ...raw ?? {} };
3653
- delete bareParams.lang;
3654
- const file = entry.toFile(bareParams);
3655
- const target = entry.toUrl({
3656
- ...bareParams,
3657
- lang: defaultLocale
3658
- });
3659
- if (target !== entry.toUrl(bareParams)) jobs.push({
3660
- file,
3661
- target
3662
- });
3739
+ const job = redirectJobFor(entry, raw, defaultLocale);
3740
+ if (job) jobs.push(job);
3663
3741
  }
3664
3742
  return jobs;
3665
3743
  }
@@ -3820,6 +3898,54 @@ function ogHash(input, template, fontsHash) {
3820
3898
  ].join("|");
3821
3899
  return (0, node_crypto.createHash)("sha256").update(payload).digest("hex");
3822
3900
  }
3901
+ /** Weight applied to fonts with no explicit `weight`. */
3902
+ const DEFAULT_FONT_WEIGHT = 400;
3903
+ /** Style applied to fonts with no explicit `style`. */
3904
+ const DEFAULT_FONT_STYLE = "normal";
3905
+ /** Family name given to the single fallback font scanned from `fontDir`. */
3906
+ const FALLBACK_FONT_NAME = "OG";
3907
+ /**
3908
+ * Load each explicitly-configured OG font, reading its `path` to a Buffer once and
3909
+ * filling in the default weight/style when omitted.
3910
+ *
3911
+ * @param fonts - The explicit named fonts to load.
3912
+ * @returns The loaded Satori font entries, in configuration order.
3913
+ * @example
3914
+ * ```ts
3915
+ * await loadExplicitFonts([{ name: "Inter", path: "./Inter.ttf" }]);
3916
+ * ```
3917
+ */
3918
+ async function loadExplicitFonts(fonts) {
3919
+ return Promise.all(fonts.map(async (font) => ({
3920
+ name: font.name,
3921
+ data: await (0, node_fs_promises.readFile)(font.path),
3922
+ weight: font.weight ?? DEFAULT_FONT_WEIGHT,
3923
+ style: font.style ?? DEFAULT_FONT_STYLE
3924
+ })));
3925
+ }
3926
+ /**
3927
+ * Scan `fontDir` for the first recognized font file and load it as a single
3928
+ * 400/normal fallback; yields an empty list when the directory or a usable file
3929
+ * is missing.
3930
+ *
3931
+ * @param fontDir - Directory scanned for a fallback font file.
3932
+ * @returns The single fallback font, or an empty list when none is available.
3933
+ * @example
3934
+ * ```ts
3935
+ * await scanFallbackFont("./fonts");
3936
+ * ```
3937
+ */
3938
+ async function scanFallbackFont(fontDir) {
3939
+ if (!(0, node_fs.existsSync)(fontDir)) return [];
3940
+ const file = (await (0, node_fs_promises.readdir)(fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3941
+ if (!file) return [];
3942
+ return [{
3943
+ name: FALLBACK_FONT_NAME,
3944
+ data: await (0, node_fs_promises.readFile)(node_path.default.join(fontDir, file)),
3945
+ weight: DEFAULT_FONT_WEIGHT,
3946
+ style: DEFAULT_FONT_STYLE
3947
+ }];
3948
+ }
3823
3949
  /**
3824
3950
  * Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
3825
3951
  * `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
@@ -3836,21 +3962,8 @@ function ogHash(input, template, fontsHash) {
3836
3962
  * ```
3837
3963
  */
3838
3964
  async function loadFonts(og) {
3839
- if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
3840
- name: font.name,
3841
- data: await (0, node_fs_promises.readFile)(font.path),
3842
- weight: font.weight ?? 400,
3843
- style: font.style ?? "normal"
3844
- })));
3845
- if (!(0, node_fs.existsSync)(og.fontDir)) return [];
3846
- const file = (await (0, node_fs_promises.readdir)(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3847
- if (!file) return [];
3848
- return [{
3849
- name: "OG",
3850
- data: await (0, node_fs_promises.readFile)(node_path.default.join(og.fontDir, file)),
3851
- weight: 400,
3852
- style: "normal"
3853
- }];
3965
+ if (og.fonts !== void 0 && og.fonts.length > 0) return loadExplicitFonts(og.fonts ?? []);
3966
+ return scanFallbackFont(og.fontDir);
3854
3967
  }
3855
3968
  /**
3856
3969
  * The built-in default OG card — a centered title on a dark background. Used when
@@ -4440,6 +4553,47 @@ function fillTemplate(template, parts) {
4440
4553
  return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4441
4554
  }
4442
4555
  /**
4556
+ * Resolve the compiled entry for a manifest definition, asserting the router
4557
+ * invariant that `manifest()` and `entries()` stay in sync (see {@link makeEntryMap}).
4558
+ *
4559
+ * @param byPattern - The pattern→compiled-`TypedRoute` index.
4560
+ * @param definition - The route definition from the manifest.
4561
+ * @returns The compiled `TypedRoute` for the definition's pattern.
4562
+ * @throws {Error} When no compiled entry exists for the definition's pattern.
4563
+ * @example
4564
+ * ```ts
4565
+ * const entry = resolveEntry(byPattern, definition);
4566
+ * ```
4567
+ */
4568
+ function resolveEntry(byPattern, definition) {
4569
+ const entry = byPattern.get(definition.pattern);
4570
+ if (!entry) throw new Error(`[web] build.pages: no router entry for pattern "${definition.pattern}" — router.manifest() and router.entries() are out of sync.`);
4571
+ return entry;
4572
+ }
4573
+ /**
4574
+ * Produce the param sets one route generates for a single locale: the route's
4575
+ * `.generate(ctx)` result when present, else a single empty-params instance. The
4576
+ * generate context is the spec `{ locale, require, has }`, so a `.generate()` handler
4577
+ * pulls sibling APIs the spec way.
4578
+ *
4579
+ * @param definition - The route definition from the manifest.
4580
+ * @param locale - The active locale to generate param sets for.
4581
+ * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4582
+ * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`).
4583
+ * @example
4584
+ * ```ts
4585
+ * const paramSets = await generateParamSets(def, "en", ctx);
4586
+ * ```
4587
+ */
4588
+ async function generateParameterSets(definition, locale, ctx) {
4589
+ const generateContext = {
4590
+ locale,
4591
+ require: ctx.require,
4592
+ has: ctx.has
4593
+ };
4594
+ return definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
4595
+ }
4596
+ /**
4443
4597
  * Expand one route definition into its concrete page instances across all locales,
4444
4598
  * using `generate?.(ctx)` when present (else a single empty-params instance per
4445
4599
  * locale). The generate context is the spec `{ locale, require, has }`, so a
@@ -4456,18 +4610,12 @@ function fillTemplate(template, parts) {
4456
4610
  * ```
4457
4611
  */
4458
4612
  async function expandRoute(definition, locales, byPattern, ctx) {
4459
- const entry = byPattern.get(definition.pattern);
4460
- if (!entry) throw new Error(`[web] build.pages: no router entry for pattern "${definition.pattern}" — router.manifest() and router.entries() are out of sync.`);
4613
+ const entry = resolveEntry(byPattern, definition);
4461
4614
  const { name } = entry;
4462
4615
  const instances = [];
4463
4616
  for (const locale of locales) {
4464
- const generateContext = {
4465
- locale,
4466
- require: ctx.require,
4467
- has: ctx.has
4468
- };
4469
- const generated = definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
4470
- for (const raw of generated) instances.push({
4617
+ const parameterSets = await generateParameterSets(definition, locale, ctx);
4618
+ for (const raw of parameterSets) instances.push({
4471
4619
  definition,
4472
4620
  entry,
4473
4621
  name,
@@ -5033,6 +5181,23 @@ function resetRun(ctx) {
5033
5181
  ctx.state.runId = `${Date.now()}-${(0, node_crypto.randomUUID)()}`;
5034
5182
  }
5035
5183
  /**
5184
+ * Report each rejected outcome from a settled output batch as a `build:outputs`
5185
+ * error, leaving fulfilled outcomes untouched (failures are isolated, not fatal).
5186
+ *
5187
+ * @param ctx - The phase context (used to log rejections).
5188
+ * @param settled - The settled results from the `runOutputs` task batch.
5189
+ * @example
5190
+ * ```ts
5191
+ * reportOutputFailures(ctx, await Promise.allSettled(tasks));
5192
+ * ```
5193
+ */
5194
+ function reportOutputFailures(ctx, settled) {
5195
+ for (const outcome of settled) {
5196
+ if (outcome.status !== "rejected") continue;
5197
+ ctx.log.error("build:outputs", { reason: String(outcome.reason) });
5198
+ }
5199
+ }
5200
+ /**
5036
5201
  * Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
5037
5202
  * concurrently, each gated by its config flag (or, for `public`, the presence of the
5038
5203
  * source dir), isolated with `Promise.allSettled` so one failure does not lose the
@@ -5057,8 +5222,7 @@ async function runOutputs(ctx) {
5057
5222
  if ((0, node_fs.existsSync)(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
5058
5223
  if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
5059
5224
  if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
5060
- const settled = await Promise.allSettled(tasks);
5061
- for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
5225
+ reportOutputFailures(ctx, await Promise.allSettled(tasks));
5062
5226
  }
5063
5227
  /**
5064
5228
  * Executes the full SSG pipeline for one run: clean → bundle → content/images →
@@ -5773,6 +5937,21 @@ async function writeScaffolding(input) {
5773
5937
  return result;
5774
5938
  }
5775
5939
  /**
5940
+ * Create the parent directory then write the scaffold file to disk.
5941
+ *
5942
+ * @param cwd - Project root the file is written into.
5943
+ * @param relativePath - Path (relative to cwd) of the scaffold file.
5944
+ * @param contents - The content to write.
5945
+ * @returns Resolves once the file (and any missing parents) exist on disk.
5946
+ * @example
5947
+ * await writeScaffoldFile(process.cwd(), "wrangler.jsonc", contents);
5948
+ */
5949
+ async function writeScaffoldFile(cwd, relativePath, contents) {
5950
+ const absolutePath = node_path$1.default.join(cwd, relativePath);
5951
+ await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(absolutePath), { recursive: true });
5952
+ await (0, node_fs_promises.writeFile)(absolutePath, contents, "utf8");
5953
+ }
5954
+ /**
5776
5955
  * Reconcile one scaffold file against disk: in check mode record drift, otherwise
5777
5956
  * skip an existing file or write a new one. Mutates the shared {@link InitResult}.
5778
5957
  *
@@ -5789,24 +5968,21 @@ async function writeScaffolding(input) {
5789
5968
  */
5790
5969
  async function reconcile(input) {
5791
5970
  const { relativePath, expected, existing, cwd, check, result } = input;
5971
+ const fileExists = existing !== null;
5972
+ const fileDrifted = fileExists && existing !== expected;
5792
5973
  if (check) {
5793
- if (existing !== null && existing !== expected) result.drifted.push(relativePath);
5974
+ if (fileDrifted) result.drifted.push(relativePath);
5794
5975
  return;
5795
5976
  }
5796
- if (existing !== null) {
5977
+ if (fileExists) {
5797
5978
  result.skipped.push(relativePath);
5798
5979
  return;
5799
5980
  }
5800
- await (0, node_fs_promises.mkdir)(node_path$1.default.dirname(node_path$1.default.join(cwd, relativePath)), { recursive: true });
5801
- await (0, node_fs_promises.writeFile)(node_path$1.default.join(cwd, relativePath), expected, "utf8");
5981
+ await writeScaffoldFile(cwd, relativePath, expected);
5802
5982
  result.written.push(relativePath);
5803
5983
  }
5804
5984
  //#endregion
5805
5985
  //#region src/plugins/deploy/preflight.ts
5806
- /**
5807
- * @file deploy plugin — preflight validators (cheap → expensive), run in order
5808
- * and short-circuiting on the first failure.
5809
- */
5810
5986
  /** Error prefix for deploy preflight failures (spec/11 Part-3). */
5811
5987
  const ERROR_PREFIX$5 = "[web] deploy";
5812
5988
  /** Cloudflare Pages free-tier file-count limit. */
@@ -5833,6 +6009,27 @@ function resolveFileLimit(env = process.env) {
5833
6009
  return Math.min(parsed, PAID_TIER_FILE_LIMIT);
5834
6010
  }
5835
6011
  /**
6012
+ * Fold one directory entry into the running walk: queue subdirectories, and for
6013
+ * files bump the count and flag the path when it breaches the per-file size cap.
6014
+ *
6015
+ * @param entry - The directory entry being visited.
6016
+ * @param entryPath - The absolute path of `entry`.
6017
+ * @param result - The running walk aggregate, mutated in place.
6018
+ * @param stack - The pending-directory stack, pushed to for subdirectories.
6019
+ * @returns Resolves once the entry has been folded into `result`/`stack`.
6020
+ * @example
6021
+ * await inspectEntry(entry, "/project/dist/app.js", result, stack);
6022
+ */
6023
+ async function inspectEntry(entry, entryPath, result, stack) {
6024
+ if (entry.isDirectory()) {
6025
+ stack.push(entryPath);
6026
+ return;
6027
+ }
6028
+ if (!entry.isFile()) return;
6029
+ result.fileCount += 1;
6030
+ if ((await (0, node_fs_promises.stat)(entryPath)).size > MAX_FILE_SIZE_BYTES) result.oversizePath = entryPath;
6031
+ }
6032
+ /**
5836
6033
  * Recursively walk `dir`, counting files and flagging the first file over the
5837
6034
  * per-file size cap. Short-circuits once an oversize file is found.
5838
6035
  *
@@ -5847,20 +6044,13 @@ async function inspectOutdir(dir) {
5847
6044
  oversizePath: null
5848
6045
  };
5849
6046
  const stack = [dir];
5850
- while (stack.length > 0) {
6047
+ while (stack.length > 0 && result.oversizePath === null) {
5851
6048
  const current = stack.pop();
5852
6049
  if (current === void 0) break;
5853
6050
  const entries = await (0, node_fs_promises.readdir)(current, { withFileTypes: true });
5854
6051
  for (const entry of entries) {
5855
- const entryPath = node_path$1.default.join(current, entry.name);
5856
- if (entry.isDirectory()) stack.push(entryPath);
5857
- else if (entry.isFile()) {
5858
- result.fileCount += 1;
5859
- if ((await (0, node_fs_promises.stat)(entryPath)).size > MAX_FILE_SIZE_BYTES) {
5860
- result.oversizePath = entryPath;
5861
- return result;
5862
- }
5863
- }
6052
+ await inspectEntry(entry, node_path$1.default.join(current, entry.name), result, stack);
6053
+ if (result.oversizePath !== null) break;
5864
6054
  }
5865
6055
  }
5866
6056
  return result;
@@ -6335,10 +6525,31 @@ function createRebuilder(input) {
6335
6525
  let building = false;
6336
6526
  let dirty = false;
6337
6527
  /**
6338
- * Run the queued rebuild once, then — if a change arrived while it was in flight —
6339
- * re-run exactly once more so no change is dropped. Marks `dirty` (instead of
6340
- * running) when a rebuild is already underway, resetting the in-flight flag when
6341
- * each run settles.
6528
+ * Rebuild repeatedly until no change arrived mid-flight: each pass clears `dirty`,
6529
+ * runs one build, then loops again if a `schedule()` set `dirty` while it ran, so
6530
+ * no change is dropped.
6531
+ *
6532
+ * @returns Resolves once a pass completes with no pending change (errors are routed,
6533
+ * never thrown).
6534
+ * @example
6535
+ * await drainPendingRebuilds();
6536
+ */
6537
+ const drainPendingRebuilds = async () => {
6538
+ do {
6539
+ dirty = false;
6540
+ await runOneRebuild({
6541
+ runBuild: input.runBuild,
6542
+ onReloaded: input.onReloaded,
6543
+ onError: input.onError,
6544
+ file: pendingFile
6545
+ });
6546
+ } while (dirty);
6547
+ };
6548
+ /**
6549
+ * Run the queued rebuild once the debounce timer fires. Marks `dirty` (instead of
6550
+ * running) when a rebuild is already underway, otherwise holds the in-flight flag
6551
+ * across a full {@link drainPendingRebuilds} so concurrent changes coalesce into
6552
+ * exactly one extra re-run.
6342
6553
  *
6343
6554
  * @returns Resolves once the rebuild (and any coalesced re-run) settles (errors are
6344
6555
  * routed, never thrown).
@@ -6352,15 +6563,7 @@ function createRebuilder(input) {
6352
6563
  return;
6353
6564
  }
6354
6565
  building = true;
6355
- do {
6356
- dirty = false;
6357
- await runOneRebuild({
6358
- runBuild: input.runBuild,
6359
- onReloaded: input.onReloaded,
6360
- onError: input.onError,
6361
- file: pendingFile
6362
- });
6363
- } while (dirty);
6566
+ await drainPendingRebuilds();
6364
6567
  building = false;
6365
6568
  };
6366
6569
  return {
@@ -6497,6 +6700,26 @@ function createReloadHub() {
6497
6700
  }
6498
6701
  };
6499
6702
  }
6703
+ /** The content-type sent on rewritten HTML responses (live-reload injection). */
6704
+ const HTML_CONTENT_TYPE = "text/html; charset=utf-8";
6705
+ /**
6706
+ * Re-render a static file response with the live-reload client injected, preserving
6707
+ * the resolved status. Reads the original body to text so {@link injectReloadClient}
6708
+ * can splice the snippet in before `</body>`.
6709
+ *
6710
+ * @param response - The original static file response to rewrite.
6711
+ * @param status - The resolved status to carry onto the rewritten response.
6712
+ * @returns A fresh HTML response containing the injected reload client.
6713
+ * @example
6714
+ * const injected = await injectReloadResponse(fileResponse, 200);
6715
+ */
6716
+ async function injectReloadResponse(response, status) {
6717
+ const html = injectReloadClient(await response.text());
6718
+ return new Response(html, {
6719
+ status,
6720
+ headers: { "content-type": HTML_CONTENT_TYPE }
6721
+ });
6722
+ }
6500
6723
  /**
6501
6724
  * Build the live-reload-aware request handler for the dev server: serves the SSE
6502
6725
  * stream at {@link RELOAD_PATH}, injects the reload client into HTML responses (when
@@ -6516,13 +6739,7 @@ function createDevHandler(ctx, hub) {
6516
6739
  const resolved = resolveCleanUrl(ctx.config.outDir, pathname);
6517
6740
  if (resolved.file === null) return new Response("Not Found", { status: 404 });
6518
6741
  const response = ctx.state.fileResponse(resolved.file, resolved.status);
6519
- if (ctx.config.liveReload && resolved.file.endsWith(".html")) {
6520
- const html = injectReloadClient(await response.text());
6521
- return new Response(html, {
6522
- status: resolved.status,
6523
- headers: { "content-type": "text/html; charset=utf-8" }
6524
- });
6525
- }
6742
+ if (ctx.config.liveReload && resolved.file.endsWith(".html")) return injectReloadResponse(response, resolved.status);
6526
6743
  return response;
6527
6744
  };
6528
6745
  }
@@ -6740,6 +6957,56 @@ function validateConfig(config) {
6740
6957
  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).`);
6741
6958
  }
6742
6959
  /**
6960
+ * Assert the SSG emitted the not-found page, rendering a hint and throwing
6961
+ * `ERR_CLI_NOT_FOUND` when it is missing (CF Pages flips to SPA mode without a
6962
+ * top-level 404). A no-op when the page exists.
6963
+ *
6964
+ * @param ctx - Plugin context (provides `state.render` for the failure hint).
6965
+ * @param page - The absolute path the not-found page is expected at.
6966
+ * @throws {Error} `ERR_CLI_NOT_FOUND` when the not-found page is missing.
6967
+ * @example
6968
+ * assertNotFoundPage(ctx, path.join(ctx.config.outDir, ctx.config.notFoundFile));
6969
+ */
6970
+ function assertNotFoundPage(ctx, page) {
6971
+ if ((0, node_fs.existsSync)(page)) return;
6972
+ ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
6973
+ 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.`);
6974
+ }
6975
+ /**
6976
+ * Whether the deploy confirmation prompt should be shown to a human. True only on
6977
+ * an interactive TTY with `CI` unset and when the caller has not passed `yes`; CI
6978
+ * and non-TTY runs are not prompted so consumer scripts never block a pipeline.
6979
+ *
6980
+ * @param yes - The caller's `yes` flag (forces the prompt to be skipped anywhere).
6981
+ * @returns `true` when a confirmation prompt should be shown, otherwise `false`.
6982
+ * @example
6983
+ * if (shouldPromptDeploy(false)) { ... }
6984
+ */
6985
+ function shouldPromptDeploy(yes) {
6986
+ return process.stdout.isTTY === true && process.env.CI === void 0 && !yes;
6987
+ }
6988
+ /**
6989
+ * Resolve whether a deploy may proceed, handling the human/non-interactive split:
6990
+ * prompts an interactive TTY user (rendering the "skipped" warning on a "no"),
6991
+ * renders the non-interactive notice when no prompt and no `yes`, and otherwise
6992
+ * proceeds silently. Returns whether the caller should run the deploy.
6993
+ *
6994
+ * @param ctx - Plugin context (provides `state.confirm` and `state.render`).
6995
+ * @param yes - The caller's `yes` flag (forces the skip anywhere).
6996
+ * @returns `true` when the deploy should run, `false` when an interactive user declined.
6997
+ * @example
6998
+ * if (!(await confirmDeploy(ctx, false))) return { deployed: false, reason: "declined" };
6999
+ */
7000
+ async function confirmDeploy(ctx, yes) {
7001
+ if (!shouldPromptDeploy(yes)) {
7002
+ if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
7003
+ return true;
7004
+ }
7005
+ const confirmed = await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to cloudflare-pages?`);
7006
+ if (!confirmed) ctx.state.render.warn("deploy skipped");
7007
+ return confirmed;
7008
+ }
7009
+ /**
6743
7010
  * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
6744
7011
  * Each method renders `state.render.header(<command>)` first, then does its work;
6745
7012
  * live progress is rendered by the hooks wired in `index.ts`, so each method's
@@ -6766,11 +7033,7 @@ function createApi$1(ctx) {
6766
7033
  const { assertNotFound = true } = options;
6767
7034
  ctx.state.render.header("build");
6768
7035
  const result = await ctx.require(buildPlugin).run();
6769
- const page = node_path$1.default.join(ctx.config.outDir, ctx.config.notFoundFile);
6770
- if (assertNotFound && !(0, node_fs.existsSync)(page)) {
6771
- ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
6772
- 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.`);
6773
- }
7036
+ if (assertNotFound) assertNotFoundPage(ctx, node_path$1.default.join(ctx.config.outDir, ctx.config.notFoundFile));
6774
7037
  return result;
6775
7038
  },
6776
7039
  /**
@@ -6816,15 +7079,10 @@ function createApi$1(ctx) {
6816
7079
  const { branch, yes = false } = options;
6817
7080
  ctx.state.render.header("deploy");
6818
7081
  await ctx.require(deployPlugin).init({ ci: true });
6819
- if (process.stdout.isTTY === true && process.env.CI === void 0 && !yes) {
6820
- if (!await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to cloudflare-pages?`)) {
6821
- ctx.state.render.warn("deploy skipped");
6822
- return {
6823
- deployed: false,
6824
- reason: "declined"
6825
- };
6826
- }
6827
- } else if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
7082
+ if (!await confirmDeploy(ctx, yes)) return {
7083
+ deployed: false,
7084
+ reason: "declined"
7085
+ };
6828
7086
  return {
6829
7087
  deployed: true,
6830
7088
  ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
@@ -7515,6 +7773,22 @@ const ERROR_PREFIX$2 = "[web]";
7515
7773
  /** The set of legal hook names, frozen for O(1) membership checks. */
7516
7774
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
7517
7775
  /**
7776
+ * Validate a single hook entry: its key must be a known hook name and its value
7777
+ * must be a function. Throws fail-fast on the first violation.
7778
+ *
7779
+ * @param componentName - The owning component name (for error messages).
7780
+ * @param hooks - The hooks object being validated.
7781
+ * @param key - The hook key to validate.
7782
+ * @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
7783
+ * @throws {TypeError} If the hook value is not a function.
7784
+ * @example
7785
+ * validateHookEntry("counter", hooks, "onMount");
7786
+ */
7787
+ function validateHookEntry(componentName, hooks, key) {
7788
+ if (!HOOK_NAME_SET.has(key)) throw new Error(`${ERROR_PREFIX$2} unknown component hook "${key}" on "${componentName}"\n → valid hooks: ${COMPONENT_HOOK_NAMES.join(", ")}`);
7789
+ if (typeof hooks[key] !== "function") throw new TypeError(`${ERROR_PREFIX$2} component hook "${key}" on "${componentName}" must be a function\n → provide a function or omit the hook`);
7790
+ }
7791
+ /**
7518
7792
  * Create a validated component definition. Validates hook names at registration
7519
7793
  * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
7520
7794
  * each provided hook is a function.
@@ -7531,10 +7805,7 @@ const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
7531
7805
  */
7532
7806
  function createComponent(name, hooks) {
7533
7807
  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)`);
7534
- for (const key of Object.keys(hooks)) {
7535
- 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(", ")}`);
7536
- 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`);
7537
- }
7808
+ for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
7538
7809
  return {
7539
7810
  name,
7540
7811
  hooks
@@ -7604,6 +7875,36 @@ function makeContext(element, data) {
7604
7875
  };
7605
7876
  }
7606
7877
  /**
7878
+ * Mounts a single `data-component` element: classifies persistent vs
7879
+ * page-specific, builds the instance, fires `onCreate` then `onMount`, records
7880
+ * it in state, and emits `spa:component-mount`. No-ops if the element is already
7881
+ * mounted, has no component name, or names an unregistered component.
7882
+ *
7883
+ * @param state - The plugin state (registeredComponents + instances).
7884
+ * @param emit - The event emitter for spa:component-mount.
7885
+ * @param swapArea - The swap-region element, or null when none was found.
7886
+ * @param data - The current page data payload.
7887
+ * @param element - The candidate element carrying a `data-component` attribute.
7888
+ * @example
7889
+ * mountElement(state, emit, swapArea, data, element);
7890
+ */
7891
+ function mountElement(state, emit, swapArea, data, element) {
7892
+ if (state.instances.has(element)) return;
7893
+ const name = element.dataset.component;
7894
+ if (!name) return;
7895
+ const definition = state.registeredComponents.get(name);
7896
+ if (!definition) return;
7897
+ const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
7898
+ const ctx = makeContext(element, data);
7899
+ runHook(instance, "onCreate", ctx);
7900
+ runHook(instance, "onMount", ctx);
7901
+ state.instances.set(element, instance);
7902
+ emit("spa:component-mount", {
7903
+ name: definition.name,
7904
+ el: element
7905
+ });
7906
+ }
7907
+ /**
7607
7908
  * Scans the swap region, mounts components for matching `data-component`
7608
7909
  * elements, classifies persistent (outside swap area) vs page-specific (inside),
7609
7910
  * fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
@@ -7619,22 +7920,7 @@ function scanAndMount(state, emit, swapSelector) {
7619
7920
  if (typeof document === "undefined") return;
7620
7921
  const swapArea = document.querySelector(swapSelector);
7621
7922
  const data = extractPageData(document);
7622
- for (const element of document.querySelectorAll("[data-component]")) {
7623
- if (state.instances.has(element)) continue;
7624
- const name = element.dataset.component;
7625
- if (!name) continue;
7626
- const definition = state.registeredComponents.get(name);
7627
- if (!definition) continue;
7628
- const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
7629
- const ctx = makeContext(element, data);
7630
- runHook(instance, "onCreate", ctx);
7631
- runHook(instance, "onMount", ctx);
7632
- state.instances.set(element, instance);
7633
- emit("spa:component-mount", {
7634
- name: definition.name,
7635
- el: element
7636
- });
7637
- }
7923
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
7638
7924
  }
7639
7925
  /**
7640
7926
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -8105,6 +8391,8 @@ function attachRouter(handlers, navigate) {
8105
8391
  //#region src/plugins/spa/state.ts
8106
8392
  /** Error prefix for spa config-validation failures (spec/11 Part-3). */
8107
8393
  const ERROR_PREFIX$1 = "[web]";
8394
+ /** Last-resort `swapSelector` when neither config nor defaults supply one. */
8395
+ const FALLBACK_SWAP_SELECTOR = "main > section";
8108
8396
  /** Default SPA config (declared as a value — no inline assertion). */
8109
8397
  const defaultSpaConfig = {
8110
8398
  swapSelector: "main > section",
@@ -8142,7 +8430,7 @@ function isValidSelector(selector) {
8142
8430
  * const resolved = resolveSpaConfig({ swapSelector: "main > section" });
8143
8431
  */
8144
8432
  function resolveSpaConfig(config) {
8145
- const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? "main > section";
8433
+ const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
8146
8434
  if (swapSelector.trim() === "") throw new Error(`${ERROR_PREFIX$1} spa.swapSelector must be a non-empty string.\n Set a CSS selector for the page region to swap (e.g. "main > section").`);
8147
8435
  if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
8148
8436
  return {
@@ -8861,6 +9149,35 @@ function pullQuoteTransform(tree) {
8861
9149
  }
8862
9150
  });
8863
9151
  }
9152
+ /** CSS class for the divider wrapper that replaces an `<hr>`. */
9153
+ const SECTION_DIVIDER_CLASS = "section-divider";
9154
+ /** CSS class for the inner ornament span inside the section divider. */
9155
+ const SECTION_DIVIDER_ORNAMENT_CLASS = "section-divider-ornament";
9156
+ /** Glyphs rendered inside the section-divider ornament span. */
9157
+ const SECTION_DIVIDER_ORNAMENT = "***";
9158
+ /**
9159
+ * Rewrite one `<hr>` element in place into an ornamental section divider:
9160
+ * a `<div>` wrapper carrying a single ornament `<span>`.
9161
+ *
9162
+ * @param node - The hast element to rewrite (expected to be an `<hr>`).
9163
+ * @example
9164
+ * ```ts
9165
+ * rewriteHrToDivider(node);
9166
+ * ```
9167
+ */
9168
+ function rewriteHrToDivider(node) {
9169
+ node.tagName = "div";
9170
+ node.properties = { class: SECTION_DIVIDER_CLASS };
9171
+ node.children = [{
9172
+ type: "element",
9173
+ tagName: "span",
9174
+ properties: { class: SECTION_DIVIDER_ORNAMENT_CLASS },
9175
+ children: [{
9176
+ type: "text",
9177
+ value: SECTION_DIVIDER_ORNAMENT
9178
+ }]
9179
+ }];
9180
+ }
8864
9181
  /**
8865
9182
  * Hast transformer rewriting `<hr>` into an ornamental section divider.
8866
9183
  *
@@ -8872,19 +9189,8 @@ function pullQuoteTransform(tree) {
8872
9189
  */
8873
9190
  function sectionDividerTransform(tree) {
8874
9191
  (0, unist_util_visit.visit)(tree, "element", (node) => {
8875
- if (node.tagName === "hr") {
8876
- node.tagName = "div";
8877
- node.properties = { class: "section-divider" };
8878
- node.children = [{
8879
- type: "element",
8880
- tagName: "span",
8881
- properties: { class: "section-divider-ornament" },
8882
- children: [{
8883
- type: "text",
8884
- value: "***"
8885
- }]
8886
- }];
8887
- }
9192
+ if (node.tagName !== "hr") return;
9193
+ rewriteHrToDivider(node);
8888
9194
  });
8889
9195
  }
8890
9196
  /**