@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.mjs CHANGED
@@ -398,6 +398,40 @@ function isPlainObject$1(value) {
398
398
  return typeof value === "object" && value !== null && !Array.isArray(value);
399
399
  }
400
400
  /**
401
+ * Tests whether `actual` is an array that recursively matches every element of
402
+ * the `partial` array (element-wise, with equal length).
403
+ *
404
+ * @param actual - The value to test against (must be an array of equal length).
405
+ * @param partial - The expected partial array shape.
406
+ * @returns `true` when `actual` is an equal-length array matching `partial` element-wise.
407
+ * @example
408
+ * ```ts
409
+ * matchesPartialArray([1, 2], [1, 2]); // true
410
+ * matchesPartialArray([1], [1, 2]); // false (length mismatch)
411
+ * ```
412
+ */
413
+ function matchesPartialArray(actual, partial) {
414
+ if (!Array.isArray(actual) || actual.length !== partial.length) return false;
415
+ return partial.every((value, index) => matchesPartial(actual[index], value));
416
+ }
417
+ /**
418
+ * Tests whether `actual` is a plain object in which every `partial` key
419
+ * recursively matches (extra `actual` keys are ignored).
420
+ *
421
+ * @param actual - The value to test against (must be a plain object).
422
+ * @param partial - The expected partial object shape.
423
+ * @returns `true` when every `partial` key exists in `actual` and matches recursively.
424
+ * @example
425
+ * ```ts
426
+ * matchesPartialObject({ a: 1, b: 2 }, { a: 1 }); // true
427
+ * matchesPartialObject({ a: 1 }, { b: 1 }); // false (missing key)
428
+ * ```
429
+ */
430
+ function matchesPartialObject(actual, partial) {
431
+ if (!isPlainObject$1(actual)) return false;
432
+ return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
433
+ }
434
+ /**
401
435
  * Subset-equality matcher: is `partial` a recursive subset of `actual`?
402
436
  *
403
437
  * Fast path via `Object.is` (covers identical primitives/references and
@@ -416,14 +450,8 @@ function isPlainObject$1(value) {
416
450
  */
417
451
  function matchesPartial(actual, partial) {
418
452
  if (Object.is(actual, partial)) return true;
419
- if (Array.isArray(partial)) {
420
- if (!Array.isArray(actual) || actual.length !== partial.length) return false;
421
- return partial.every((value, index) => matchesPartial(actual[index], value));
422
- }
423
- if (isPlainObject$1(partial)) {
424
- if (!isPlainObject$1(actual)) return false;
425
- return Object.keys(partial).every((key) => key in actual && matchesPartial(actual[key], partial[key]));
426
- }
453
+ if (Array.isArray(partial)) return matchesPartialArray(actual, partial);
454
+ if (isPlainObject$1(partial)) return matchesPartialObject(actual, partial);
427
455
  return false;
428
456
  }
429
457
  /**
@@ -1937,6 +1965,25 @@ function hasValidLangCount(pattern) {
1937
1965
  return (pattern.match(/\{lang:\?\}/g) ?? []).length <= MAX_LANG_SEGMENTS;
1938
1966
  }
1939
1967
  /**
1968
+ * Assert a single route's pattern is well-formed, throwing the `[web]`-prefixed
1969
+ * error for the first failure: not rooted at `/`, unbalanced `{…}` braces, or
1970
+ * more than one `{lang:?}` segment. Extracted from {@link validateRoutes} so the
1971
+ * loop body stays flat.
1972
+ *
1973
+ * @param name - The route name key, surfaced in any error message.
1974
+ * @param pattern - The route's user pattern to validate.
1975
+ * @throws {Error} When the pattern is malformed.
1976
+ * @example
1977
+ * ```ts
1978
+ * assertRouteValid("home", "/{slug}/");
1979
+ * ```
1980
+ */
1981
+ function assertRouteValid(name, pattern) {
1982
+ if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1983
+ if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1984
+ if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1985
+ }
1986
+ /**
1940
1987
  * Validate the route map (fail-fast in `onInit`). Throws with the `[web]` prefix
1941
1988
  * naming the offending route/pattern on any failure: empty map, a pattern not
1942
1989
  * starting with `/`, unbalanced `{…}` braces, or more than one `{lang:?}` segment.
@@ -1951,12 +1998,7 @@ function hasValidLangCount(pattern) {
1951
1998
  function validateRoutes(routes) {
1952
1999
  const names = Object.keys(routes);
1953
2000
  if (names.length === 0) throw new Error(`${ERROR_PREFIX$11}: route map is empty.\n Register at least one route via pluginConfigs.router.routes.`);
1954
- for (const name of names) {
1955
- const pattern = routes[name]?.pattern ?? "";
1956
- if (!isPatternRooted(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern must start with "/" (got "${pattern}").`);
1957
- if (!hasBalancedBraces(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has unbalanced braces ("${pattern}").`);
1958
- if (!hasValidLangCount(pattern)) throw new Error(`${ERROR_PREFIX$11}: route "${name}" pattern has more than one {lang:?} segment ("${pattern}").`);
1959
- }
2001
+ for (const name of names) assertRouteValid(name, routes[name]?.pattern ?? "");
1960
2002
  }
1961
2003
  /**
1962
2004
  * Convert a user pattern to a `URLPattern` source string, in a `withLang` or
@@ -3538,6 +3580,26 @@ async function generateFeeds(ctx) {
3538
3580
  /** Conventional source directories scanned for static images to copy. */
