@moku-labs/web 1.0.0 → 1.1.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
@@ -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 →
@@ -5597,6 +5761,19 @@ const CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
5597
5761
  const SETUP_BUN_SHA = "4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5";
5598
5762
  /** Pinned `cloudflare/wrangler-action` commit SHA. */
5599
5763
  const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
5764
+ /** The `on:` block for each {@link WorkflowTrigger} (kept indentation-exact for YAML). */
5765
+ const TRIGGER_ON_BLOCKS = {
5766
+ auto: `on:
5767
+ push:
5768
+ branches: [main]
5769
+ workflow_dispatch:`,
5770
+ "versioned-tag": `on:
5771
+ push:
5772
+ tags: ["v*"]
5773
+ workflow_dispatch:`,
5774
+ dispatch: `on:
5775
+ workflow_dispatch:`
5776
+ };
5600
5777
  /**
5601
5778
  * Generate a SHA-pinned GitHub Actions workflow that builds and deploys to
5602
5779
  * Cloudflare Pages. Every action is pinned to a commit SHA (with a `# vX`
@@ -5606,9 +5783,10 @@ const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
5606
5783
  *
5607
5784
  * @param input - The generator inputs.
5608
5785
  * @param input.slug - Cloudflare project-name slug used as the wrangler `--project-name`.
5786
+ * @param input.trigger - What fires the workflow (see {@link WorkflowTrigger}). Default `"auto"`.
5609
5787
  * @returns The workflow YAML.
5610
5788
  * @example
5611
- * generateGithubWorkflow({ slug: "my-site" });
5789
+ * generateGithubWorkflow({ slug: "my-site", trigger: "versioned-tag" });
5612
5790
  */