3539
3581
  const IMAGE_SOURCE_DIRECTORIES = ["public", "static"];
3540
3582
  /**
3583
+ * Copy one source directory into the assets target, skipping it when the
3584
+ * directory is absent or empty. The target is created lazily so an all-empty
3585
+ * build never touches `outDir`.
3586
+ *
3587
+ * @param directory - The candidate source directory to copy.
3588
+ * @param target - The assets directory inside `outDir` to copy into.
3589
+ * @returns `true` when the directory was copied, `false` when skipped.
3590
+ * @example
3591
+ * ```ts
3592
+ * const didCopy = await copyImageDirectory("public", "dist/assets");
3593
+ * ```
3594
+ */
3595
+ async function copyImageDirectory(directory, target) {
3596
+ if (!existsSync(directory)) return false;
3597
+ if ((await readdir(directory)).length === 0) return false;
3598
+ await mkdir(target, { recursive: true });
3599
+ await cp(directory, target, { recursive: true });
3600
+ return true;
3601
+ }
3602
+ /**
3541
3603
  * Copies static image directories into the output directory. No-op when
3542
3604
  * `config.images` is false or no source directory exists. Image bytes are copied
3543
3605
  * verbatim (optimization is a no-op hook point) — build only sequences I/O.
@@ -3558,13 +3620,7 @@ async function processImages(ctx, options = {}) {
3558
3620
  const sourceDirectories = options.sourceDirectories ?? IMAGE_SOURCE_DIRECTORIES;
3559
3621
  const target = path.join(ctx.config.outDir, "assets");
3560
3622
  let copied = 0;
3561
- for (const directory of sourceDirectories) {
3562
- if (!existsSync(directory)) continue;
3563
- if ((await readdir(directory)).length === 0) continue;
3564
- await mkdir(target, { recursive: true });
3565
- await cp(directory, target, { recursive: true });
3566
- copied += 1;
3567
- }
3623
+ for (const directory of sourceDirectories) if (await copyImageDirectory(directory, target)) copied += 1;
3568
3624
  ctx.log.debug("build:images", { copied });
3569
3625
  return copied;
3570
3626
  }
@@ -3612,6 +3668,37 @@ function pairRoutes(router) {
3612
3668
  return pairs;
3613
3669
  }
3614
3670
  /**
3671
+ * Compute the single bare→default redirect job for one generated parameter set, or
3672
+ * `null` when no redirect is needed. The BARE (locale-less) path is derived by
3673
+ * stripping `lang`. `generate()` supplies `lang` (pages need it), so using `params`
3674
+ * as-is makes the "bare" URL already carry the locale → target === bareUrl → NO
3675
+ * redirect is ever emitted. Removing `lang` yields the real lang-less file/URL
3676
+ * (`/`, `/about/`, `/{slug}/`) that must redirect to the default-locale URL.
3677
+ *
3678
+ * @param entry - The compiled `TypedRoute` (owns `toFile`/`toUrl`).
3679
+ * @param raw - One raw parameter set from `generate()` (may be `null`/`undefined`).
3680
+ * @param defaultLocale - The default locale to redirect bare paths to.
3681
+ * @returns The `{ file, target }` redirect job, or `null` when no redirect is needed.
3682
+ * @example
3683
+ * ```ts
3684
+ * redirectJobFor(entry, { lang: "en", slug: "hello" }, "en");
3685
+ * ```
3686
+ */
3687
+ function redirectJobFor(entry, raw, defaultLocale) {
3688
+ const bareParams = { ...raw ?? {} };
3689
+ delete bareParams.lang;
3690
+ const file = entry.toFile(bareParams);
3691
+ const target = entry.toUrl({
3692
+ ...bareParams,
3693
+ lang: defaultLocale
3694
+ });
3695
+ if (!(target !== entry.toUrl(bareParams))) return null;
3696
+ return {
3697
+ file,
3698
+ target
3699
+ };
3700
+ }
3701
+ /**
3615
3702
  * Expand one route into bare→default redirect jobs for the default locale. Uses
3616
3703
  * `generate?.(defaultLocale)` (or a single empty-params instance) and emits a job
3617
3704
  * only when the bare file path differs from the default-locale URL (i.e. the route
@@ -3636,17 +3723,8 @@ async function expandRedirects(definition, entry, defaultLocale, ctx) {
3636
3723
  const parameterSets = definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
3637
3724
  const jobs = [];
3638
3725
  for (const raw of parameterSets) {
3639
- const bareParams = { ...raw ?? {} };
3640
- delete bareParams.lang;
3641
- const file = entry.toFile(bareParams);
3642
- const target = entry.toUrl({
3643
- ...bareParams,
3644
- lang: defaultLocale
3645
- });
3646
- if (target !== entry.toUrl(bareParams)) jobs.push({
3647
- file,
3648
- target
3649
- });
3726
+ const job = redirectJobFor(entry, raw, defaultLocale);
3727
+ if (job) jobs.push(job);
3650
3728
  }
3651
3729
  return jobs;
3652
3730
  }
@@ -3807,6 +3885,54 @@ function ogHash(input, template, fontsHash) {
3807
3885
  ].join("|");
3808
3886
  return createHash("sha256").update(payload).digest("hex");
3809
3887
  }
3888
+ /** Weight applied to fonts with no explicit `weight`. */
3889
+ const DEFAULT_FONT_WEIGHT = 400;
3890
+ /** Style applied to fonts with no explicit `style`. */
3891
+ const DEFAULT_FONT_STYLE = "normal";
3892
+ /** Family name given to the single fallback font scanned from `fontDir`. */
3893
+ const FALLBACK_FONT_NAME = "OG";
3894
+ /**
3895
+ * Load each explicitly-configured OG font, reading its `path` to a Buffer once and
3896
+ * filling in the default weight/style when omitted.
3897
+ *
3898
+ * @param fonts - The explicit named fonts to load.
3899
+ * @returns The loaded Satori font entries, in configuration order.
3900
+ * @example
3901
+ * ```ts
3902
+ * await loadExplicitFonts([{ name: "Inter", path: "./Inter.ttf" }]);
3903
+ * ```
3904
+ */
3905
+ async function loadExplicitFonts(fonts) {
3906
+ return Promise.all(fonts.map(async (font) => ({
3907
+ name: font.name,
3908
+ data: await readFile(font.path),
3909
+ weight: font.weight ?? DEFAULT_FONT_WEIGHT,
3910
+ style: font.style ?? DEFAULT_FONT_STYLE
3911
+ })));
3912
+ }
3913
+ /**
3914
+ * Scan `fontDir` for the first recognized font file and load it as a single
3915
+ * 400/normal fallback; yields an empty list when the directory or a usable file
3916
+ * is missing.
3917
+ *
3918
+ * @param fontDir - Directory scanned for a fallback font file.
3919
+ * @returns The single fallback font, or an empty list when none is available.
3920
+ * @example
3921
+ * ```ts
3922
+ * await scanFallbackFont("./fonts");
3923
+ * ```
3924
+ */
3925
+ async function scanFallbackFont(fontDir) {
3926
+ if (!existsSync(fontDir)) return [];
3927
+ const file = (await readdir(fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3928
+ if (!file) return [];
3929
+ return [{
3930
+ name: FALLBACK_FONT_NAME,
3931
+ data: await readFile(path.join(fontDir, file)),
3932
+ weight: DEFAULT_FONT_WEIGHT,
3933
+ style: DEFAULT_FONT_STYLE
3934
+ }];
3935
+ }
3810
3936
  /**
3811
3937
  * Load the configured OG fonts ONCE per build. When `ogImage.fonts` is set, each
3812
3938
  * `path` is read to a Buffer (outside any per-image loop) and mapped to a Satori
@@ -3823,21 +3949,8 @@ function ogHash(input, template, fontsHash) {
3823
3949
  * ```
3824
3950
  */
3825
3951
  async function loadFonts(og) {
3826
- if (og.fonts && og.fonts.length > 0) return Promise.all(og.fonts.map(async (font) => ({
3827
- name: font.name,
3828
- data: await readFile(font.path),
3829
- weight: font.weight ?? 400,
3830
- style: font.style ?? "normal"
3831
- })));
3832
- if (!existsSync(og.fontDir)) return [];
3833
- const file = (await readdir(og.fontDir)).find((name) => FONT_EXTENSIONS$1.some((extension) => name.endsWith(extension)));
3834
- if (!file) return [];
3835
- return [{
3836
- name: "OG",
3837
- data: await readFile(path.join(og.fontDir, file)),
3838
- weight: 400,
3839
- style: "normal"
3840
- }];
3952
+ if (og.fonts !== void 0 && og.fonts.length > 0) return loadExplicitFonts(og.fonts ?? []);
3953
+ return scanFallbackFont(og.fontDir);
3841
3954
  }
3842
3955
  /**
3843
3956
  * The built-in default OG card — a centered title on a dark background. Used when
@@ -4427,6 +4540,47 @@ function fillTemplate(template, parts) {
4427
4540
  return template.replaceAll(HEAD_PLACEHOLDER, parts.head).replaceAll(BODY_PLACEHOLDER, parts.body).replaceAll(ASSETS_PLACEHOLDER, parts.assets);
4428
4541
  }
4429
4542
  /**
4543
+ * Resolve the compiled entry for a manifest definition, asserting the router
4544
+ * invariant that `manifest()` and `entries()` stay in sync (see {@link makeEntryMap}).
4545
+ *
4546
+ * @param byPattern - The pattern→compiled-`TypedRoute` index.
4547
+ * @param definition - The route definition from the manifest.
4548
+ * @returns The compiled `TypedRoute` for the definition's pattern.
4549
+ * @throws {Error} When no compiled entry exists for the definition's pattern.
4550
+ * @example
4551
+ * ```ts
4552
+ * const entry = resolveEntry(byPattern, definition);
4553
+ * ```
4554
+ */
4555
+ function resolveEntry(byPattern, definition) {
4556
+ const entry = byPattern.get(definition.pattern);
4557
+ if (!entry) throw new Error(`[web] build.pages: no router entry for pattern "${definition.pattern}" — router.manifest() and router.entries() are out of sync.`);
4558
+ return entry;
4559
+ }
4560
+ /**
4561
+ * Produce the param sets one route generates for a single locale: the route's
4562
+ * `.generate(ctx)` result when present, else a single empty-params instance. The
4563
+ * generate context is the spec `{ locale, require, has }`, so a `.generate()` handler
4564
+ * pulls sibling APIs the spec way.
4565
+ *
4566
+ * @param definition - The route definition from the manifest.
4567
+ * @param locale - The active locale to generate param sets for.
4568
+ * @param ctx - Plugin context (provides `require`/`has` for the generate context).
4569
+ * @returns The param sets for this route+locale (`[{}]` when there is no `.generate()`).
4570
+ * @example
4571
+ * ```ts
4572
+ * const paramSets = await generateParamSets(def, "en", ctx);
4573
+ * ```
4574
+ */
4575
+ async function generateParameterSets(definition, locale, ctx) {
4576
+ const generateContext = {
4577
+ locale,
4578
+ require: ctx.require,
4579
+ has: ctx.has
4580
+ };
4581
+ return definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
4582
+ }
4583
+ /**
4430
4584
  * Expand one route definition into its concrete page instances across all locales,
4431
4585
  * using `generate?.(ctx)` when present (else a single empty-params instance per
4432
4586
  * locale). The generate context is the spec `{ locale, require, has }`, so a
@@ -4443,18 +4597,12 @@ function fillTemplate(template, parts) {
4443
4597
  * ```
4444
4598
  */
4445
4599
  async function expandRoute(definition, locales, byPattern, ctx) {
4446
- const entry = byPattern.get(definition.pattern);
4447
- if (!entry) throw new Error(`[web] build.pages: no router entry for pattern "${definition.pattern}" — router.manifest() and router.entries() are out of sync.`);
4600
+ const entry = resolveEntry(byPattern, definition);
4448
4601
  const { name } = entry;
4449
4602
  const instances = [];
4450
4603
  for (const locale of locales) {
4451
- const generateContext = {
4452
- locale,
4453
- require: ctx.require,
4454
- has: ctx.has
4455
- };
4456
- const generated = definition._handlers.generate ? await definition._handlers.generate(generateContext) : [{}];
4457
- for (const raw of generated) instances.push({
4604
+ const parameterSets = await generateParameterSets(definition, locale, ctx);
4605
+ for (const raw of parameterSets) instances.push({
4458
4606
  definition,
4459
4607
  entry,
4460
4608
  name,
@@ -5020,6 +5168,23 @@ function resetRun(ctx) {
5020
5168
  ctx.state.runId = `${Date.now()}-${randomUUID()}`;
5021
5169
  }
5022
5170
  /**
5171
+ * Report each rejected outcome from a settled output batch as a `build:outputs`
5172
+ * error, leaving fulfilled outcomes untouched (failures are isolated, not fatal).
5173
+ *
5174
+ * @param ctx - The phase context (used to log rejections).
5175
+ * @param settled - The settled results from the `runOutputs` task batch.
5176
+ * @example
5177
+ * ```ts
5178
+ * reportOutputFailures(ctx, await Promise.allSettled(tasks));
5179
+ * ```
5180
+ */
5181
+ function reportOutputFailures(ctx, settled) {
5182
+ for (const outcome of settled) {
5183
+ if (outcome.status !== "rejected") continue;
5184
+ ctx.log.error("build:outputs", { reason: String(outcome.reason) });
5185
+ }
5186
+ }
5187
+ /**
5023
5188
  * Phase 4 — run feeds / sitemap / og-images / public / not-found / locale-redirects
5024
5189
  * concurrently, each gated by its config flag (or, for `public`, the presence of the
5025
5190
  * source dir), isolated with `Promise.allSettled` so one failure does not lose the
@@ -5044,8 +5209,7 @@ async function runOutputs(ctx) {
5044
5209
  if (existsSync(ctx.config.publicDir ?? "public")) tasks.push(withPhase(ctx, "public", () => copyPublic(ctx)));
5045
5210
  if (ctx.config.notFound) tasks.push(withPhase(ctx, "not-found", () => generateNotFound(ctx)));
5046
5211
  if (ctx.config.localeRedirects) tasks.push(withPhase(ctx, "locale-redirects", () => generateLocaleRedirects(ctx)));
5047
- const settled = await Promise.allSettled(tasks);
5048
- for (const outcome of settled) if (outcome.status === "rejected") ctx.log.error("build:outputs", { reason: String(outcome.reason) });
5212
+ reportOutputFailures(ctx, await Promise.allSettled(tasks));
5049
5213
  }
5050
5214
  /**
5051
5215
  * Executes the full SSG pipeline for one run: clean → bundle → content/images →
@@ -5760,6 +5924,21 @@ async function writeScaffolding(input) {
5760
5924
  return result;
5761
5925
  }
5762
5926
  /**
5927
+ * Create the parent directory then write the scaffold file to disk.
5928
+ *
5929
+ * @param cwd - Project root the file is written into.
5930
+ * @param relativePath - Path (relative to cwd) of the scaffold file.
5931
+ * @param contents - The content to write.
5932
+ * @returns Resolves once the file (and any missing parents) exist on disk.
5933
+ * @example
5934
+ * await writeScaffoldFile(process.cwd(), "wrangler.jsonc", contents);
5935
+ */
5936
+ async function writeScaffoldFile(cwd, relativePath, contents) {
5937
+ const absolutePath = path.join(cwd, relativePath);
5938
+ await mkdir(path.dirname(absolutePath), { recursive: true });
5939
+ await writeFile(absolutePath, contents, "utf8");
5940
+ }
5941
+ /**
5763
5942
  * Reconcile one scaffold file against disk: in check mode record drift, otherwise
5764
5943
  * skip an existing file or write a new one. Mutates the shared {@link InitResult}.
5765
5944
  *
@@ -5776,24 +5955,21 @@ async function writeScaffolding(input) {
5776
5955
  */
5777
5956
  async function reconcile(input) {
5778
5957
  const { relativePath, expected, existing, cwd, check, result } = input;
5958
+ const fileExists = existing !== null;
5959
+ const fileDrifted = fileExists && existing !== expected;
5779
5960
  if (check) {
5780
- if (existing !== null && existing !== expected) result.drifted.push(relativePath);
5961
+ if (fileDrifted) result.drifted.push(relativePath);
5781
5962
  return;
5782
5963
  }
5783
- if (existing !== null) {
5964
+ if (fileExists) {
5784
5965
  result.skipped.push(relativePath);
5785
5966
  return;
5786
5967
  }
5787
- await mkdir(path.dirname(path.join(cwd, relativePath)), { recursive: true });
5788
- await writeFile(path.join(cwd, relativePath), expected, "utf8");
5968
+ await writeScaffoldFile(cwd, relativePath, expected);
5789
5969
  result.written.push(relativePath);
5790
5970
  }
5791
5971
  //#endregion
5792
5972
  //#region src/plugins/deploy/preflight.ts
5793
- /**
5794
- * @file deploy plugin — preflight validators (cheap → expensive), run in order
5795
- * and short-circuiting on the first failure.
5796
- */
5797
5973
  /** Error prefix for deploy preflight failures (spec/11 Part-3). */
5798
5974
  const ERROR_PREFIX$5 = "[web] deploy";
5799
5975
  /** Cloudflare Pages free-tier file-count limit. */
@@ -5820,6 +5996,27 @@ function resolveFileLimit(env = process.env) {
5820
5996
  return Math.min(parsed, PAID_TIER_FILE_LIMIT);
5821
5997
  }
5822
5998
  /**
5999
+ * Fold one directory entry into the running walk: queue subdirectories, and for
6000
+ * files bump the count and flag the path when it breaches the per-file size cap.
6001
+ *
6002
+ * @param entry - The directory entry being visited.
6003
+ * @param entryPath - The absolute path of `entry`.
6004
+ * @param result - The running walk aggregate, mutated in place.
6005
+ * @param stack - The pending-directory stack, pushed to for subdirectories.
6006
+ * @returns Resolves once the entry has been folded into `result`/`stack`.
6007
+ * @example
6008
+ * await inspectEntry(entry, "/project/dist/app.js", result, stack);
6009
+ */
6010
+ async function inspectEntry(entry, entryPath, result, stack) {
6011
+ if (entry.isDirectory()) {
6012
+ stack.push(entryPath);
6013
+ return;
6014
+ }
6015
+ if (!entry.isFile()) return;
6016
+ result.fileCount += 1;
6017
+ if ((await stat(entryPath)).size > MAX_FILE_SIZE_BYTES) result.oversizePath = entryPath;
6018
+ }
6019
+ /**
5823
6020
  * Recursively walk `dir`, counting files and flagging the first file over the
5824
6021
  * per-file size cap. Short-circuits once an oversize file is found.
5825
6022
  *
@@ -5834,20 +6031,13 @@ async function inspectOutdir(dir) {
5834
6031
  oversizePath: null
5835
6032
  };
5836
6033
  const stack = [dir];
5837
- while (stack.length > 0) {
6034
+ while (stack.length > 0 && result.oversizePath === null) {
5838
6035
  const current = stack.pop();
5839
6036
  if (current === void 0) break;
5840
6037
  const entries = await readdir(current, { withFileTypes: true });
5841
6038
  for (const entry of entries) {
5842
- const entryPath = path.join(current, entry.name);
5843
- if (entry.isDirectory()) stack.push(entryPath);
5844
- else if (entry.isFile()) {
5845
- result.fileCount += 1;
5846
- if ((await stat(entryPath)).size > MAX_FILE_SIZE_BYTES) {
5847
- result.oversizePath = entryPath;
5848
- return result;
5849
- }
5850
- }
6039
+ await inspectEntry(entry, path.join(current, entry.name), result, stack);
6040
+ if (result.oversizePath !== null) break;
5851
6041
  }
5852
6042
  }
5853
6043
  return result;
@@ -6322,10 +6512,31 @@ function createRebuilder(input) {
6322
6512
  let building = false;
6323
6513
  let dirty = false;
6324
6514
  /**
6325
- * Run the queued rebuild once, then — if a change arrived while it was in flight —
6326
- * re-run exactly once more so no change is dropped. Marks `dirty` (instead of
6327
- * running) when a rebuild is already underway, resetting the in-flight flag when
6328
- * each run settles.
6515
+ * Rebuild repeatedly until no change arrived mid-flight: each pass clears `dirty`,
6516
+ * runs one build, then loops again if a `schedule()` set `dirty` while it ran, so
6517
+ * no change is dropped.
6518
+ *
6519
+ * @returns Resolves once a pass completes with no pending change (errors are routed,
6520
+ * never thrown).
6521
+ * @example
6522
+ * await drainPendingRebuilds();
6523
+ */
6524
+ const drainPendingRebuilds = async () => {
6525
+ do {
6526
+ dirty = false;
6527
+ await runOneRebuild({
6528
+ runBuild: input.runBuild,
6529
+ onReloaded: input.onReloaded,
6530
+ onError: input.onError,
6531
+ file: pendingFile
6532
+ });
6533
+ } while (dirty);
6534
+ };
6535
+ /**
6536
+ * Run the queued rebuild once the debounce timer fires. Marks `dirty` (instead of
6537
+ * running) when a rebuild is already underway, otherwise holds the in-flight flag
6538
+ * across a full {@link drainPendingRebuilds} so concurrent changes coalesce into
6539
+ * exactly one extra re-run.
6329
6540
  *
6330
6541
  * @returns Resolves once the rebuild (and any coalesced re-run) settles (errors are
6331
6542
  * routed, never thrown).
@@ -6339,15 +6550,7 @@ function createRebuilder(input) {
6339
6550
  return;
6340
6551
  }
6341
6552
  building = true;
6342
- do {
6343
- dirty = false;
6344
- await runOneRebuild({
6345
- runBuild: input.runBuild,
6346
- onReloaded: input.onReloaded,
6347
- onError: input.onError,
6348
- file: pendingFile
6349
- });
6350
- } while (dirty);
6553
+ await drainPendingRebuilds();
6351
6554
  building = false;
6352
6555
  };
6353
6556
  return {
@@ -6484,6 +6687,26 @@ function createReloadHub() {
6484
6687
  }
6485
6688
  };
6486
6689
  }
6690
+ /** The content-type sent on rewritten HTML responses (live-reload injection). */
6691
+ const HTML_CONTENT_TYPE = "text/html; charset=utf-8";
6692
+ /**
6693
+ * Re-render a static file response with the live-reload client injected, preserving
6694
+ * the resolved status. Reads the original body to text so {@link injectReloadClient}
6695
+ * can splice the snippet in before `</body>`.
6696
+ *
6697
+ * @param response - The original static file response to rewrite.
6698
+ * @param status - The resolved status to carry onto the rewritten response.
6699
+ * @returns A fresh HTML response containing the injected reload client.
6700
+ * @example
6701
+ * const injected = await injectReloadResponse(fileResponse, 200);
6702
+ */
6703
+ async function injectReloadResponse(response, status) {
6704
+ const html = injectReloadClient(await response.text());
6705
+ return new Response(html, {
6706
+ status,
6707
+ headers: { "content-type": HTML_CONTENT_TYPE }
6708
+ });
6709
+ }
6487
6710
  /**
6488
6711
  * Build the live-reload-aware request handler for the dev server: serves the SSE
6489
6712
  * stream at {@link RELOAD_PATH}, injects the reload client into HTML responses (when
@@ -6503,13 +6726,7 @@ function createDevHandler(ctx, hub) {
6503
6726
  const resolved = resolveCleanUrl(ctx.config.outDir, pathname);
6504
6727
  if (resolved.file === null) return new Response("Not Found", { status: 404 });
6505
6728
  const response = ctx.state.fileResponse(resolved.file, resolved.status);
6506
- if (ctx.config.liveReload && resolved.file.endsWith(".html")) {
6507
- const html = injectReloadClient(await response.text());
6508
- return new Response(html, {
6509
- status: resolved.status,
6510
- headers: { "content-type": "text/html; charset=utf-8" }
6511
- });
6512
- }
6729
+ if (ctx.config.liveReload && resolved.file.endsWith(".html")) return injectReloadResponse(response, resolved.status);
6513
6730
  return response;
6514
6731
  };
6515
6732
  }
@@ -6727,6 +6944,56 @@ function validateConfig(config) {
6727
6944
  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).`);
6728
6945
  }
6729
6946
  /**
6947
+ * Assert the SSG emitted the not-found page, rendering a hint and throwing
6948
+ * `ERR_CLI_NOT_FOUND` when it is missing (CF Pages flips to SPA mode without a
6949
+ * top-level 404). A no-op when the page exists.
6950
+ *
6951
+ * @param ctx - Plugin context (provides `state.render` for the failure hint).
6952
+ * @param page - The absolute path the not-found page is expected at.
6953
+ * @throws {Error} `ERR_CLI_NOT_FOUND` when the not-found page is missing.
6954
+ * @example
6955
+ * assertNotFoundPage(ctx, path.join(ctx.config.outDir, ctx.config.notFoundFile));
6956
+ */
6957
+ function assertNotFoundPage(ctx, page) {
6958
+ if (existsSync(page)) return;
6959
+ ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
6960
+ 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.`);
6961
+ }
6962
+ /**
6963
+ * Whether the deploy confirmation prompt should be shown to a human. True only on
6964
+ * an interactive TTY with `CI` unset and when the caller has not passed `yes`; CI
6965
+ * and non-TTY runs are not prompted so consumer scripts never block a pipeline.
6966
+ *
6967
+ * @param yes - The caller's `yes` flag (forces the prompt to be skipped anywhere).
6968
+ * @returns `true` when a confirmation prompt should be shown, otherwise `false`.
6969
+ * @example
6970
+ * if (shouldPromptDeploy(false)) { ... }
6971
+ */
6972
+ function shouldPromptDeploy(yes) {
6973
+ return process.stdout.isTTY === true && process.env.CI === void 0 && !yes;
6974
+ }
6975
+ /**
6976
+ * Resolve whether a deploy may proceed, handling the human/non-interactive split:
6977
+ * prompts an interactive TTY user (rendering the "skipped" warning on a "no"),
6978
+ * renders the non-interactive notice when no prompt and no `yes`, and otherwise
6979
+ * proceeds silently. Returns whether the caller should run the deploy.
6980
+ *
6981
+ * @param ctx - Plugin context (provides `state.confirm` and `state.render`).
6982
+ * @param yes - The caller's `yes` flag (forces the skip anywhere).
6983
+ * @returns `true` when the deploy should run, `false` when an interactive user declined.
6984
+ * @example
6985
+ * if (!(await confirmDeploy(ctx, false))) return { deployed: false, reason: "declined" };
6986
+ */
6987
+ async function confirmDeploy(ctx, yes) {
6988
+ if (!shouldPromptDeploy(yes)) {
6989
+ if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
6990
+ return true;
6991
+ }
6992
+ const confirmed = await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to cloudflare-pages?`);
6993
+ if (!confirmed) ctx.state.render.warn("deploy skipped");
6994
+ return confirmed;
6995
+ }
6996
+ /**
6730
6997
  * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
6731
6998
  * Each method renders `state.render.header(<command>)` first, then does its work;
6732
6999
  * live progress is rendered by the hooks wired in `index.ts`, so each method's
@@ -6753,11 +7020,7 @@ function createApi$1(ctx) {
6753
7020
  const { assertNotFound = true } = options;
6754
7021
  ctx.state.render.header("build");
6755
7022
  const result = await ctx.require(buildPlugin).run();
6756
- const page = path.join(ctx.config.outDir, ctx.config.notFoundFile);
6757
- if (assertNotFound && !existsSync(page)) {
6758
- ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
6759
- 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.`);
6760
- }
7023
+ if (assertNotFound) assertNotFoundPage(ctx, path.join(ctx.config.outDir, ctx.config.notFoundFile));
6761
7024
  return result;
6762
7025
  },
6763
7026
  /**
@@ -6803,15 +7066,10 @@ function createApi$1(ctx) {
6803
7066
  const { branch, yes = false } = options;
6804
7067
  ctx.state.render.header("deploy");
6805
7068
  await ctx.require(deployPlugin).init({ ci: true });
6806
- if (process.stdout.isTTY === true && process.env.CI === void 0 && !yes) {
6807
- if (!await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to cloudflare-pages?`)) {
6808
- ctx.state.render.warn("deploy skipped");
6809
- return {
6810
- deployed: false,
6811
- reason: "declined"
6812
- };
6813
- }
6814
- } else if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
7069
+ if (!await confirmDeploy(ctx, yes)) return {
7070
+ deployed: false,
7071
+ reason: "declined"
7072
+ };
6815
7073
  return {
6816
7074
  deployed: true,
6817
7075
  ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
@@ -7502,6 +7760,22 @@ const ERROR_PREFIX$2 = "[web]";
7502
7760
  /** The set of legal hook names, frozen for O(1) membership checks. */
7503
7761
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
7504
7762
  /**
7763
+ * Validate a single hook entry: its key must be a known hook name and its value
7764
+ * must be a function. Throws fail-fast on the first violation.
7765
+ *
7766
+ * @param componentName - The owning component name (for error messages).
7767
+ * @param hooks - The hooks object being validated.
7768
+ * @param key - The hook key to validate.
7769
+ * @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
7770
+ * @throws {TypeError} If the hook value is not a function.
7771
+ * @example
7772
+ * validateHookEntry("counter", hooks, "onMount");
7773
+ */
7774
+ function validateHookEntry(componentName, hooks, key) {
7775
+ 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(", ")}`);
7776
+ 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`);
7777
+ }
7778
+ /**
7505
7779
  * Create a validated component definition. Validates hook names at registration
7506
7780
  * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
7507
7781
  * each provided hook is a function.
@@ -7518,10 +7792,7 @@ const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
7518
7792
  */
7519
7793
  function createComponent(name, hooks) {
7520
7794
  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)`);
7521
- for (const key of Object.keys(hooks)) {
7522
- 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(", ")}`);
7523
- 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`);
7524
- }
7795
+ for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
7525
7796
  return {
7526
7797
  name,
7527
7798
  hooks
@@ -7591,6 +7862,36 @@ function makeContext(element, data) {
7591
7862
  };
7592
7863
  }
7593
7864
  /**
7865
+ * Mounts a single `data-component` element: classifies persistent vs
7866
+ * page-specific, builds the instance, fires `onCreate` then `onMount`, records
7867
+ * it in state, and emits `spa:component-mount`. No-ops if the element is already
7868
+ * mounted, has no component name, or names an unregistered component.
7869
+ *
7870
+ * @param state - The plugin state (registeredComponents + instances).
7871
+ * @param emit - The event emitter for spa:component-mount.
7872
+ * @param swapArea - The swap-region element, or null when none was found.
7873
+ * @param data - The current page data payload.
7874
+ * @param element - The candidate element carrying a `data-component` attribute.
7875
+ * @example
7876
+ * mountElement(state, emit, swapArea, data, element);
7877
+ */
7878
+ function mountElement(state, emit, swapArea, data, element) {
7879
+ if (state.instances.has(element)) return;
7880
+ const name = element.dataset.component;
7881
+ if (!name) return;
7882
+ const definition = state.registeredComponents.get(name);
7883
+ if (!definition) return;
7884
+ const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
7885
+ const ctx = makeContext(element, data);
7886
+ runHook(instance, "onCreate", ctx);
7887
+ runHook(instance, "onMount", ctx);
7888
+ state.instances.set(element, instance);
7889
+ emit("spa:component-mount", {
7890
+ name: definition.name,
7891
+ el: element
7892
+ });
7893
+ }
7894
+ /**
7594
7895
  * Scans the swap region, mounts components for matching `data-component`
7595
7896
  * elements, classifies persistent (outside swap area) vs page-specific (inside),
7596
7897
  * fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
@@ -7606,22 +7907,7 @@ function scanAndMount(state, emit, swapSelector) {
7606
7907
  if (typeof document === "undefined") return;
7607
7908
  const swapArea = document.querySelector(swapSelector);
7608
7909
  const data = extractPageData(document);
7609
- for (const element of document.querySelectorAll("[data-component]")) {
7610
- if (state.instances.has(element)) continue;
7611
- const name = element.dataset.component;
7612
- if (!name) continue;
7613
- const definition = state.registeredComponents.get(name);
7614
- if (!definition) continue;
7615
- const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
7616
- const ctx = makeContext(element, data);
7617
- runHook(instance, "onCreate", ctx);
7618
- runHook(instance, "onMount", ctx);
7619
- state.instances.set(element, instance);
7620
- emit("spa:component-mount", {
7621
- name: definition.name,
7622
- el: element
7623
- });
7624
- }
7910
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
7625
7911
  }
7626
7912
  /**
7627
7913
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -8092,6 +8378,8 @@ function attachRouter(handlers, navigate) {
8092
8378
  //#region src/plugins/spa/state.ts
8093
8379
  /** Error prefix for spa config-validation failures (spec/11 Part-3). */
8094
8380
  const ERROR_PREFIX$1 = "[web]";
8381
+ /** Last-resort `swapSelector` when neither config nor defaults supply one. */
8382
+ const FALLBACK_SWAP_SELECTOR = "main > section";
8095
8383
  /** Default SPA config (declared as a value — no inline assertion). */
8096
8384
  const defaultSpaConfig = {
8097
8385
  swapSelector: "main > section",
@@ -8129,7 +8417,7 @@ function isValidSelector(selector) {
8129
8417
  * const resolved = resolveSpaConfig({ swapSelector: "main > section" });
8130
8418
  */
8131
8419
  function resolveSpaConfig(config) {
8132
- const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? "main > section";
8420
+ const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
8133
8421
  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").`);
8134
8422
  if (!isValidSelector(swapSelector)) throw new Error(`${ERROR_PREFIX$1} spa.swapSelector is not a valid CSS selector: "${swapSelector}".\n Provide a syntactically valid selector.`);
8135
8423
  return {
@@ -8848,6 +9136,35 @@ function pullQuoteTransform(tree) {
8848
9136
  }
8849
9137
  });
8850
9138
  }
9139
+ /** CSS class for the divider wrapper that replaces an `<hr>`. */
9140
+ const SECTION_DIVIDER_CLASS = "section-divider";
9141
+ /** CSS class for the inner ornament span inside the section divider. */
9142
+ const SECTION_DIVIDER_ORNAMENT_CLASS = "section-divider-ornament";
9143
+ /** Glyphs rendered inside the section-divider ornament span. */
9144
+ const SECTION_DIVIDER_ORNAMENT = "***";
9145
+ /**
9146
+ * Rewrite one `<hr>` element in place into an ornamental section divider:
9147
+ * a `<div>` wrapper carrying a single ornament `<span>`.
9148
+ *
9149
+ * @param node - The hast element to rewrite (expected to be an `<hr>`).
9150
+ * @example
9151
+ * ```ts
9152
+ * rewriteHrToDivider(node);
9153
+ * ```
9154
+ */
9155
+ function rewriteHrToDivider(node) {
9156
+ node.tagName = "div";
9157
+ node.properties = { class: SECTION_DIVIDER_CLASS };
9158
+ node.children = [{
9159
+ type: "element",
9160
+ tagName: "span",
9161
+ properties: { class: SECTION_DIVIDER_ORNAMENT_CLASS },
9162
+ children: [{
9163
+ type: "text",
9164
+ value: SECTION_DIVIDER_ORNAMENT
9165
+ }]
9166
+ }];
9167
+ }
8851
9168
  /**
8852
9169
  * Hast transformer rewriting `<hr>` into an ornamental section divider.
8853
9170
  *
@@ -8859,19 +9176,8 @@ function pullQuoteTransform(tree) {
8859
9176
  */
8860
9177
  function sectionDividerTransform(tree) {
8861
9178
  visit(tree, "element", (node) => {
8862
- if (node.tagName === "hr") {
8863
- node.tagName = "div";
8864
- node.properties = { class: "section-divider" };
8865
- node.children = [{
8866
- type: "element",
8867
- tagName: "span",
8868
- properties: { class: "section-divider-ornament" },
8869
- children: [{
8870
- type: "text",
8871
- value: "***"
8872
- }]
8873
- }];
8874
- }
9179
+ if (node.tagName !== "hr") return;
9180
+ rewriteHrToDivider(node);
8875
9181
  });
8876
9182
  }
8877
9183
  /**