5613
5791
  function generateGithubWorkflow(input) {
5614
5792
  return `# .github/workflows/deploy.yml — generated by \`app.deploy.init({ ci: true })\`.
@@ -5616,10 +5794,7 @@ function generateGithubWorkflow(input) {
5616
5794
 
5617
5795
  name: Deploy
5618
5796
 
5619
- on:
5620
- push:
5621
- branches: [main]
5622
- workflow_dispatch:
5797
+ ${TRIGGER_ON_BLOCKS[input.trigger ?? "auto"]}
5623
5798
 
5624
5799
  permissions:
5625
5800
  contents: read
@@ -5751,7 +5926,10 @@ async function writeScaffolding(input) {
5751
5926
  });
5752
5927
  if (ci) await reconcile({
5753
5928
  relativePath: WORKFLOW_PATH,
5754
- expected: generateGithubWorkflow({ slug }),
5929
+ expected: generateGithubWorkflow({
5930
+ slug,
5931
+ ...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
5932
+ }),
5755
5933
  existing: await readMaybe(cwd, WORKFLOW_PATH),
5756
5934
  cwd,
5757
5935
  check,
@@ -5760,6 +5938,21 @@ async function writeScaffolding(input) {
5760
5938
  return result;
5761
5939
  }
5762
5940
  /**
5941
+ * Create the parent directory then write the scaffold file to disk.
5942
+ *
5943
+ * @param cwd - Project root the file is written into.
5944
+ * @param relativePath - Path (relative to cwd) of the scaffold file.
5945
+ * @param contents - The content to write.
5946
+ * @returns Resolves once the file (and any missing parents) exist on disk.
5947
+ * @example
5948
+ * await writeScaffoldFile(process.cwd(), "wrangler.jsonc", contents);
5949
+ */
5950
+ async function writeScaffoldFile(cwd, relativePath, contents) {
5951
+ const absolutePath = path.join(cwd, relativePath);
5952
+ await mkdir(path.dirname(absolutePath), { recursive: true });
5953
+ await writeFile(absolutePath, contents, "utf8");
5954
+ }
5955
+ /**
5763
5956
  * Reconcile one scaffold file against disk: in check mode record drift, otherwise
5764
5957
  * skip an existing file or write a new one. Mutates the shared {@link InitResult}.
5765
5958
  *
@@ -5776,24 +5969,21 @@ async function writeScaffolding(input) {
5776
5969
  */
5777
5970
  async function reconcile(input) {
5778
5971
  const { relativePath, expected, existing, cwd, check, result } = input;
5972
+ const fileExists = existing !== null;
5973
+ const fileDrifted = fileExists && existing !== expected;
5779
5974
  if (check) {
5780
- if (existing !== null && existing !== expected) result.drifted.push(relativePath);
5975
+ if (fileDrifted) result.drifted.push(relativePath);
5781
5976
  return;
5782
5977
  }
5783
- if (existing !== null) {
5978
+ if (fileExists) {
5784
5979
  result.skipped.push(relativePath);
5785
5980
  return;
5786
5981
  }
5787
- await mkdir(path.dirname(path.join(cwd, relativePath)), { recursive: true });
5788
- await writeFile(path.join(cwd, relativePath), expected, "utf8");
5982
+ await writeScaffoldFile(cwd, relativePath, expected);
5789
5983
  result.written.push(relativePath);
5790
5984
  }
5791
5985
  //#endregion
5792
5986
  //#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
5987
  /** Error prefix for deploy preflight failures (spec/11 Part-3). */
5798
5988
  const ERROR_PREFIX$5 = "[web] deploy";
5799
5989
  /** Cloudflare Pages free-tier file-count limit. */
@@ -5820,6 +6010,27 @@ function resolveFileLimit(env = process.env) {
5820
6010
  return Math.min(parsed, PAID_TIER_FILE_LIMIT);
5821
6011
  }
5822
6012
  /**
6013
+ * Fold one directory entry into the running walk: queue subdirectories, and for
6014
+ * files bump the count and flag the path when it breaches the per-file size cap.
6015
+ *
6016
+ * @param entry - The directory entry being visited.
6017
+ * @param entryPath - The absolute path of `entry`.
6018
+ * @param result - The running walk aggregate, mutated in place.
6019
+ * @param stack - The pending-directory stack, pushed to for subdirectories.
6020
+ * @returns Resolves once the entry has been folded into `result`/`stack`.
6021
+ * @example
6022
+ * await inspectEntry(entry, "/project/dist/app.js", result, stack);
6023
+ */
6024
+ async function inspectEntry(entry, entryPath, result, stack) {
6025
+ if (entry.isDirectory()) {
6026
+ stack.push(entryPath);
6027
+ return;
6028
+ }
6029
+ if (!entry.isFile()) return;
6030
+ result.fileCount += 1;
6031
+ if ((await stat(entryPath)).size > MAX_FILE_SIZE_BYTES) result.oversizePath = entryPath;
6032
+ }
6033
+ /**
5823
6034
  * Recursively walk `dir`, counting files and flagging the first file over the
5824
6035
  * per-file size cap. Short-circuits once an oversize file is found.
5825
6036
  *
@@ -5834,20 +6045,13 @@ async function inspectOutdir(dir) {
5834
6045
  oversizePath: null
5835
6046
  };
5836
6047
  const stack = [dir];
5837
- while (stack.length > 0) {
6048
+ while (stack.length > 0 && result.oversizePath === null) {
5838
6049
  const current = stack.pop();
5839
6050
  if (current === void 0) break;
5840
6051
  const entries = await readdir(current, { withFileTypes: true });
5841
6052
  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
- }
6053
+ await inspectEntry(entry, path.join(current, entry.name), result, stack);
6054
+ if (result.oversizePath !== null) break;
5851
6055
  }
5852
6056
  }
5853
6057
  return result;
@@ -6243,6 +6447,185 @@ const deployPlugin = createPlugin$1("deploy", {
6243
6447
  api: createApi$2
6244
6448
  });
6245
6449
  //#endregion
6450
+ //#region src/plugins/cli/deploy-wizard.ts
6451
+ /**
6452
+ * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`). Walks a
6453
+ * human through a Cloudflare Pages deploy: checks prerequisites (wrangler config + the
6454
+ * Cloudflare credentials) with concrete fix guidance, offers to scaffold/build what is
6455
+ * missing, HARD-GATES the deploy on everything being green, runs a local build smoke
6456
+ * test, confirms, deploys, then offers to scaffold a GitHub Actions workflow (auto on
6457
+ * push to main, or a versioned/manual trigger). The non-guided `--cli` path stays in
6458
+ * `api.ts`. Every prompt + line of output flows through injectable `state` seams.
6459
+ */
6460
+ /** How to create a Cloudflare API token + where to make it available locally. */
6461
+ const TOKEN_HELP = [
6462
+ "Create one at https://dash.cloudflare.com/profile/api-tokens → Create Token →",
6463
+ "use the \"Cloudflare Pages — Edit\" template (or a custom token with the",
6464
+ "Account › Cloudflare Pages › Edit permission). Then make it available:",
6465
+ " export CLOUDFLARE_API_TOKEN=… (shell) or add it to .env (gitignored)."
6466
+ ].join("\n");
6467
+ /** Where to find the Cloudflare account id + where to make it available locally. */
6468
+ const ACCOUNT_HELP = [
6469
+ "Find it on the Cloudflare dashboard → Workers & Pages: the Account ID is in the",
6470
+ "right-hand sidebar (also in the dashboard URL). Then make it available:",
6471
+ " export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
6472
+ ].join("\n");
6473
+ /** The GitHub repo secrets the generated workflow consumes. */
6474
+ const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
6475
+ /**
6476
+ * Evaluate the three deploy prerequisites against the current project: the Cloudflare
6477
+ * wrangler config exists, and both Cloudflare credentials are present in the environment.
6478
+ *
6479
+ * @param cwd - The project root (where `wrangler.jsonc` lives).
6480
+ * @returns The ordered prerequisite checks.
6481
+ * @example
6482
+ * const prereqs = diagnose(process.cwd());
6483
+ */
6484
+ function diagnose(cwd) {
6485
+ const wranglerOk = existsSync(path.join(cwd, "wrangler.jsonc"));
6486
+ const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
6487
+ const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
6488
+ return [
6489
+ {
6490
+ ok: wranglerOk,
6491
+ label: "wrangler.jsonc (Cloudflare project config)",
6492
+ detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
6493
+ scaffoldable: true
6494
+ },
6495
+ {
6496
+ ok: tokenOk,
6497
+ label: "CLOUDFLARE_API_TOKEN is set",
6498
+ detail: tokenOk ? void 0 : TOKEN_HELP,
6499
+ scaffoldable: false
6500
+ },
6501
+ {
6502
+ ok: accountOk,
6503
+ label: "CLOUDFLARE_ACCOUNT_ID is set",
6504
+ detail: accountOk ? void 0 : ACCOUNT_HELP,
6505
+ scaffoldable: false
6506
+ }
6507
+ ];
6508
+ }
6509
+ /**
6510
+ * Offer to scaffold a missing `wrangler.jsonc` (the only auto-fixable prerequisite),
6511
+ * generating it via the deploy plugin when the user accepts.
6512
+ *
6513
+ * @param ctx - The cli plugin context.
6514
+ * @param prereqs - The current prerequisite checks.
6515
+ * @returns Resolves once any accepted fix has run.
6516
+ * @example
6517
+ * await offerScaffold(ctx, diagnose(cwd));
6518
+ */
6519
+ async function offerScaffold(ctx, prereqs) {
6520
+ if (!prereqs.some((item) => item.scaffoldable && !item.ok)) return;
6521
+ if (!await ctx.state.confirm("Scaffold wrangler.jsonc now?")) return;
6522
+ await ctx.require(deployPlugin).init({});
6523
+ ctx.state.render.check(true, "wrangler.jsonc scaffolded");
6524
+ }
6525
+ /**
6526
+ * Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
6527
+ * concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
6528
+ *
6529
+ * @param ctx - The cli plugin context (for the follow-up sub-choice prompt).
6530
+ * @param choice - The selected zero-based index of the top-level options.
6531
+ * @returns The resolved trigger, or `null` to skip.
6532
+ * @example
6533
+ * const trigger = await resolveTrigger(ctx, 1);
6534
+ */
6535
+ async function resolveTrigger(ctx, choice) {
6536
+ if (choice === 2) return null;
6537
+ if (choice === 0) return "auto";
6538
+ return await ctx.state.select("How should the versioned deploy be triggered?", ["On a version tag push (v*) + the manual Run-workflow button", "Manual Run-workflow button only (workflow_dispatch)"]) === 0 ? "versioned-tag" : "dispatch";
6539
+ }
6540
+ /**
6541
+ * Offer to scaffold a GitHub Actions deploy workflow, letting the user choose how it is
6542
+ * triggered, then remind them which repo secrets to add. A no-op past a "skip" choice.
6543
+ *
6544
+ * @param ctx - The cli plugin context.
6545
+ * @returns Resolves once any chosen workflow has been scaffolded.
6546
+ * @example
6547
+ * await offerWorkflowSetup(ctx);
6548
+ */
6549
+ async function offerWorkflowSetup(ctx) {
6550
+ ctx.state.render.heading("Automate future deploys (GitHub Actions)");
6551
+ const trigger = await resolveTrigger(ctx, await ctx.state.select("Set up a deploy workflow?", [
6552
+ "Auto-deploy on every push to main",
6553
+ "Manual / versioned deploy (choose trigger)",
6554
+ "Skip for now"
6555
+ ]));
6556
+ if (trigger === null) return;
6557
+ const result = await ctx.require(deployPlugin).init({
6558
+ ci: true,
6559
+ workflowTrigger: trigger
6560
+ });
6561
+ const workflowPath = ".github/workflows/deploy.yml";
6562
+ const wrote = result.written.includes(workflowPath);
6563
+ ctx.state.render.check(true, wrote ? `wrote ${workflowPath}` : `${workflowPath} already exists (left unchanged)`);
6564
+ ctx.state.render.info(SECRETS_HELP);
6565
+ }
6566
+ /**
6567
+ * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
6568
+ * report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
6569
+ *
6570
+ * @param ctx - The cli plugin context.
6571
+ * @param options - The deploy options (branch override + `yes`).
6572
+ * @returns The deploy outcome.
6573
+ * @example
6574
+ * const outcome = await runDeployStep(ctx, { yes: true });
6575
+ */
6576
+ async function runDeployStep(ctx, options) {
6577
+ ctx.state.render.heading("Deploy");
6578
+ if (!(options.yes === true || await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to Cloudflare Pages now?`))) {
6579
+ ctx.state.render.warn("deploy skipped");
6580
+ return {
6581
+ deployed: false,
6582
+ reason: "declined"
6583
+ };
6584
+ }
6585
+ return {
6586
+ deployed: true,
6587
+ ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6588
+ };
6589
+ }
6590
+ /**
6591
+ * Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
6592
+ * the wrangler config), HARD-GATE on the remaining blockers, run a local build smoke
6593
+ * test, deploy (with confirmation), then offer to scaffold a CI workflow. Returns
6594
+ * `{ deployed: false, reason: "blocked" }` when prerequisites are unmet, so a thin script
6595
+ * can exit non-zero. Assumes the caller already rendered the `deploy` header.
6596
+ *
6597
+ * @param ctx - The cli plugin context (state seams + `require` + config).
6598
+ * @param options - The deploy options (branch override, `yes`, `guided`).
6599
+ * @returns The deploy outcome (`deployed`, or a `declined`/`blocked` skip).
6600
+ * @example
6601
+ * const outcome = await runDeployWizard(ctx, { guided: true });
6602
+ */
6603
+ async function runDeployWizard(ctx, options) {
6604
+ const cwd = process.cwd();
6605
+ ctx.state.render.heading("Checking prerequisites");
6606
+ for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
6607
+ await offerScaffold(ctx, diagnose(cwd));
6608
+ const blockers = diagnose(cwd).filter((item) => !item.ok);
6609
+ if (blockers.length > 0) {
6610
+ ctx.state.render.heading("Not ready to deploy");
6611
+ for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
6612
+ ctx.state.render.warn(`Fix the ${blockers.length} item(s) above, then re-run \`bun run deploy\`.`);
6613
+ return {
6614
+ deployed: false,
6615
+ reason: "blocked"
6616
+ };
6617
+ }
6618
+ ctx.state.render.heading("Local test");
6619
+ const summary = await ctx.require(buildPlugin).run();
6620
+ ctx.state.render.check(true, `built ${summary.pageCount} pages → ${summary.outDir}/`);
6621
+ const notFoundOk = existsSync(path.join(ctx.config.outDir, ctx.config.notFoundFile));
6622
+ ctx.state.render.check(notFoundOk, `${ctx.config.notFoundFile} present`, notFoundOk ? void 0 : "Set build.notFound so the SSG emits it (CF Pages else flips to SPA mode).");
6623
+ ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
6624
+ const outcome = await runDeployStep(ctx, options);
6625
+ await offerWorkflowSetup(ctx);
6626
+ return outcome;
6627
+ }
6628
+ //#endregion
6246
6629
  //#region src/plugins/cli/errors.ts
6247
6630
  /** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
6248
6631
  const ERROR_PREFIX$3 = "[web] cli";
@@ -6276,11 +6659,12 @@ function injectReloadClient(html) {
6276
6659
  return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
6277
6660
  }
6278
6661
  /**
6279
- * Run one rebuild and report the result. Skips re-entrancy via the shared `building`
6280
- * flag and routes success to `onReloaded`, failure to `onError`.
6662
+ * Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
6663
+ * routes success to `onReloaded` and failure to `onError`.
6281
6664
  *
6282
6665
  * @param input - The rebuild dependencies + the changed file.
6283
6666
  * @param input.runBuild - Runs one build and resolves with its summary.
6667
+ * @param input.onRebuildStart - Called with the changed file just before the build runs.
6284
6668
  * @param input.onReloaded - Called with the changed file + summary after a rebuild.
6285
6669
  * @param input.onError - Called when a rebuild throws.
6286
6670
  * @param input.file - The changed file to report alongside the summary.
@@ -6289,6 +6673,7 @@ function injectReloadClient(html) {
6289
6673
  * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
6290
6674
  */
6291
6675
  async function runOneRebuild(input) {
6676
+ input.onRebuildStart?.(input.file);
6292
6677
  try {
6293
6678
  const summary = await input.runBuild();
6294
6679
  input.onReloaded({
@@ -6310,6 +6695,7 @@ async function runOneRebuild(input) {
6310
6695
  * @param input - The rebuild dependencies.
6311
6696
  * @param input.debounceMs - Debounce window in milliseconds.
6312
6697
  * @param input.runBuild - Runs one build and resolves with its summary.
6698
+ * @param input.onRebuildStart - Called with the changed file just before each build runs.
6313
6699
  * @param input.onReloaded - Called with the changed file + summary after a rebuild.
6314
6700
  * @param input.onError - Called when a rebuild throws.
6315
6701
  * @returns The debounced rebuild driver.
@@ -6322,10 +6708,32 @@ function createRebuilder(input) {
6322
6708
  let building = false;
6323
6709
  let dirty = false;
6324
6710
  /**
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.
6711
+ * Rebuild repeatedly until no change arrived mid-flight: each pass clears `dirty`,
6712
+ * runs one build, then loops again if a `schedule()` set `dirty` while it ran, so
6713
+ * no change is dropped.
6714
+ *
6715
+ * @returns Resolves once a pass completes with no pending change (errors are routed,
6716
+ * never thrown).
6717
+ * @example
6718
+ * await drainPendingRebuilds();
6719
+ */
6720
+ const drainPendingRebuilds = async () => {
6721
+ do {
6722
+ dirty = false;
6723
+ await runOneRebuild({
6724
+ runBuild: input.runBuild,
6725
+ ...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
6726
+ onReloaded: input.onReloaded,
6727
+ onError: input.onError,
6728
+ file: pendingFile
6729
+ });
6730
+ } while (dirty);
6731
+ };
6732
+ /**
6733
+ * Run the queued rebuild once the debounce timer fires. Marks `dirty` (instead of
6734
+ * running) when a rebuild is already underway, otherwise holds the in-flight flag
6735
+ * across a full {@link drainPendingRebuilds} so concurrent changes coalesce into
6736
+ * exactly one extra re-run.
6329
6737
  *
6330
6738
  * @returns Resolves once the rebuild (and any coalesced re-run) settles (errors are
6331
6739
  * routed, never thrown).
@@ -6339,15 +6747,7 @@ function createRebuilder(input) {
6339
6747
  return;
6340
6748
  }
6341
6749
  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);
6750
+ await drainPendingRebuilds();
6351
6751
  building = false;
6352
6752
  };
6353
6753
  return {
@@ -6376,6 +6776,70 @@ function createRebuilder(input) {
6376
6776
  };
6377
6777
  }
6378
6778
  /**
6779
+ * Whether a changed path (relative to a watched dir) is editor/OS noise that is never a
6780
+ * page source: any hidden segment (`.DS_Store`, anything under `.git/` or `.cache/`,
6781
+ * vim `.*.swp`) or a `~` backup file. Checks every segment, not just the basename.
6782
+ *
6783
+ * @param filename - The changed path relative to its watched directory.
6784
+ * @returns `true` when the change should be ignored as noise.
6785
+ * @example
6786
+ * isNoisePath(".git/HEAD"); // true
6787
+ */
6788
+ function isNoisePath(filename) {
6789
+ return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
6790
+ }
6791
+ /**
6792
+ * Create a {@link ChangeGate} that drops three kinds of spurious change events before
6793
+ * they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
6794
+ * `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
6795
+ * echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
6796
+ * a change whose file mtime is at or before the last build we started was already
6797
+ * captured (or is a late echo), so it is ignored — while a genuinely newer edit (even
6798
+ * one made mid-build) and a deletion (missing file) always pass. The single timestamp
6799
+ * also means no per-path map grows over a long session.
6800
+ *
6801
+ * @param input - The gate dependencies.
6802
+ * @param input.outDir - The build output directory whose writes must never re-trigger a build.
6803
+ * @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
6804
+ * @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
6805
+ * @returns The change gate.
6806
+ * @example
6807
+ * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
6808
+ */
6809
+ function createChangeGate(input) {
6810
+ const outDirAbs = path.resolve(input.outDir);
6811
+ let lastBuildStartedAt = input.now();
6812
+ return {
6813
+ /**
6814
+ * Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
6815
+ *
6816
+ * @param dir - The watched directory the event fired on.
6817
+ * @param filename - The changed path relative to `dir` (or `undefined`).
6818
+ * @returns `true` to schedule a rebuild, `false` to ignore.
6819
+ * @example
6820
+ * gate.accept("content", "post/en.md");
6821
+ */
6822
+ accept(dir, filename) {
6823
+ if (filename === void 0) return true;
6824
+ if (isNoisePath(filename)) return false;
6825
+ const changed = path.resolve(dir, filename);
6826
+ if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${path.sep}`)) return false;
6827
+ const mtime = input.fileMtime(changed);
6828
+ if (mtime !== null && mtime < lastBuildStartedAt) return false;
6829
+ return true;
6830
+ },
6831
+ /**
6832
+ * Advance the high-water mark to now (see {@link ChangeGate.markBuildStart}).
6833
+ *
6834
+ * @example
6835
+ * gate.markBuildStart();
6836
+ */
6837
+ markBuildStart() {
6838
+ lastBuildStartedAt = input.now();
6839
+ }
6840
+ };
6841
+ }
6842
+ /**
6379
6843
  * Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
6380
6844
  * promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
6381
6845
  * Ctrl-C / termination and detaches its own listeners. Used by both servers.
@@ -6407,18 +6871,45 @@ function installSignalTeardown(teardown) {
6407
6871
  const SSE_OPEN = ": connected\n\n";
6408
6872
  /** The SSE frame pushed to reload a connected browser. */
6409
6873
  const SSE_RELOAD = "event: reload\ndata: 1\n\n";
6874
+ /** The SSE comment frame sent on the heartbeat to keep an idle stream warm. */
6875
+ const SSE_PING = ": ping\n\n";
6876
+ /** Default heartbeat interval (ms): one ping well under any 30s+ proxy idle window. */
6877
+ const DEFAULT_HEARTBEAT_MS = 15e3;
6410
6878
  /**
6411
6879
  * Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
6412
6880
  * enqueues into a new stream; `reloadAll()` writes the reload frame to every live
6413
- * controller (dropping any that have closed).
6881
+ * controller (dropping any that have closed). A periodic heartbeat comment keeps idle
6882
+ * streams warm — belt-and-suspenders alongside the dev server's `idleTimeout: 0`, so a
6883
+ * quiet connection is never severed (which the browser surfaces as
6884
+ * `ERR_INCOMPLETE_CHUNKED_ENCODING` and then reconnects in a storm).
6414
6885
  *
6886
+ * @param options - Optional heartbeat tuning.
6887
+ * @param options.heartbeatMs - Heartbeat interval in ms (`0` disables). Default `15000`.
6415
6888
  * @returns The reload hub.
6416
6889
  * @example
6417
6890
  * const hub = createReloadHub();
6418
6891
  */
6419
- function createReloadHub() {
6892
+ function createReloadHub(options = {}) {
6420
6893
  const encoder = new TextEncoder();
6421
6894
  const clients = /* @__PURE__ */ new Set();
6895
+ /**
6896
+ * Enqueue one frame to every live controller, dropping any that have closed.
6897
+ *
6898
+ * @param frame - The SSE wire text to broadcast.
6899
+ * @example
6900
+ * broadcast(SSE_RELOAD);
6901
+ */
6902
+ const broadcast = (frame) => {
6903
+ const bytes = encoder.encode(frame);
6904
+ for (const controller of clients) try {
6905
+ controller.enqueue(bytes);
6906
+ } catch {
6907
+ clients.delete(controller);
6908
+ }
6909
+ };
6910
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
6911
+ const heartbeat = heartbeatMs > 0 ? setInterval(() => broadcast(SSE_PING), heartbeatMs) : void 0;
6912
+ heartbeat?.unref?.();
6422
6913
  return {
6423
6914
  /**
6424
6915
  * Open one SSE connection, register its controller, and return the streaming
@@ -6466,11 +6957,7 @@ function createReloadHub() {
6466
6957
  * hub.reloadAll();
6467
6958
  */
6468
6959
  reloadAll() {
6469
- for (const controller of clients) try {
6470
- controller.enqueue(encoder.encode(SSE_RELOAD));
6471
- } catch {
6472
- clients.delete(controller);
6473
- }
6960
+ broadcast(SSE_RELOAD);
6474
6961
  },
6475
6962
  /**
6476
6963
  * The number of currently-connected clients.
@@ -6481,9 +6968,42 @@ function createReloadHub() {
6481
6968
  */
6482
6969
  size() {
6483
6970
  return clients.size;
6971
+ },
6972
+ /**
6973
+ * Stop the heartbeat and close every live SSE stream (SIGINT/SIGTERM teardown).
6974
+ *
6975
+ * @example
6976
+ * hub.close();
6977
+ */
6978
+ close() {
6979
+ if (heartbeat !== void 0) clearInterval(heartbeat);
6980
+ for (const controller of clients) try {
6981
+ controller.close();
6982
+ } catch {}
6983
+ clients.clear();
6484
6984
  }
6485
6985
  };
6486
6986
  }
6987
+ /** The content-type sent on rewritten HTML responses (live-reload injection). */
6988
+ const HTML_CONTENT_TYPE = "text/html; charset=utf-8";
6989
+ /**
6990
+ * Re-render a static file response with the live-reload client injected, preserving
6991
+ * the resolved status. Reads the original body to text so {@link injectReloadClient}
6992
+ * can splice the snippet in before `</body>`.
6993
+ *
6994
+ * @param response - The original static file response to rewrite.
6995
+ * @param status - The resolved status to carry onto the rewritten response.
6996
+ * @returns A fresh HTML response containing the injected reload client.
6997
+ * @example
6998
+ * const injected = await injectReloadResponse(fileResponse, 200);
6999
+ */
7000
+ async function injectReloadResponse(response, status) {
7001
+ const html = injectReloadClient(await response.text());
7002
+ return new Response(html, {
7003
+ status,
7004
+ headers: { "content-type": HTML_CONTENT_TYPE }
7005
+ });
7006
+ }
6487
7007
  /**
6488
7008
  * Build the live-reload-aware request handler for the dev server: serves the SSE
6489
7009
  * stream at {@link RELOAD_PATH}, injects the reload client into HTML responses (when
@@ -6503,13 +7023,7 @@ function createDevHandler(ctx, hub) {
6503
7023
  const resolved = resolveCleanUrl(ctx.config.outDir, pathname);
6504
7024
  if (resolved.file === null) return new Response("Not Found", { status: 404 });
6505
7025
  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
- }
7026
+ if (ctx.config.liveReload && resolved.file.endsWith(".html")) return injectReloadResponse(response, resolved.status);
6513
7027
  return response;
6514
7028
  };
6515
7029
  }
@@ -6530,8 +7044,14 @@ async function runDevServer(ctx, port) {
6530
7044
  const hub = createReloadHub();
6531
7045
  const server = ctx.state.serveStatic({
6532
7046
  port,
7047
+ idleTimeout: 0,
6533
7048
  fetch: createDevHandler(ctx, hub)
6534
7049
  });
7050
+ const gate = createChangeGate({
7051
+ outDir: ctx.config.outDir,
7052
+ fileMtime: ctx.state.fileMtime,
7053
+ now: ctx.state.clock
7054
+ });
6535
7055
  const rebuilder = createRebuilder({
6536
7056
  debounceMs: ctx.config.debounceMs,
6537
7057
  /**
@@ -6545,6 +7065,17 @@ async function runDevServer(ctx, port) {
6545
7065
  return ctx.require(buildPlugin).run();
6546
7066
  },
6547
7067
  /**
7068
+ * Show the compact in-place "rebuilding {label}" line before the build runs.
7069
+ *
7070
+ * @param file - The changed watch target shown in the line.
7071
+ * @example
7072
+ * onRebuildStart("content");
7073
+ */
7074
+ onRebuildStart(file) {
7075
+ gate.markBuildStart();
7076
+ ctx.state.render.rebuildStart(file);
7077
+ },
7078
+ /**
6548
7079
  * Render the reload line and push a browser reload after a rebuild.
6549
7080
  *
6550
7081
  * @param info - The changed file plus the rebuild's page count and duration.
@@ -6566,7 +7097,9 @@ async function runDevServer(ctx, port) {
6566
7097
  ctx.state.render.error("rebuild failed", error);
6567
7098
  }
6568
7099
  });
6569
- const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, () => rebuilder.schedule(dir)));
7100
+ const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
7101
+ if (gate.accept(dir, filename)) rebuilder.schedule(dir);
7102
+ }));
6570
7103
  ctx.state.render.serverReady({
6571
7104
  local: `http://localhost:${port}`,
6572
7105
  network: ctx.state.networkUrl(port),
@@ -6575,6 +7108,7 @@ async function runDevServer(ctx, port) {
6575
7108
  return installSignalTeardown(() => {
6576
7109
  rebuilder.cancel();
6577
7110
  for (const watcher of watchers) watcher.close();
7111
+ hub.close();
6578
7112
  server.stop();
6579
7113
  });
6580
7114
  }
@@ -6727,6 +7261,56 @@ function validateConfig(config) {
6727
7261
  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
7262
  }
6729
7263
  /**
7264
+ * Assert the SSG emitted the not-found page, rendering a hint and throwing
7265
+ * `ERR_CLI_NOT_FOUND` when it is missing (CF Pages flips to SPA mode without a
7266
+ * top-level 404). A no-op when the page exists.
7267
+ *
7268
+ * @param ctx - Plugin context (provides `state.render` for the failure hint).
7269
+ * @param page - The absolute path the not-found page is expected at.
7270
+ * @throws {Error} `ERR_CLI_NOT_FOUND` when the not-found page is missing.
7271
+ * @example
7272
+ * assertNotFoundPage(ctx, path.join(ctx.config.outDir, ctx.config.notFoundFile));
7273
+ */
7274
+ function assertNotFoundPage(ctx, page) {
7275
+ if (existsSync(page)) return;
7276
+ ctx.state.render.error(`${page} missing — set build.notFound (CF Pages would flip to SPA mode)`);
7277
+ 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.`);
7278
+ }
7279
+ /**
7280
+ * Whether the deploy confirmation prompt should be shown to a human. True only on
7281
+ * an interactive TTY with `CI` unset and when the caller has not passed `yes`; CI
7282
+ * and non-TTY runs are not prompted so consumer scripts never block a pipeline.
7283
+ *
7284
+ * @param yes - The caller's `yes` flag (forces the prompt to be skipped anywhere).
7285
+ * @returns `true` when a confirmation prompt should be shown, otherwise `false`.
7286
+ * @example
7287
+ * if (shouldPromptDeploy(false)) { ... }
7288
+ */
7289
+ function shouldPromptDeploy(yes) {
7290
+ return process.stdout.isTTY === true && process.env.CI === void 0 && !yes;
7291
+ }
7292
+ /**
7293
+ * Resolve whether a deploy may proceed, handling the human/non-interactive split:
7294
+ * prompts an interactive TTY user (rendering the "skipped" warning on a "no"),
7295
+ * renders the non-interactive notice when no prompt and no `yes`, and otherwise
7296
+ * proceeds silently. Returns whether the caller should run the deploy.
7297
+ *
7298
+ * @param ctx - Plugin context (provides `state.confirm` and `state.render`).
7299
+ * @param yes - The caller's `yes` flag (forces the skip anywhere).
7300
+ * @returns `true` when the deploy should run, `false` when an interactive user declined.
7301
+ * @example
7302
+ * if (!(await confirmDeploy(ctx, false))) return { deployed: false, reason: "declined" };
7303
+ */
7304
+ async function confirmDeploy(ctx, yes) {
7305
+ if (!shouldPromptDeploy(yes)) {
7306
+ if (!yes) ctx.state.render.info("non-interactive — skipping deploy confirmation");
7307
+ return true;
7308
+ }
7309
+ const confirmed = await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to cloudflare-pages?`);
7310
+ if (!confirmed) ctx.state.render.warn("deploy skipped");
7311
+ return confirmed;
7312
+ }
7313
+ /**
6730
7314
  * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
6731
7315
  * Each method renders `state.render.header(<command>)` first, then does its work;
6732
7316
  * live progress is rendered by the hooks wired in `index.ts`, so each method's
@@ -6753,11 +7337,7 @@ function createApi$1(ctx) {
6753
7337
  const { assertNotFound = true } = options;
6754
7338
  ctx.state.render.header("build");
6755
7339
  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
- }
7340
+ if (assertNotFound) assertNotFoundPage(ctx, path.join(ctx.config.outDir, ctx.config.notFoundFile));
6761
7341
  return result;
6762
7342
  },
6763
7343
  /**
@@ -6788,30 +7368,27 @@ function createApi$1(ctx) {
6788
7368
  return runPreviewServer(ctx, port);
6789
7369
  },
6790
7370
  /**
6791
- * Scaffold, then deploy. A y/N confirm is shown only when a human is present (an
6792
- * interactive TTY, with `CI` unset). Non-interactive runs (CI, or any non-TTY)
6793
- * skip the prompt and deploy, so the consumer scripts never hang a pipeline.
6794
- * `options.yes` forces the skip anywhere. An interactive "no" returns
6795
- * `{ deployed: false, reason: "declined" }`.
7371
+ * Deploy to Cloudflare Pages. Two modes: `{ guided: true }` runs the interactive
7372
+ * setup wizard (diagnose prerequisites, guide fixes, gate, local test, deploy, offer
7373
+ * a CI workflow); the default direct path scaffolds, gates on a y/N confirm (shown
7374
+ * only to an interactive TTY with `CI` unset non-interactive runs proceed so a
7375
+ * pipeline never hangs), then deploys. `options.yes` forces the skip. A "no" returns
7376
+ * `{ deployed: false, reason: "declined" }`; the wizard may return `"blocked"`.
6796
7377
  *
6797
- * @param options - Optional branch override and `yes` flag.
6798
- * @returns The deploy outcome (completed details, or `declined` if a TTY user says no).
7378
+ * @param options - Optional branch override, `yes` flag, and `guided` toggle.
7379
+ * @returns The deploy outcome (completed details, or a `declined`/`blocked` skip).
6799
7380
  * @example
6800
- * await api.deploy({ branch: "preview/x", yes: true });
7381
+ * await api.deploy({ guided: true });
6801
7382
  */
6802
7383
  async deploy(options = {}) {
6803
7384
  const { branch, yes = false } = options;
6804
7385
  ctx.state.render.header("deploy");
7386
+ if (options.guided === true) return runDeployWizard(ctx, options);
6805
7387
  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");
7388
+ if (!await confirmDeploy(ctx, yes)) return {
7389
+ deployed: false,
7390
+ reason: "declined"
7391
+ };
6815
7392
  return {
6816
7393
  deployed: true,
6817
7394
  ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
@@ -6906,6 +7483,39 @@ const ANSI = {
6906
7483
  cyan: `${ESC}[36m`,
6907
7484
  gray: `${ESC}[90m`
6908
7485
  };
7486
+ /** ANSI: erase the entire current line, leaving the cursor where it is. */
7487
+ const CLEAR_LINE = `${ESC}[2K`;
7488
+ /** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
7489
+ const CLEAR_BELOW = `${ESC}[0J`;
7490
+ /**
7491
+ * Braille spinner frames for live "working…" indicators on a TTY (advance one per tick).
7492
+ * Off a TTY the Panel never animates, so this is unused in plain/CI output.
7493
+ */
7494
+ const SPINNER_FRAMES = [
7495
+ "⠋",
7496
+ "⠙",
7497
+ "⠹",
7498
+ "⠸",
7499
+ "⠼",
7500
+ "⠴",
7501
+ "⠦",
7502
+ "⠧",
7503
+ "⠇",
7504
+ "⠏"
7505
+ ];
7506
+ /**
7507
+ * The ANSI sequence to move the cursor up `n` lines (empty string for `n <= 0`). The
7508
+ * Panel uses it to repaint a live block in place — move up over the previous draw, then
7509
+ * rewrite each row — so progress updates a fixed region instead of scrolling new lines.
7510
+ *
7511
+ * @param n - Number of lines to move the cursor up.
7512
+ * @returns The cursor-up escape sequence, or `""` when `n <= 0`.
7513
+ * @example
7514
+ * cursorUp(3); // "\x1b[3A"
7515
+ */
7516
+ function cursorUp(n) {
7517
+ return n > 0 ? `${ESC}[${n}A` : "";
7518
+ }
6909
7519
  /** Unicode rounded box glyphs used when output is a color-capable TTY. */
6910
7520
  const UNICODE_BOX = {
6911
7521
  topLeft: "╭",
@@ -7122,8 +7732,97 @@ function durationSuffix(palette, durationMs) {
7122
7732
  function createPanelRenderer(options = {}) {
7123
7733
  const write = options.write ?? ((line) => console.log(line));
7124
7734
  const writeError = options.writeError ?? ((line) => console.error(line));
7735
+ const writeRaw = options.writeRaw ?? ((chunk) => {
7736
+ process.stdout.write(chunk);
7737
+ });
7738
+ const now = options.now ?? Date.now;
7125
7739
  const color = options.color ?? supportsColor();
7126
7740
  const palette = makePalette(color);
7741
+ let phaseRows = [];
7742
+ let phaseDrawn = 0;
7743
+ let phaseOpen = false;
7744
+ let rebuilding = false;
7745
+ let rebuildLabel = "";
7746
+ let rebuildStartedAt = 0;
7747
+ let spinnerFrame = 0;
7748
+ let ticker;
7749
+ /**
7750
+ * The current spinner glyph (with a static fallback under `noUncheckedIndexedAccess`).
7751
+ *
7752
+ * @returns The active braille spinner frame.
7753
+ * @example
7754
+ * frameGlyph(); // "⠙"
7755
+ */
7756
+ const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
7757
+ /**
7758
+ * Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
7759
+ * before the dim name.
7760
+ *
7761
+ * @param row - The phase row to render.
7762
+ * @returns The rendered row line (no trailing newline).
7763
+ * @example
7764
+ * renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
7765
+ */
7766
+ const renderPhaseRow = (row) => {
7767
+ if (row.done) return ` ${palette.green("✓")} ${row.name}${durationSuffix(palette, row.durationMs)}`;
7768
+ return ` ${palette.cyan(frameGlyph())} ${palette.dim(row.name)}`;
7769
+ };
7770
+ /**
7771
+ * Repaint the live phase block in place: move up over the prior draw, then rewrite each
7772
+ * row (clearing any stale trailing lines).
7773
+ *
7774
+ * @example
7775
+ * paintPhaseBlock();
7776
+ */
7777
+ const paintPhaseBlock = () => {
7778
+ let frame = cursorUp(phaseDrawn);
7779
+ for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
7780
+ writeRaw(frame + CLEAR_BELOW);
7781
+ phaseDrawn = phaseRows.length;
7782
+ };
7783
+ /**
7784
+ * Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
7785
+ *
7786
+ * @example
7787
+ * paintRebuildLine();
7788
+ */
7789
+ const paintRebuildLine = () => {
7790
+ const elapsed = ((now() - rebuildStartedAt) / 1e3).toFixed(1);
7791
+ const meta = palette.dim(`· ${elapsed}s`);
7792
+ writeRaw(`\r${CLEAR_LINE} ${palette.cyan(frameGlyph())} rebuilding ${rebuildLabel} ${meta}`);
7793
+ };
7794
+ /**
7795
+ * Advance the spinner one frame and repaint whichever live region is active.
7796
+ *
7797
+ * @example
7798
+ * onTick();
7799
+ */
7800
+ const onTick = () => {
7801
+ spinnerFrame += 1;
7802
+ if (rebuilding) paintRebuildLine();
7803
+ else if (phaseOpen) paintPhaseBlock();
7804
+ };
7805
+ /**
7806
+ * Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
7807
+ *
7808
+ * @example
7809
+ * startTicker();
7810
+ */
7811
+ const startTicker = () => {
7812
+ if (!color || ticker) return;
7813
+ ticker = setInterval(onTick, 80);
7814
+ ticker.unref?.();
7815
+ };
7816
+ /**
7817
+ * Stop the animation ticker if running.
7818
+ *
7819
+ * @example
7820
+ * stopTicker();
7821
+ */
7822
+ const stopTicker = () => {
7823
+ if (ticker) clearInterval(ticker);
7824
+ ticker = void 0;
7825
+ };
7127
7826
  /**
7128
7827
  * Write each line of a multi-line block through the stdout sink.
7129
7828
  *
@@ -7146,15 +7845,38 @@ function createPanelRenderer(options = {}) {
7146
7845
  writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
7147
7846
  },
7148
7847
  /**
7149
- * Render a live per-phase row from a `build:phase` event.
7848
+ * Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE row
7849
+ * that updates in place (spinning glyph while running → green ✓ + duration when done);
7850
+ * off a TTY one line is printed per completed phase (no start/done duplication). A
7851
+ * no-op while a serve() rebuild is in flight — those show the compact rebuild line.
7150
7852
  *
7151
7853
  * @param phase - The `build:phase` payload.
7152
7854
  * @example
7153
7855
  * render.phase({ phase: "pages", status: "done", durationMs: 12 });
7154
7856
  */
7155
7857
  phase(phase) {
7858
+ if (rebuilding) return;
7859
+ if (!color) {
7860
+ if (phase.status === "done") write(` ${palette.green("✓")} ${phase.phase}${durationSuffix(palette, phase.durationMs)}`);
7861
+ return;
7862
+ }
7863
+ if (!phaseOpen) {
7864
+ phaseRows = [];
7865
+ phaseDrawn = 0;
7866
+ phaseOpen = true;
7867
+ }
7156
7868
  const done = phase.status === "done";
7157
- write(` ${done ? palette.green("✓") : palette.dim("•")} ${done ? phase.phase : palette.dim(phase.phase)}${durationSuffix(palette, phase.durationMs)}`);
7869
+ const existing = phaseRows.find((row) => row.name === phase.phase);
7870
+ if (existing) {
7871
+ existing.done = done;
7872
+ existing.durationMs = phase.durationMs;
7873
+ } else phaseRows.push({
7874
+ name: phase.phase,
7875
+ done,
7876
+ durationMs: phase.durationMs
7877
+ });
7878
+ paintPhaseBlock();
7879
+ startTicker();
7158
7880
  },
7159
7881
  /**
7160
7882
  * Render the BUILD summary block from a `build:complete` event.
@@ -7164,6 +7886,10 @@ function createPanelRenderer(options = {}) {
7164
7886
  * render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
7165
7887
  */
7166
7888
  built(summary) {
7889
+ if (rebuilding) return;
7890
+ phaseOpen = false;
7891
+ phaseDrawn = 0;
7892
+ stopTicker();
7167
7893
  const pages = palette.bold(String(summary.pageCount));
7168
7894
  writeBlock(box([
7169
7895
  `${palette.green("✓")} ${palette.bold("BUILD")} complete`,
@@ -7185,15 +7911,47 @@ function createPanelRenderer(options = {}) {
7185
7911
  writeBlock(box(lines, color));
7186
7912
  },
7187
7913
  /**
7188
- * Render the post-rebuild line ("~ file" + " rebuilt N pages · Xms · reloaded").
7914
+ * Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
7915
+ * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise) and mute
7916
+ * the verbose phase rows + BUILD box until {@link reload}/{@link error} settles it.
7917
+ *
7918
+ * @param label - The changed watch target shown in the line.
7919
+ * @example
7920
+ * render.rebuildStart("content");
7921
+ */
7922
+ rebuildStart(label) {
7923
+ rebuilding = true;
7924
+ rebuildLabel = label;
7925
+ rebuildStartedAt = now();
7926
+ spinnerFrame = 0;
7927
+ if (!color) {
7928
+ write(` ${palette.yellow("~")} ${label}`);
7929
+ return;
7930
+ }
7931
+ paintRebuildLine();
7932
+ startTicker();
7933
+ },
7934
+ /**
7935
+ * Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
7936
+ * "✓ rebuilt N pages · Xms · reloaded" (on a TTY) and re-enable verbose build output.
7937
+ * Called standalone (no preceding {@link rebuildStart}) it also prints the "~ file"
7938
+ * line so the changed target stays visible.
7189
7939
  *
7190
7940
  * @param info - The changed file plus the rebuild's page count and duration.
7191
7941
  * @example
7192
7942
  * render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
7193
7943
  */
7194
7944
  reload(info) {
7195
- write(` ${palette.yellow("~")} ${info.file}`);
7196
- write(` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · browser reloaded`)}`);
7945
+ const settledRebuild = rebuilding;
7946
+ rebuilding = false;
7947
+ stopTicker();
7948
+ const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
7949
+ if (settledRebuild && color) {
7950
+ writeRaw(`\r${CLEAR_LINE}${line}\n`);
7951
+ return;
7952
+ }
7953
+ if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
7954
+ write(line);
7197
7955
  },
7198
7956
  /**
7199
7957
  * Render the deploy result panel from a `deploy:complete` event.
@@ -7219,7 +7977,9 @@ function createPanelRenderer(options = {}) {
7219
7977
  * render.info("watching for changes…");
7220
7978
  */
7221
7979
  info(message) {
7222
- write(` ${palette.cyan("")} ${message}`);
7980
+ const [first = "", ...rest] = message.split("\n");
7981
+ write(` ${palette.cyan("›")} ${first}`);
7982
+ for (const line of rest) write(` ${line}`);
7223
7983
  },
7224
7984
  /**
7225
7985
  * Render a warning line (to stderr).
@@ -7240,8 +8000,38 @@ function createPanelRenderer(options = {}) {
7240
8000
  * render.error("build failed", err);
7241
8001
  */
7242
8002
  error(message, cause) {
8003
+ if (rebuilding) {
8004
+ rebuilding = false;
8005
+ stopTicker();
8006
+ if (color) writeRaw(`\r${CLEAR_LINE}`);
8007
+ }
7243
8008
  writeError(` ${palette.red("✗")} ${message}`);
7244
8009
  if (cause !== void 0) writeError(String(cause));
8010
+ },
8011
+ /**
8012
+ * Render a section heading (a blank line + a bold cyan label) for a multi-step flow.
8013
+ *
8014
+ * @param text - The heading label.
8015
+ * @example
8016
+ * render.heading("Diagnostics");
8017
+ */
8018
+ heading(text) {
8019
+ write("");
8020
+ write(` ${palette.bold(palette.cyan(text))}`);
8021
+ },
8022
+ /**
8023
+ * Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
8024
+ * dim, indented detail beneath (e.g. a fix hint for a failing check).
8025
+ *
8026
+ * @param ok - Whether the check passed.
8027
+ * @param label - The check label.
8028
+ * @param detail - Optional multi-line guidance shown indented under the line.
8029
+ * @example
8030
+ * render.check(false, "CLOUDFLARE_API_TOKEN is set", "Create one at …");
8031
+ */
8032
+ check(ok, label, detail) {
8033
+ write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
8034
+ if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
7245
8035
  }
7246
8036
  };
7247
8037
  }
@@ -7320,18 +8110,44 @@ function defaultConfirm(question) {
7320
8110
  });
7321
8111
  }
7322
8112
  /**
8113
+ * Default stdin single-choice prompt. Prints the choices numbered from 1, reads a line
8114
+ * via `node:readline`, and resolves the chosen zero-based index (empty / out-of-range
8115
+ * falls back to 0). Tests inject a canned selection so no real TTY interaction occurs.
8116
+ *
8117
+ * @param question - The prompt to display.
8118
+ * @param choices - The selectable option labels.
8119
+ * @returns Resolves the chosen zero-based index.
8120
+ * @example
8121
+ * await defaultSelect("Trigger?", ["Auto on push", "Manual only"]);
8122
+ */
8123
+ function defaultSelect(question, choices) {
8124
+ return new Promise((resolve) => {
8125
+ const readline = createInterface({
8126
+ input: process.stdin,
8127
+ output: process.stdout
8128
+ });
8129
+ for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
8130
+ readline.question(`${question} [1-${choices.length}] `, (answer) => {
8131
+ readline.close();
8132
+ const picked = Number.parseInt(answer.trim(), 10);
8133
+ resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
8134
+ });
8135
+ });
8136
+ }
8137
+ /**
7323
8138
  * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
7324
8139
  * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
7325
8140
  * FS watch is registered.
7326
8141
  *
7327
8142
  * @param dir - The directory to watch recursively.
7328
- * @param onChange - Invoked on any change beneath `dir`.
8143
+ * @param onChange - Invoked on any change beneath `dir`, forwarding the changed path
8144
+ * relative to `dir` when `node:fs.watch` reports it (`undefined` otherwise).
7329
8145
  * @returns A handle whose `close()` detaches the watcher.
7330
8146
  * @example
7331
- * const handle = defaultWatch("content", () => rebuild());
8147
+ * const handle = defaultWatch("content", file => rebuild(file));
7332
8148
  */
7333
8149
  function defaultWatch(dir, onChange) {
7334
- const watcher = watch(dir, { recursive: true }, () => onChange());
8150
+ const watcher = watch(dir, { recursive: true }, (_event, filename) => onChange(typeof filename === "string" ? filename : void 0));
7335
8151
  return {
7336
8152
  /**
7337
8153
  * Detach the underlying `node:fs.watch` listener.
@@ -7344,6 +8160,23 @@ close() {
7344
8160
  } };
7345
8161
  }
7346
8162
  /**
8163
+ * Default file-mtime probe — `node:fs.statSync(path).mtimeMs`, returning `null` for a
8164
+ * missing path (so a deleted file still reads as a change). serve() compares this
8165
+ * across `fs.watch` events to drop the duplicate notifications macOS fires per save.
8166
+ *
8167
+ * @param filePath - The absolute path to stat.
8168
+ * @returns The modification time in epoch milliseconds, or `null` when absent.
8169
+ * @example
8170
+ * const mtime = defaultFileMtime("/abs/content/a.md");
8171
+ */
8172
+ function defaultFileMtime(filePath) {
8173
+ try {
8174
+ return statSync(filePath).mtimeMs;
8175
+ } catch {
8176
+ return null;
8177
+ }
8178
+ }
8179
+ /**
7347
8180
  * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
7348
8181
  * reads `node:os` interfaces while tests can inject a deterministic value.
7349
8182
  *
@@ -7371,11 +8204,13 @@ function createState$1(_ctx) {
7371
8204
  return {
7372
8205
  render: createPanelRenderer(),
7373
8206
  confirm: defaultConfirm,
8207
+ select: defaultSelect,
7374
8208
  clock: Date.now,
7375
8209
  watch: defaultWatch,
7376
8210
  serveStatic: defaultServeStatic,
7377
8211
  fileResponse: defaultFileResponse,
7378
- networkUrl: defaultNetworkUrl
8212
+ networkUrl: defaultNetworkUrl,
8213
+ fileMtime: defaultFileMtime
7379
8214
  };
7380
8215
  }
7381
8216
  //#endregion
@@ -7502,6 +8337,22 @@ const ERROR_PREFIX$2 = "[web]";
7502
8337
  /** The set of legal hook names, frozen for O(1) membership checks. */
7503
8338
  const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
7504
8339
  /**
8340
+ * Validate a single hook entry: its key must be a known hook name and its value
8341
+ * must be a function. Throws fail-fast on the first violation.
8342
+ *
8343
+ * @param componentName - The owning component name (for error messages).
8344
+ * @param hooks - The hooks object being validated.
8345
+ * @param key - The hook key to validate.
8346
+ * @throws {Error} If `key` is not in `COMPONENT_HOOK_NAMES`.
8347
+ * @throws {TypeError} If the hook value is not a function.
8348
+ * @example
8349
+ * validateHookEntry("counter", hooks, "onMount");
8350
+ */
8351
+ function validateHookEntry(componentName, hooks, key) {
8352
+ 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(", ")}`);
8353
+ 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`);
8354
+ }
8355
+ /**
7505
8356
  * Create a validated component definition. Validates hook names at registration
7506
8357
  * for fail-fast typo detection (e.g. `onMout` throws immediately) and asserts
7507
8358
  * each provided hook is a function.
@@ -7518,10 +8369,7 @@ const HOOK_NAME_SET = new Set(COMPONENT_HOOK_NAMES);
7518
8369
  */
7519
8370
  function createComponent(name, hooks) {
7520
8371
  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
- }
8372
+ for (const key of Object.keys(hooks)) validateHookEntry(name, hooks, key);
7525
8373
  return {
7526
8374
  name,
7527
8375
  hooks
@@ -7591,6 +8439,36 @@ function makeContext(element, data) {
7591
8439
  };
7592
8440
  }
7593
8441
  /**
8442
+ * Mounts a single `data-component` element: classifies persistent vs
8443
+ * page-specific, builds the instance, fires `onCreate` then `onMount`, records
8444
+ * it in state, and emits `spa:component-mount`. No-ops if the element is already
8445
+ * mounted, has no component name, or names an unregistered component.
8446
+ *
8447
+ * @param state - The plugin state (registeredComponents + instances).
8448
+ * @param emit - The event emitter for spa:component-mount.
8449
+ * @param swapArea - The swap-region element, or null when none was found.
8450
+ * @param data - The current page data payload.
8451
+ * @param element - The candidate element carrying a `data-component` attribute.
8452
+ * @example
8453
+ * mountElement(state, emit, swapArea, data, element);
8454
+ */
8455
+ function mountElement(state, emit, swapArea, data, element) {
8456
+ if (state.instances.has(element)) return;
8457
+ const name = element.dataset.component;
8458
+ if (!name) return;
8459
+ const definition = state.registeredComponents.get(name);
8460
+ if (!definition) return;
8461
+ const instance = createInstance(definition, element, swapArea ? !swapArea.contains(element) : true);
8462
+ const ctx = makeContext(element, data);
8463
+ runHook(instance, "onCreate", ctx);
8464
+ runHook(instance, "onMount", ctx);
8465
+ state.instances.set(element, instance);
8466
+ emit("spa:component-mount", {
8467
+ name: definition.name,
8468
+ el: element
8469
+ });
8470
+ }
8471
+ /**
7594
8472
  * Scans the swap region, mounts components for matching `data-component`
7595
8473
  * elements, classifies persistent (outside swap area) vs page-specific (inside),
7596
8474
  * fires `onCreate` then `onMount`, and emits `spa:component-mount` per instance.
@@ -7606,22 +8484,7 @@ function scanAndMount(state, emit, swapSelector) {
7606
8484
  if (typeof document === "undefined") return;
7607
8485
  const swapArea = document.querySelector(swapSelector);
7608
8486
  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
- }
8487
+ for (const element of document.querySelectorAll("[data-component]")) mountElement(state, emit, swapArea, data, element);
7625
8488
  }
7626
8489
  /**
7627
8490
  * Unmounts page-specific instances inside the swap region (runs `onUnMount`
@@ -8092,6 +8955,8 @@ function attachRouter(handlers, navigate) {
8092
8955
  //#region src/plugins/spa/state.ts
8093
8956
  /** Error prefix for spa config-validation failures (spec/11 Part-3). */
8094
8957
  const ERROR_PREFIX$1 = "[web]";
8958
+ /** Last-resort `swapSelector` when neither config nor defaults supply one. */
8959
+ const FALLBACK_SWAP_SELECTOR = "main > section";
8095
8960
  /** Default SPA config (declared as a value — no inline assertion). */
8096
8961
  const defaultSpaConfig = {
8097
8962
  swapSelector: "main > section",
@@ -8129,7 +8994,7 @@ function isValidSelector(selector) {
8129
8994
  * const resolved = resolveSpaConfig({ swapSelector: "main > section" });
8130
8995
  */
8131
8996
  function resolveSpaConfig(config) {
8132
- const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? "main > section";
8997
+ const swapSelector = config.swapSelector ?? defaultSpaConfig.swapSelector ?? FALLBACK_SWAP_SELECTOR;
8133
8998
  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
8999
  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
9000
  return {
@@ -8848,6 +9713,35 @@ function pullQuoteTransform(tree) {
8848
9713
  }
8849
9714
  });
8850
9715
  }
9716
+ /** CSS class for the divider wrapper that replaces an `<hr>`. */
9717
+ const SECTION_DIVIDER_CLASS = "section-divider";
9718
+ /** CSS class for the inner ornament span inside the section divider. */
9719
+ const SECTION_DIVIDER_ORNAMENT_CLASS = "section-divider-ornament";
9720
+ /** Glyphs rendered inside the section-divider ornament span. */
9721
+ const SECTION_DIVIDER_ORNAMENT = "***";
9722
+ /**
9723
+ * Rewrite one `<hr>` element in place into an ornamental section divider:
9724
+ * a `<div>` wrapper carrying a single ornament `<span>`.
9725
+ *
9726
+ * @param node - The hast element to rewrite (expected to be an `<hr>`).
9727
+ * @example
9728
+ * ```ts
9729
+ * rewriteHrToDivider(node);
9730
+ * ```
9731
+ */
9732
+ function rewriteHrToDivider(node) {
9733
+ node.tagName = "div";
9734
+ node.properties = { class: SECTION_DIVIDER_CLASS };
9735
+ node.children = [{
9736
+ type: "element",
9737
+ tagName: "span",
9738
+ properties: { class: SECTION_DIVIDER_ORNAMENT_CLASS },
9739
+ children: [{
9740
+ type: "text",
9741
+ value: SECTION_DIVIDER_ORNAMENT
9742
+ }]
9743
+ }];
9744
+ }
8851
9745
  /**
8852
9746
  * Hast transformer rewriting `<hr>` into an ornamental section divider.
8853
9747
  *
@@ -8859,19 +9753,8 @@ function pullQuoteTransform(tree) {
8859
9753
  */
8860
9754
  function sectionDividerTransform(tree) {
8861
9755
  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
- }
9756
+ if (node.tagName !== "hr") return;
9757
+ rewriteHrToDivider(node);
8875
9758
  });
8876
9759
  }
8877
9760
  /**