@moku-labs/web 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -10,7 +10,9 @@ node_path = require_convention.__toESM(node_path);
10
10
  let feed = require("feed");
11
11
  let preact = require("preact");
12
12
  let preact_render_to_string = require("preact-render-to-string");
13
+ let node_child_process = require("node:child_process");
13
14
  let node_readline = require("node:readline");
15
+ let node_url = require("node:url");
14
16
  let node_os = require("node:os");
15
17
  let gray_matter = require("gray-matter");
16
18
  gray_matter = require_convention.__toESM(gray_matter, 1);
@@ -1293,18 +1295,24 @@ function isPublished(article, isProduction) {
1293
1295
  * locale collection: existing files only, drafts dropped in production, sorted
1294
1296
  * date-descending. The single load+filter+sort step behind {@link createContentApi.loadAll}.
1295
1297
  *
1298
+ * When a `cached` map is supplied (incremental dev rebuild), a slug already present in it
1299
+ * is reused as-is (skipping the re-read + Markdown/Shiki re-render); only slugs absent
1300
+ * from it — the ones a preceding `invalidate()` dropped, plus any never-loaded — are
1301
+ * resolved fresh. With no `cached` map every slug is resolved (the full load).
1302
+ *
1296
1303
  * @param ctx - Kernel-free domain context (provider + i18n helpers + stage).
1297
1304
  * @param slugs - Every known article slug from the provider.
1298
1305
  * @param locale - The locale to resolve and collect.
1306
+ * @param cached - Optional per-locale article cache to reuse for unchanged slugs.
1299
1307
  * @returns The published (date-descending) articles for this locale.
1300
1308
  * @example
1301
1309
  * ```ts
1302
1310
  * const present = await loadAndFilterArticles(ctx, slugs, "en");
1303
1311
  * ```
1304
1312
  */
1305
- async function loadAndFilterArticles(ctx, slugs, locale) {
1313
+ async function loadAndFilterArticles(ctx, slugs, locale, cached) {
1306
1314
  const isProduction = ctx.global.stage === "production";
1307
- return (await Promise.all(slugs.map((slug) => resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
1315
+ return (await Promise.all(slugs.map((slug) => cached?.get(slug) ?? resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
1308
1316
  }
1309
1317
  /**
1310
1318
  * Derive the article slug from a source file path — the parent directory name
@@ -1341,19 +1349,26 @@ function createContentApi(ctx) {
1341
1349
  * Load every article across every active locale (locale fallback, production
1342
1350
  * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
1343
1351
  *
1352
+ * With `{ reuse: true }` (dev incremental rebuild) cached articles are reused for
1353
+ * every slug a preceding `invalidate()` did not drop, so only the dirty articles
1354
+ * re-read + re-run the Markdown/Shiki pipeline; the `contentId` ordinals are still
1355
+ * recomputed across the FULL sorted set, so ids + order match a full load.
1356
+ *
1357
+ * @param options - Optional load behaviour (`reuse`); omit for a full load.
1344
1358
  * @returns A locale-keyed map of date-descending articles.
1345
1359
  * @example
1346
1360
  * ```ts
1347
1361
  * const byLocale = await api.loadAll();
1348
1362
  * ```
1349
1363
  */
1350
- async loadAll() {
1364
+ async loadAll(options) {
1365
+ const reuse = options?.reuse === true;
1351
1366
  const slugs = await ctx.provider.slugs();
1352
1367
  const locales = ctx.locales();
1353
1368
  const result = /* @__PURE__ */ new Map();
1354
1369
  let total = 0;
1355
1370
  for (const locale of locales) {
1356
- const present = await loadAndFilterArticles(ctx, slugs, locale);
1371
+ const present = await loadAndFilterArticles(ctx, slugs, locale, reuse ? ctx.state.articles.get(locale) : void 0);
1357
1372
  const cache = /* @__PURE__ */ new Map();
1358
1373
  let index = 0;
1359
1374
  for (const article of present) {
@@ -3354,7 +3369,11 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3354
3369
  /**
3355
3370
  * Bundles CSS and JS into the output directory via two separate runner passes
3356
3371
  * (dodging Bun's mixed-entrypoint segfault), honoring `config.minify`, and caches
3357
- * the resulting hashed asset paths in `state.buildCache` for downstream phases.
3372
+ * the resulting hashed asset paths in `state.buildCache` for downstream phases. The
3373
+ * two passes run CONCURRENTLY (`Promise.all`) — they target disjoint hashed outputs
3374
+ * and distinct `buildCache` keys (`css`/`js`), so overlapping them ~halves bundle
3375
+ * wall-time with no shared-state hazard. The CSS pass is still dispatched first, so
3376
+ * the runner's invocation order stays css-then-js.
3358
3377
  *
3359
3378
  * @param ctx - Plugin context (provides `state`, `config`, `log`).
3360
3379
  * @param options - Optional dependency-injection seam (runner + entrypoints).
@@ -3367,10 +3386,10 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3367
3386
  async function bundle(ctx, options = {}) {
3368
3387
  const runner = options.runner ?? defaultRunner;
3369
3388
  const { minify, outDir } = ctx.config;
3389
+ const assetsDir = node_path$1.default.join(outDir, "assets");
3370
3390
  const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
3371
3391
  const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
3372
- await runOne(ctx, runner, "css", cssEntrypoints, outDir, node_path$1.default.join(outDir, "assets"), minify);
3373
- await runOne(ctx, runner, "js", jsEntrypoints, outDir, node_path$1.default.join(outDir, "assets"), minify);
3392
+ await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
3374
3393
  }
3375
3394
  //#endregion
3376
3395
  //#region src/plugins/build/phases/content.ts
@@ -3387,15 +3406,24 @@ const CONTENT_CACHE_KEY = "content";
3387
3406
  * pages/feeds/og-images phases. Performs NO Markdown parsing itself — the
3388
3407
  * content plugin owns rendering (god-plugin invariant).
3389
3408
  *
3409
+ * On a dev incremental rebuild (`options.changed` set) it first `invalidate()`s the
3410
+ * changed Markdown so `loadAll({ reuse: true })` re-reads + re-renders ONLY those
3411
+ * articles, reusing the cached HTML for the rest. With no options it does a full load.
3412
+ *
3390
3413
  * @param ctx - Plugin context (provides `require`, `state`, `log`).
3414
+ * @param options - Optional incremental hints; omit for a full load.
3415
+ * @param options.reuse - Reuse cached content for slugs not invalidated (dev incremental rebuild).
3416
+ * @param options.changed - The changed Markdown paths to invalidate before loading.
3391
3417
  * @returns The locale-keyed article map returned by the content plugin.
3392
3418
  * @example
3393
3419
  * ```ts
3394
3420
  * const byLocale = await loadContent(ctx);
3395
3421
  * ```
3396
3422
  */
3397
- async function loadContent(ctx) {
3398
- const byLocale = await ctx.require(contentPlugin).loadAll();
3423
+ async function loadContent(ctx, options) {
3424
+ const content = ctx.require(contentPlugin);
3425
+ if (options?.changed && options.changed.length > 0) content.invalidate(options.changed);
3426
+ const byLocale = await content.loadAll({ reuse: options?.reuse ?? false });
3399
3427
  ctx.state.buildCache.set(CONTENT_CACHE_KEY, byLocale);
3400
3428
  ctx.log.debug("build:content", { locales: byLocale.size });
3401
3429
  return byLocale;
@@ -4749,6 +4777,71 @@ function renderBody(definition, routeContext) {
4749
4777
  return (0, preact_render_to_string.renderToString)(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
4750
4778
  }
4751
4779
  /**
4780
+ * Hash a page's render inputs (its loaded data) for the render cache. `null` when the
4781
+ * data is not JSON-serializable — such a page is never cached and always re-renders.
4782
+ *
4783
+ * @param data - The route's loaded data (the only per-page input besides params/locale/code).
4784
+ * @returns The hex SHA-256 of the serialized data, or `null` when it cannot be serialized.
4785
+ * @example
4786
+ * ```ts
4787
+ * hashData({ title: "Hi" }); // "9f8e…"
4788
+ * ```
4789
+ */
4790
+ function hashData(data) {
4791
+ try {
4792
+ const serialized = JSON.stringify(data) ?? "";
4793
+ return (0, node_crypto.createHash)("sha256").update(serialized).digest("hex");
4794
+ } catch {
4795
+ return null;
4796
+ }
4797
+ }
4798
+ /**
4799
+ * The render-cache key for one page instance: name + params + locale (the stable identity
4800
+ * that, together with the data hash, determines its body). NUL-joined so no value collides.
4801
+ *
4802
+ * @param instance - The page instance.
4803
+ * @returns The cache key string.
4804
+ * @example
4805
+ * ```ts
4806
+ * renderCacheKey(instance); // "article{\"slug\":\"x\"}en"
4807
+ * ```
4808
+ */
4809
+ function renderCacheKey(instance) {
4810
+ return `${instance.name}${JSON.stringify(instance.params)}${instance.locale}`;
4811
+ }
4812
+ /**
4813
+ * Render one page's body, reusing the cached body when this page's data is unchanged.
4814
+ * The body is the synchronous, dominant-cost step ({@link renderBody}); an incremental
4815
+ * dev rebuild (`reuse`, code unchanged) skips it for every page whose data hash matches
4816
+ * the cache, and a changed page (or a non-`reuse` run) renders + refreshes the cache.
4817
+ *
4818
+ * @param ctx - Plugin context (provides the cross-run `state.renderCache`).
4819
+ * @param instance - The page instance being rendered.
4820
+ * @param routeContext - The route context passed to `.render()`/`.layout()`.
4821
+ * @param data - The route's loaded data (hashed to detect a change).
4822
+ * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
4823
+ * @returns The SSR-rendered body HTML.
4824
+ * @example
4825
+ * ```ts
4826
+ * const body = renderBodyCached(ctx, instance, routeContext, data, true);
4827
+ * ```
4828
+ */
4829
+ function renderBodyCached(ctx, instance, routeContext, data, reuse) {
4830
+ const cache = ctx.state.renderCache;
4831
+ const key = renderCacheKey(instance);
4832
+ const hash = hashData(data);
4833
+ if (reuse && hash !== null) {
4834
+ const hit = cache.get(key);
4835
+ if (hit?.dataHash === hash) return hit.body;
4836
+ }
4837
+ const body = renderBody(instance.definition, routeContext);
4838
+ if (hash !== null) cache.set(key, {
4839
+ dataHash: hash,
4840
+ body
4841
+ });
4842
+ return body;
4843
+ }
4844
+ /**
4752
4845
  * Write a rendered page document to its on-disk path. The path comes from the
4753
4846
  * compiled `TypedRoute.toFile(params)` (honoring any route-level `.toFile()`
4754
4847
  * override), resolved under the build `outDir`; parent directories are created first.
@@ -4777,13 +4870,14 @@ async function writeDocument(outDir, entry, params, html) {
4777
4870
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
4778
4871
  * @param instance - The concrete page instance to render.
4779
4872
  * @param shell - Per-build wiring shared across instances (asset tags + template).
4873
+ * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
4780
4874
  * @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
4781
4875
  * @example
4782
4876
  * ```ts
4783
- * await renderInstance(ctx, instance, { assets: "", template: null });
4877
+ * await renderInstance(ctx, instance, { assets: "", template: null }, false);
4784
4878
  * ```
4785
4879
  */
4786
- async function renderInstance(ctx, instance, shell) {
4880
+ async function renderInstance(ctx, instance, shell, reuse) {
4787
4881
  const { definition, entry, params, locale } = instance;
4788
4882
  const router = ctx.require(routerPlugin);
4789
4883
  const data = await loadRouteData(definition, params, locale, ctx);
@@ -4796,7 +4890,7 @@ async function renderInstance(ctx, instance, shell) {
4796
4890
  };
4797
4891
  const parts = {
4798
4892
  head: composeHeadHtml(ctx, instance, url, routeContext, data),
4799
- body: renderBody(definition, routeContext),
4893
+ body: renderBodyCached(ctx, instance, routeContext, data, reuse),
4800
4894
  assets: shell.assets,
4801
4895
  locale
4802
4896
  };
@@ -4890,27 +4984,77 @@ function findRootHtml(rendered) {
4890
4984
  return rendered.find((page) => page.url === "/" || page.url === "")?.html ?? null;
4891
4985
  }
4892
4986
  /**
4987
+ * Pages rendered concurrently per batch. Kept small so the macrotask yield between
4988
+ * batches fires frequently — a large batch renders for seconds before yielding, which
4989
+ * leaves a watching dev server's spinner repainting only every few seconds (sluggish).
4990
+ * Smaller batches trade a little write-concurrency for a smooth, responsive spinner.
4991
+ */
4992
+ const RENDER_BATCH_SIZE = 2;
4993
+ /**
4994
+ * Batch size for an incremental (`reuse`) rebuild. Most instances are cheap cache hits, so
4995
+ * a larger batch cuts the per-batch `setImmediate` round-trips (which would otherwise add
4996
+ * pure latency to an otherwise-fast rebuild) without starving the dev spinner.
4997
+ */
4998
+ const INCREMENTAL_BATCH_SIZE = 32;
4999
+ /**
5000
+ * Render `items` through `worker` in bounded-size batches, yielding a macrotask
5001
+ * (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
5002
+ * sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
5003
+ * hundreds of synchronous `renderToString` calls starves the event loop, which freezes a
5004
+ * watching dev server's progress spinner until the whole phase resolves. Output order is
5005
+ * preserved (batch order + `Promise.all` order within a batch).
5006
+ *
5007
+ * @template Item - The input item type.
5008
+ * @template Out - The rendered output type.
5009
+ * @param items - The items to render.
5010
+ * @param batchSize - Maximum items rendered concurrently per batch.
5011
+ * @param worker - Renders one item to its output.
5012
+ * @returns All rendered outputs in input order.
5013
+ * @example
5014
+ * ```ts
5015
+ * const pages = await renderInBatches(instances, 32, i => renderInstance(ctx, i, shell));
5016
+ * ```
5017
+ */
5018
+ async function renderInBatches(items, batchSize, worker) {
5019
+ const out = [];
5020
+ for (let start = 0; start < items.length; start += batchSize) {
5021
+ const batch = items.slice(start, start + batchSize);
5022
+ out.push(...await Promise.all(batch.map((item) => worker(item))));
5023
+ if (start + batchSize < items.length) await new Promise((resolve) => {
5024
+ setImmediate(resolve);
5025
+ });
5026
+ }
5027
+ return out;
5028
+ }
5029
+ /**
4893
5030
  * Renders every route in the manifest to `outDir/<path>/index.html`. Reads as a
4894
- * pipeline: resolve deps → prepare the shared shell → expand instances → render all
4895
- * concurrently (`Promise.all`, legal intra-plugin concurrency) → write data sidecars
4896
- * (hybrid/spa) → capture the root page's HTML for the root-index phase.
5031
+ * pipeline: resolve deps → prepare the shared shell → expand instances → render in
5032
+ * bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
5033
+ * capture the root page's HTML for the root-index phase.
5034
+ *
5035
+ * On an incremental rebuild (`options.reuse`) the cross-run render cache is kept and each
5036
+ * unchanged-data page reuses its cached body; a full render clears the cache first so a
5037
+ * removed/renamed route's stale body never lingers.
4897
5038
  *
4898
5039
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`, `has`).
5040
+ * @param options - Optional incremental hint; omit for a full render.
5041
+ * @param options.reuse - Reuse cached page bodies for unchanged-data pages (dev incremental rebuild).
4899
5042
  * @returns The number of pages rendered and the captured default-page HTML.
4900
5043
  * @example
4901
5044
  * ```ts
4902
5045
  * const { pageCount, rootHtml } = await renderPages(ctx);
4903
5046
  * ```
4904
5047
  */
4905
- async function renderPages(ctx) {
5048
+ async function renderPages(ctx, options) {
5049
+ const reuse = options?.reuse === true;
4906
5050
  const router = ctx.require(routerPlugin);
4907
5051
  const manifest = router.manifest();
4908
5052
  ctx.state.manifest = [...manifest];
4909
5053
  const locales = ctx.require(i18nPlugin).locales();
4910
5054
  const byPattern = makeEntryMap(router);
5055
+ if (!reuse) ctx.state.renderCache.clear();
4911
5056
  const shell = await prepareShell(ctx);
4912
- const instances = await expandAllInstances(manifest, locales, byPattern, ctx);
4913
- const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
5057
+ const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
4914
5058
  await writeDataSidecars(ctx, rendered, router.mode());
4915
5059
  ctx.log.debug("build:pages", { count: rendered.length });
4916
5060
  return {
@@ -5118,6 +5262,40 @@ async function generateSitemap(ctx) {
5118
5262
  * @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
5119
5263
  * emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
5120
5264
  */
5265
+ /** Matches a Markdown source path (a content edit). */
5266
+ const MARKDOWN_PATH = /\.md$/;
5267
+ /** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
5268
+ const STYLE_PATH = /\.css$/;
5269
+ /** Matches a code path (TS/JS/JSON — may change ANY page's render output). */
5270
+ const CODE_PATH = /\.(?:tsx?|jsx?|mjs|cjs|json)$/;
5271
+ /**
5272
+ * Derive the {@link ChangePlan} for a run from its changed-path set (see the type docs
5273
+ * for the rules).
5274
+ *
5275
+ * @param changed - Absolute/relative changed paths, or `undefined` for a full build.
5276
+ * @returns The reuse plan for this run.
5277
+ * @example
5278
+ * ```ts
5279
+ * const plan = planIncrementalRebuild(options?.changed);
5280
+ * ```
5281
+ */
5282
+ function planIncrementalRebuild(changed) {
5283
+ if (changed === void 0 || changed.length === 0) return {
5284
+ contentChanged: [],
5285
+ contentReuse: false,
5286
+ renderReuse: false
5287
+ };
5288
+ if (!changed.every((file) => MARKDOWN_PATH.test(file) || STYLE_PATH.test(file) || CODE_PATH.test(file))) return {
5289
+ contentChanged: [],
5290
+ contentReuse: false,
5291
+ renderReuse: false
5292
+ };
5293
+ return {
5294
+ contentChanged: changed.filter((file) => MARKDOWN_PATH.test(file)),
5295
+ contentReuse: true,
5296
+ renderReuse: !changed.some((file) => CODE_PATH.test(file))
5297
+ };
5298
+ }
5121
5299
  /**
5122
5300
  * The static ordered list of pipeline phase names.
5123
5301
  *
@@ -5231,8 +5409,7 @@ async function runOutputs(ctx) {
5231
5409
  * `build:phase` boundary per phase and `build:complete` once at the end.
5232
5410
  *
5233
5411
  * @param ctx - Plugin context (provides `require`, `emit`, `state`, `config`, `log`).
5234
- * @param options - Optional run overrides.
5235
- * @param options.outDir - Override the configured output directory for this run.
5412
+ * @param options - Optional per-run overrides ({@link RunOptions}).
5236
5413
  * @returns The build result (outDir, pageCount, durationMs).
5237
5414
  * @example
5238
5415
  * ```ts
@@ -5247,17 +5424,22 @@ async function runPipeline(ctx, options) {
5247
5424
  ...ctx,
5248
5425
  config: {
5249
5426
  ...ctx.config,
5250
- outDir
5427
+ outDir,
5428
+ ...options?.overrides
5251
5429
  }
5252
5430
  };
5253
- await (0, node_fs_promises.rm)(outDir, {
5431
+ const plan = planIncrementalRebuild(options?.changed);
5432
+ if (!options?.skipClean) await (0, node_fs_promises.rm)(outDir, {
5254
5433
  recursive: true,
5255
5434
  force: true
5256
5435
  });
5257
5436
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
5258
5437
  await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
5259
- await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext)), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
5260
- const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext));
5438
+ await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
5439
+ reuse: plan.contentReuse,
5440
+ changed: plan.contentChanged
5441
+ })), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
5442
+ const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
5261
5443
  await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
5262
5444
  await runOutputs(phaseContext);
5263
5445
  await withPhase(phaseContext, "root-index", async () => {
@@ -5310,10 +5492,12 @@ const defaultConfig$2 = {
5310
5492
  function createApi$3(ctx) {
5311
5493
  return {
5312
5494
  /**
5313
- * Run the full SSG pipeline and write the site to disk.
5495
+ * Run the full SSG pipeline and write the site to disk. With no options a full
5496
+ * production build runs; dev callers pass `skipClean`/`overrides`/`changed` for a
5497
+ * fast incremental rebuild (all gated behind opt-in fields — the default path is
5498
+ * unchanged).
5314
5499
  *
5315
- * @param options - Optional run overrides.
5316
- * @param options.outDir - Override the configured output directory for this run.
5500
+ * @param options - Optional per-run overrides (outDir / skipClean / overrides / changed).
5317
5501
  * @returns The build result (outDir, pageCount, durationMs).
5318
5502
  * @example
5319
5503
  * ```ts
@@ -5406,8 +5590,8 @@ function createEvents(register) {
5406
5590
  /**
5407
5591
  * Creates initial `build` plugin state: a frozen config snapshot plus empty
5408
5592
  * per-run caches (`manifest`, `buildCache`, `runId`) and the cross-run OG
5409
- * content-hash cache. Holds caches and config only — no domain data is
5410
- * duplicated here (pulled fresh via `ctx.require` each run).
5593
+ * content-hash + page-render caches. Holds caches and config only — no domain data
5594
+ * is duplicated here (pulled fresh via `ctx.require` each run).
5411
5595
  *
5412
5596
  * @param ctx - Minimal context with global and config.
5413
5597
  * @param ctx.global - Global plugin registry (unused; caches are config-driven).
@@ -5424,7 +5608,8 @@ function createState$3(ctx) {
5424
5608
  manifest: null,
5425
5609
  buildCache: /* @__PURE__ */ new Map(),
5426
5610
  runId: null,
5427
- ogImageHashCache: /* @__PURE__ */ new Map()
5611
+ ogImageHashCache: /* @__PURE__ */ new Map(),
5612
+ renderCache: /* @__PURE__ */ new Map()
5428
5613
  };
5429
5614
  }
5430
5615
  //#endregion
@@ -5648,12 +5833,37 @@ function buildWranglerArgs(input) {
5648
5833
  branch
5649
5834
  ];
5650
5835
  }
5836
+ /**
5837
+ * Assemble the argv for `wrangler pages project create` (no shell). Guards the
5838
+ * production branch against flag injection; the slug is already a safe `toSlug` output.
5839
+ *
5840
+ * @param input - The resolved project-create inputs.
5841
+ * @param input.slug - Cloudflare project-name slug (`toSlug(site.name())`).
5842
+ * @param input.branch - Production branch (guarded by `/^[a-zA-Z0-9/_.-]+$/`).
5843
+ * @returns The wrangler argv array.
5844
+ * @throws {Error} `ERR_DEPLOY_INVALID_BRANCH` when the branch fails the guard.
5845
+ * @example
5846
+ * buildProjectCreateArgs({ slug: "my-site", branch: "main" });
5847
+ */
5848
+ function buildProjectCreateArgs(input) {
5849
+ const branch = guardBranch(input.branch);
5850
+ return [
5851
+ "bunx",
5852
+ "wrangler",
5853
+ "pages",
5854
+ "project",
5855
+ "create",
5856
+ input.slug,
5857
+ "--production-branch",
5858
+ branch
5859
+ ];
5860
+ }
5651
5861
  /** Lowercased substring matchers for the wrangler error taxonomy. */
5652
5862
  const ERROR_SIGNATURES = [
5653
5863
  {
5654
5864
  match: ["could not find project", "project not found"],
5655
5865
  kind: "ERR_DEPLOY_PROJECT_NOT_FOUND",
5656
- advice: "The Cloudflare Pages project does not exist. Run `app.deploy.init()` or create it in the dashboard, then retry."
5866
+ advice: "The Cloudflare Pages project does not exist yet. Create it in the dashboard (Workers & Pages → Create → Pages) or with `bunx wrangler pages project create <name>`, then retry. (app.deploy.init() only scaffolds local config — it does not create the remote project.)"
5657
5867
  },
5658
5868
  {
5659
5869
  match: [
@@ -6232,15 +6442,16 @@ function validateConfig$1(ctx) {
6232
6442
  * Run wrangler for the prepared argv and surface its scrubbed result, translating
6233
6443
  * a non-zero exit into the classified deploy error. The API token is read from env
6234
6444
  * here so it never crosses a logging boundary; only scrubbed output is returned.
6445
+ * Shared by `run()` (deploy) and `createProject()` (project create).
6235
6446
  *
6236
6447
  * @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
6237
6448
  * @param args - The fully-built, pre-validated wrangler argv.
6238
6449
  * @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
6239
6450
  * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6240
6451
  * @example
6241
- * const { stdout, scrubbedStderr } = await executeDeploy(ctx, args);
6452
+ * const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6242
6453
  */
6243
- async function executeDeploy(ctx, args) {
6454
+ async function executeWrangler(ctx, args) {
6244
6455
  const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
6245
6456
  const { stdout, scrubbedStderr, exitCode } = await runWrangler({
6246
6457
  spawn: ctx.state.spawn,
@@ -6312,7 +6523,7 @@ function createApi$2(ctx) {
6312
6523
  root
6313
6524
  });
6314
6525
  const start = Date.now();
6315
- const { stdout, scrubbedStderr } = await executeDeploy(ctx, args);
6526
+ const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6316
6527
  ctx.log.info(scrubbedStderr);
6317
6528
  const result = buildDeployResult(stdout, branch, start);
6318
6529
  ctx.state.lastDeployment = result;
@@ -6351,6 +6562,38 @@ function createApi$2(ctx) {
6351
6562
  cwd: process.cwd(),
6352
6563
  options
6353
6564
  });
6565
+ },
6566
+ /**
6567
+ * The Cloudflare Pages project name this app deploys to (`toSlug(site.name())`).
6568
+ *
6569
+ * @returns The project-name slug.
6570
+ * @example
6571
+ * api.projectName(); // "my-site"
6572
+ */
6573
+ projectName() {
6574
+ return toSlug(ctx.require(sitePlugin).name());
6575
+ },
6576
+ /**
6577
+ * Create the remote Cloudflare Pages project via wrangler, so a first deploy has a
6578
+ * target. Derives the slug from `site.name()` and the production branch from config.
6579
+ *
6580
+ * @returns The created project name + production branch.
6581
+ * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6582
+ * @example
6583
+ * await api.createProject(); // { name: "my-site", branch: "main" }
6584
+ */
6585
+ async createProject() {
6586
+ const name = toSlug(ctx.require(sitePlugin).name());
6587
+ const branch = ctx.config.productionBranch ?? "main";
6588
+ const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
6589
+ slug: name,
6590
+ branch
6591
+ }));
6592
+ ctx.log.info(scrubbedStderr);
6593
+ return {
6594
+ name,
6595
+ branch
6596
+ };
6354
6597
  }
6355
6598
  };
6356
6599
  }
@@ -6462,13 +6705,14 @@ const deployPlugin = createPlugin$1("deploy", {
6462
6705
  //#endregion
6463
6706
  //#region src/plugins/cli/deploy-wizard.ts
6464
6707
  /**
6465
- * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`). Walks a
6466
- * human through a Cloudflare Pages deploy: checks prerequisites (wrangler config + the
6467
- * Cloudflare credentials) with concrete fix guidance, offers to scaffold/build what is
6468
- * missing, HARD-GATES the deploy on everything being green, runs a local build smoke
6469
- * test, confirms, deploys, then offers to scaffold a GitHub Actions workflow (auto on
6470
- * push to main, or a versioned/manual trigger). The non-guided `--cli` path stays in
6471
- * `api.ts`. Every prompt + line of output flows through injectable `state` seams.
6708
+ * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`, the default
6709
+ * for `bun run deploy`; the direct `--cli` path stays in `api.ts`). Walks a human through a
6710
+ * Cloudflare Pages deploy: checks prerequisites (wrangler config + the Cloudflare
6711
+ * credentials) with concrete fix guidance, offers to scaffold what is missing (a
6712
+ * `wrangler.jsonc`, and a placeholder `.env` for any missing credentials), HARD-GATES the
6713
+ * deploy on everything being green, runs a local build smoke test, confirms, deploys, then
6714
+ * offers to scaffold a GitHub Actions workflow (auto on push to main, or a versioned/manual
6715
+ * trigger). Every prompt + line of output flows through injectable `state` seams.
6472
6716
  */
6473
6717
  /** How to create a Cloudflare API token + where to make it available locally. */
6474
6718
  const TOKEN_HELP = [
@@ -6483,21 +6727,56 @@ const ACCOUNT_HELP = [
6483
6727
  "right-hand sidebar (also in the dashboard URL). Then make it available:",
6484
6728
  " export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
6485
6729
  ].join("\n");
6730
+ /** Shown when a credential is in the raw environment but the app's env providers did not resolve it. */
6731
+ const PROVIDERS_HELP = [
6732
+ "Found in your shell/.env but the app's env plugin did not resolve it — its providers",
6733
+ "are not wired. Add the Node providers in createApp so the deploy can read it:",
6734
+ " pluginConfigs.env = { providers: [processEnv(), dotenv()] } (import them from @moku-labs/web)."
6735
+ ].join("\n");
6486
6736
  /** The GitHub repo secrets the generated workflow consumes. */
6487
6737
  const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
6488
6738
  /**
6739
+ * Build one credential prerequisite by reading the SAME source the deploy reads — the
6740
+ * resolved `ctx.env` table — so a ✓ guarantees `ctx.env.require(key)` will succeed. When
6741
+ * the value is present in the raw `process.env` but unresolved by the app's providers
6742
+ * (the silent "deploy can't see it" trap a bare `process.env` check would mark green),
6743
+ * the fix hint points at wiring the providers instead of re-adding the value.
6744
+ *
6745
+ * @param ctx - The cli plugin context (provides the resolved `ctx.env`).
6746
+ * @param key - The credential variable name.
6747
+ * @param label - The diagnostic line label.
6748
+ * @param missingHelp - The fix hint when the credential is genuinely absent everywhere.
6749
+ * @returns The credential prerequisite check.
6750
+ * @example
6751
+ * credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP);
6752
+ */
6753
+ function credentialPrereq(ctx, key, label, missingHelp) {
6754
+ if ((ctx.env.get(key) ?? "") !== "") return {
6755
+ ok: true,
6756
+ label,
6757
+ detail: void 0,
6758
+ scaffoldable: false
6759
+ };
6760
+ return {
6761
+ ok: false,
6762
+ label,
6763
+ detail: (process.env[key] ?? "") !== "" ? PROVIDERS_HELP : missingHelp,
6764
+ scaffoldable: false
6765
+ };
6766
+ }
6767
+ /**
6489
6768
  * Evaluate the three deploy prerequisites against the current project: the Cloudflare
6490
- * wrangler config exists, and both Cloudflare credentials are present in the environment.
6769
+ * wrangler config exists, and both Cloudflare credentials resolve through `ctx.env` (the
6770
+ * deploy's own source of truth — not a bare `process.env` read that can diverge from it).
6491
6771
  *
6772
+ * @param ctx - The cli plugin context (provides the resolved `ctx.env`).
6492
6773
  * @param cwd - The project root (where `wrangler.jsonc` lives).
6493
6774
  * @returns The ordered prerequisite checks.
6494
6775
  * @example
6495
- * const prereqs = diagnose(process.cwd());
6776
+ * const prereqs = diagnose(ctx, process.cwd());
6496
6777
  */
6497
- function diagnose(cwd) {
6778
+ function diagnose(ctx, cwd) {
6498
6779
  const wranglerOk = (0, node_fs.existsSync)(node_path$1.default.join(cwd, "wrangler.jsonc"));
6499
- const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
6500
- const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
6501
6780
  return [
6502
6781
  {
6503
6782
  ok: wranglerOk,
@@ -6505,18 +6784,8 @@ function diagnose(cwd) {
6505
6784
  detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
6506
6785
  scaffoldable: true
6507
6786
  },
6508
- {
6509
- ok: tokenOk,
6510
- label: "CLOUDFLARE_API_TOKEN is set",
6511
- detail: tokenOk ? void 0 : TOKEN_HELP,
6512
- scaffoldable: false
6513
- },
6514
- {
6515
- ok: accountOk,
6516
- label: "CLOUDFLARE_ACCOUNT_ID is set",
6517
- detail: accountOk ? void 0 : ACCOUNT_HELP,
6518
- scaffoldable: false
6519
- }
6787
+ credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP),
6788
+ credentialPrereq(ctx, "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_ACCOUNT_ID is set", ACCOUNT_HELP)
6520
6789
  ];
6521
6790
  }
6522
6791
  /**
@@ -6535,6 +6804,43 @@ async function offerScaffold(ctx, prereqs) {
6535
6804
  await ctx.require(deployPlugin).init({});
6536
6805
  ctx.state.render.check(true, "wrangler.jsonc scaffolded");
6537
6806
  }
6807
+ /** The Cloudflare credentials the deploy needs, with the comment written above each in a scaffolded `.env`. */
6808
+ const ENV_CREDENTIALS = [{
6809
+ key: "CLOUDFLARE_API_TOKEN",
6810
+ comment: "# Cloudflare API token — https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
6811
+ }, {
6812
+ key: "CLOUDFLARE_ACCOUNT_ID",
6813
+ comment: "# Cloudflare account id — dashboard → Workers & Pages → right-hand sidebar"
6814
+ }];
6815
+ /**
6816
+ * Offer to scaffold a `.env` with placeholders for whichever Cloudflare credentials are
6817
+ * missing — created when absent, appended to (never clobbering a key already present)
6818
+ * when it exists. The placeholders are empty, so the deploy still hard-gates until the
6819
+ * user fills them in; this just removes the "where do I even put these?" friction.
6820
+ *
6821
+ * @param ctx - The cli plugin context.
6822
+ * @param cwd - The project root (where `.env` lives).
6823
+ * @returns Resolves once any accepted scaffold has been written.
6824
+ * @example
6825
+ * await offerEnvScaffold(ctx, process.cwd());
6826
+ */
6827
+ async function offerEnvScaffold(ctx, cwd) {
6828
+ const missing = ENV_CREDENTIALS.filter(({ key }) => (process.env[key] ?? "") === "");
6829
+ if (missing.length === 0) return;
6830
+ const envPath = node_path$1.default.join(cwd, ".env");
6831
+ const exists = (0, node_fs.existsSync)(envPath);
6832
+ const verb = exists ? "Add placeholders for the missing secret(s) to" : "Create";
6833
+ if (!await ctx.state.confirm(`${verb} .env?`)) return;
6834
+ const lines = exists ? (0, node_fs.readFileSync)(envPath, "utf8").split(/\r?\n/) : [];
6835
+ const toAdd = missing.filter(({ key }) => !lines.some((line) => line.trimStart().startsWith(`${key}=`)));
6836
+ if (toAdd.length === 0) {
6837
+ ctx.state.render.info(".env already lists those keys — fill in their values, then re-run.");
6838
+ return;
6839
+ }
6840
+ (0, node_fs.appendFileSync)(envPath, `${exists ? "\n" : "# Cloudflare Pages deploy credentials — fill these in (keep .env gitignored).\n"}${toAdd.map(({ key, comment }) => `${comment}\n${key}=`).join("\n\n")}\n`);
6841
+ const names = toAdd.map(({ key }) => key).join(", ");
6842
+ ctx.state.render.check(true, `${exists ? "added placeholders to" : "created"} .env`, `fill in ${names}, then re-run \`bun run deploy\`.`);
6843
+ }
6538
6844
  /**
6539
6845
  * Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
6540
6846
  * concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
@@ -6577,8 +6883,139 @@ async function offerWorkflowSetup(ctx) {
6577
6883
  ctx.state.render.info(SECRETS_HELP);
6578
6884
  }
6579
6885
  /**
6580
- * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
6581
- * report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
6886
+ * Read the taxonomy `code` off a thrown value, when present. Deploy errors carry a
6887
+ * `code` (e.g. `ERR_DEPLOY_PROJECT_NOT_FOUND`) so the wizard can tailor the fix hint.
6888
+ *
6889
+ * @param error - The thrown value.
6890
+ * @returns The `code` string, or `undefined` when absent.
6891
+ * @example
6892
+ * codeOf(deployError("ERR_DEPLOY_AUTH", "…")); // "ERR_DEPLOY_AUTH"
6893
+ */
6894
+ function codeOf(error) {
6895
+ if (typeof error === "object" && error !== null && "code" in error) {
6896
+ const { code } = error;
6897
+ return typeof code === "string" ? code : void 0;
6898
+ }
6899
+ }
6900
+ /**
6901
+ * A copy-pasteable "create the project yourself" hint, shown when the user declines the
6902
+ * offer to auto-create. Spells out that the remote project is what's missing (init only
6903
+ * scaffolds local config).
6904
+ *
6905
+ * @param name - The Cloudflare Pages project name (the deploy slug).
6906
+ * @returns The multi-line hint (newline-separated; rendered indented under a `›`).
6907
+ * @example
6908
+ * ctx.state.render.info(projectNotFoundHint("my-site"));
6909
+ */
6910
+ function projectNotFoundHint(name) {
6911
+ return [
6912
+ "how to fix: the Cloudflare Pages project does not exist yet — create it once, then",
6913
+ "re-run `bun run deploy`. (app.deploy.init() only scaffolds local config; it does not",
6914
+ "create the remote project.)",
6915
+ ` • CLI: bunx wrangler pages project create ${name} --production-branch main`,
6916
+ " • Dashboard: Cloudflare → Workers & Pages → Create → Pages"
6917
+ ].join("\n");
6918
+ }
6919
+ /**
6920
+ * An actionable, error-specific "how to fix" hint for a failed deploy (other than the
6921
+ * project-not-found case, which the wizard handles interactively), so the user never
6922
+ * lands on a raw stack trace.
6923
+ *
6924
+ * @param error - The thrown deploy error.
6925
+ * @returns The fix hint line.
6926
+ * @example
6927
+ * ctx.state.render.info(deployFailureHint(err));
6928
+ */
6929
+ function deployFailureHint$1(error) {
6930
+ const code = codeOf(error);
6931
+ if (code === "ERR_DEPLOY_AUTH" || code === "ERR_DEPLOY_AUTH_EXPIRED") return "how to fix: refresh CLOUDFLARE_API_TOKEN (scope: Account › Cloudflare Pages › Edit), then re-run `bun run deploy`.";
6932
+ if (code === "ERR_DEPLOY_NETWORK") return "how to fix: a network error reached Cloudflare — check connectivity, then re-run `bun run deploy`.";
6933
+ return "how to fix: resolve the error above, then re-run `bun run deploy`.";
6934
+ }
6935
+ /**
6936
+ * Render a styled deploy failure (✗ + fix hint) and return the `"failed"` outcome, so a
6937
+ * caught error surfaces consistently instead of as a raw throw.
6938
+ *
6939
+ * @param ctx - The cli plugin context.
6940
+ * @param error - The thrown deploy error.
6941
+ * @returns The `"failed"` deploy outcome.
6942
+ * @example
6943
+ * return renderFailure(ctx, error);
6944
+ */
6945
+ function renderFailure(ctx, error) {
6946
+ ctx.state.render.error("deploy failed", error);
6947
+ ctx.state.render.info(deployFailureHint$1(error));
6948
+ return {
6949
+ deployed: false,
6950
+ reason: "failed"
6951
+ };
6952
+ }
6953
+ /**
6954
+ * Deploy once via the deploy plugin and wrap the result as a successful outcome. Throws
6955
+ * the classified deploy error on failure (the caller decides how to surface it).
6956
+ *
6957
+ * @param ctx - The cli plugin context.
6958
+ * @param options - The deploy options (branch override).
6959
+ * @returns The successful deploy outcome.
6960
+ * @throws {Error} With a `code` from the deploy error taxonomy on any failure.
6961
+ * @example
6962
+ * const outcome = await deployOnce(ctx, { branch: "main" });
6963
+ */
6964
+ async function deployOnce(ctx, options) {
6965
+ return {
6966
+ deployed: true,
6967
+ ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6968
+ };
6969
+ }
6970
+ /**
6971
+ * Handle a project-not-found deploy failure interactively: ask (a confirmation step)
6972
+ * before creating a real Cloudflare resource, create the Pages project via the deploy
6973
+ * plugin, then retry the deploy once. A declined offer (or a create failure) returns the
6974
+ * `"failed"` outcome with an actionable hint — never a raw stack trace.
6975
+ *
6976
+ * @param ctx - The cli plugin context.
6977
+ * @param options - The deploy options (branch override).
6978
+ * @param originalError - The project-not-found error from the first attempt.
6979
+ * @returns The deploy outcome (deployed after a successful create + retry, else failed).
6980
+ * @example
6981
+ * return createProjectThenRetry(ctx, options, error);
6982
+ */
6983
+ async function createProjectThenRetry(ctx, options, originalError) {
6984
+ const deploy = ctx.require(deployPlugin);
6985
+ const name = deploy.projectName();
6986
+ ctx.state.render.warn(`The Cloudflare Pages project "${name}" does not exist yet.`);
6987
+ if (!await ctx.state.confirm(`Create the Cloudflare Pages project "${name}" now?`)) {
6988
+ ctx.state.render.error("deploy failed", originalError);
6989
+ ctx.state.render.info(projectNotFoundHint(name));
6990
+ return {
6991
+ deployed: false,
6992
+ reason: "failed"
6993
+ };
6994
+ }
6995
+ try {
6996
+ const created = await deploy.createProject();
6997
+ ctx.state.render.check(true, `created Cloudflare Pages project "${created.name}"`);
6998
+ } catch (error) {
6999
+ ctx.state.render.error("could not create the Pages project", error);
7000
+ ctx.state.render.info(deployFailureHint$1(error));
7001
+ return {
7002
+ deployed: false,
7003
+ reason: "failed"
7004
+ };
7005
+ }
7006
+ ctx.state.render.info("project created — retrying the deploy…");
7007
+ try {
7008
+ return await deployOnce(ctx, options);
7009
+ } catch (error) {
7010
+ return renderFailure(ctx, error);
7011
+ }
7012
+ }
7013
+ /**
7014
+ * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin. A
7015
+ * declined confirm returns `{ deployed: false, reason: "declined" }`. A project-not-found
7016
+ * failure offers to create the project (with a confirmation step) and retries; any other
7017
+ * runtime failure is surfaced as a styled error + fix hint, returning
7018
+ * `{ deployed: false, reason: "failed" }` — never a raw stack trace.
6582
7019
  *
6583
7020
  * @param ctx - The cli plugin context.
6584
7021
  * @param options - The deploy options (branch override + `yes`).
@@ -6595,10 +7032,12 @@ async function runDeployStep(ctx, options) {
6595
7032
  reason: "declined"
6596
7033
  };
6597
7034
  }
6598
- return {
6599
- deployed: true,
6600
- ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6601
- };
7035
+ try {
7036
+ return await deployOnce(ctx, options);
7037
+ } catch (error) {
7038
+ if (codeOf(error) === "ERR_DEPLOY_PROJECT_NOT_FOUND") return createProjectThenRetry(ctx, options, error);
7039
+ return renderFailure(ctx, error);
7040
+ }
6602
7041
  }
6603
7042
  /**
6604
7043
  * Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
@@ -6616,9 +7055,10 @@ async function runDeployStep(ctx, options) {
6616
7055
  async function runDeployWizard(ctx, options) {
6617
7056
  const cwd = process.cwd();
6618
7057
  ctx.state.render.heading("Checking prerequisites");
6619
- for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
6620
- await offerScaffold(ctx, diagnose(cwd));
6621
- const blockers = diagnose(cwd).filter((item) => !item.ok);
7058
+ for (const item of diagnose(ctx, cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
7059
+ await offerScaffold(ctx, diagnose(ctx, cwd));
7060
+ await offerEnvScaffold(ctx, cwd);
7061
+ const blockers = diagnose(ctx, cwd).filter((item) => !item.ok);
6622
7062
  if (blockers.length > 0) {
6623
7063
  ctx.state.render.heading("Not ready to deploy");
6624
7064
  for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
@@ -6635,7 +7075,7 @@ async function runDeployWizard(ctx, options) {
6635
7075
  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).");
6636
7076
  ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
6637
7077
  const outcome = await runDeployStep(ctx, options);
6638
- await offerWorkflowSetup(ctx);
7078
+ if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx);
6639
7079
  return outcome;
6640
7080
  }
6641
7081
  //#endregion
@@ -6675,25 +7115,26 @@ function injectReloadClient(html) {
6675
7115
  * Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
6676
7116
  * routes success to `onReloaded` and failure to `onError`.
6677
7117
  *
6678
- * @param input - The rebuild dependencies + the changed file.
6679
- * @param input.runBuild - Runs one build and resolves with its summary.
7118
+ * @param input - The rebuild dependencies + the changed file/paths.
7119
+ * @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
6680
7120
  * @param input.onRebuildStart - Called with the changed file just before the build runs.
6681
- * @param input.onReloaded - Called with the changed file + summary after a rebuild.
7121
+ * @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
6682
7122
  * @param input.onError - Called when a rebuild throws.
6683
7123
  * @param input.file - The changed file to report alongside the summary.
7124
+ * @param input.changed - The accumulated changed paths handed to `runBuild` (incremental).
6684
7125
  * @returns Resolves once the rebuild settles (always — errors are routed, not thrown).
6685
7126
  * @example
6686
- * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
7127
+ * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md", changed: ["a.md"] });
6687
7128
  */
6688
7129
  async function runOneRebuild(input) {
6689
7130
  input.onRebuildStart?.(input.file);
6690
7131
  try {
6691
- const summary = await input.runBuild();
7132
+ const summary = await input.runBuild(input.changed);
6692
7133
  input.onReloaded({
6693
7134
  file: input.file,
6694
7135
  pageCount: summary.pageCount,
6695
7136
  durationMs: summary.durationMs
6696
- });
7137
+ }, input.changed);
6697
7138
  } catch (error) {
6698
7139
  input.onError(error);
6699
7140
  }
@@ -6707,9 +7148,9 @@ async function runOneRebuild(input) {
6707
7148
  *
6708
7149
  * @param input - The rebuild dependencies.
6709
7150
  * @param input.debounceMs - Debounce window in milliseconds.
6710
- * @param input.runBuild - Runs one build and resolves with its summary.
7151
+ * @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
6711
7152
  * @param input.onRebuildStart - Called with the changed file just before each build runs.
6712
- * @param input.onReloaded - Called with the changed file + summary after a rebuild.
7153
+ * @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
6713
7154
  * @param input.onError - Called when a rebuild throws.
6714
7155
  * @returns The debounced rebuild driver.
6715
7156
  * @example
@@ -6718,12 +7159,13 @@ async function runOneRebuild(input) {
6718
7159
  function createRebuilder(input) {
6719
7160
  let timer;
6720
7161
  let pendingFile = "";
7162
+ const pendingChanged = /* @__PURE__ */ new Set();
6721
7163
  let building = false;
6722
7164
  let dirty = false;
6723
7165
  /**
6724
- * Rebuild repeatedly until no change arrived mid-flight: each pass clears `dirty`,
6725
- * runs one build, then loops again if a `schedule()` set `dirty` while it ran, so
6726
- * no change is dropped.
7166
+ * Rebuild repeatedly until no change arrived mid-flight: each pass snapshots + clears
7167
+ * the accumulated changed paths, runs one build over them, then loops again if a
7168
+ * `schedule()` set `dirty` (and added more paths) while it ran, so no change is dropped.
6727
7169
  *
6728
7170
  * @returns Resolves once a pass completes with no pending change (errors are routed,
6729
7171
  * never thrown).
@@ -6733,12 +7175,15 @@ function createRebuilder(input) {
6733
7175
  const drainPendingRebuilds = async () => {
6734
7176
  do {
6735
7177
  dirty = false;
7178
+ const changed = [...pendingChanged];
7179
+ pendingChanged.clear();
6736
7180
  await runOneRebuild({
6737
7181
  runBuild: input.runBuild,
6738
7182
  ...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
6739
7183
  onReloaded: input.onReloaded,
6740
7184
  onError: input.onError,
6741
- file: pendingFile
7185
+ file: pendingFile,
7186
+ changed
6742
7187
  });
6743
7188
  } while (dirty);
6744
7189
  };
@@ -6765,14 +7210,15 @@ function createRebuilder(input) {
6765
7210
  };
6766
7211
  return {
6767
7212
  /**
6768
- * Queue a rebuild for the given label (debounced + coalesced).
7213
+ * Queue a rebuild for the given changed path (debounced + coalesced + accumulated).
6769
7214
  *
6770
- * @param file - The label reported as `ReloadInfo.file` the watched directory in `serve()`.
7215
+ * @param file - The changed path reported as `ReloadInfo.file` and added to the changed set.
6771
7216
  * @example
6772
- * rebuilder.schedule("content");
7217
+ * rebuilder.schedule("content/intro/en.md");
6773
7218
  */
6774
7219
  schedule(file) {
6775
7220
  pendingFile = file;
7221
+ pendingChanged.add(file);
6776
7222
  if (timer) clearTimeout(timer);
6777
7223
  timer = setTimeout(fire, input.debounceMs);
6778
7224
  },
@@ -6802,26 +7248,33 @@ function isNoisePath(filename) {
6802
7248
  return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
6803
7249
  }
6804
7250
  /**
6805
- * Create a {@link ChangeGate} that drops three kinds of spurious change events before
6806
- * they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
6807
- * `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
6808
- * echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
6809
- * a change whose file mtime is at or before the last build we started was already
6810
- * captured (or is a late echo), so it is ignored while a genuinely newer edit (even
6811
- * one made mid-build) and a deletion (missing file) always pass. The single timestamp
6812
- * also means no per-path map grows over a long session.
7251
+ * Create a {@link ChangeGate} that drops four kinds of spurious change events before they
7252
+ * reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
7253
+ * `outDir` (the build's own output — a loop guard), the stale duplicate/parent-dir echoes
7254
+ * macOS fires for one save (a build-start high-water mark — a change whose mtime is at or
7255
+ * before the last build we started was already captured), and when a `fileHash` seam is
7256
+ * supplied a NO-OP save whose bytes are identical to the last successfully-built version
7257
+ * (a double Ctrl-S, a `touch`, a format-on-save that reverts). The no-op baseline is
7258
+ * recorded ONLY by {@link ChangeGate.commitBuilt} on build SUCCESS, scoped to that build's
7259
+ * paths — so a failed build commits nothing (a retry save always rebuilds) and a file
7260
+ * edited mid-build is never falsely baselined by another file's success. A genuinely newer
7261
+ * edit (even mid-build) and a deletion (missing file) always pass.
6813
7262
  *
6814
7263
  * @param input - The gate dependencies.
6815
7264
  * @param input.outDir - The build output directory whose writes must never re-trigger a build.
6816
7265
  * @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
6817
7266
  * @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
7267
+ * @param input.fileHash - Resolves a path's content hash (or `null` when missing). Optional;
7268
+ * defaults to `() => null`, which disables the no-op-save short-circuit (every edit passes).
6818
7269
  * @returns The change gate.
6819
7270
  * @example
6820
- * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
7271
+ * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock, fileHash: state.fileHash });
6821
7272
  */
6822
7273
  function createChangeGate(input) {
6823
7274
  const outDirAbs = node_path$1.default.resolve(input.outDir);
7275
+ const fileHash = input.fileHash ?? (() => null);
6824
7276
  let lastBuildStartedAt = input.now();
7277
+ const committedHash = /* @__PURE__ */ new Map();
6825
7278
  return {
6826
7279
  /**
6827
7280
  * Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
@@ -6839,6 +7292,12 @@ function createChangeGate(input) {
6839
7292
  if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${node_path$1.default.sep}`)) return false;
6840
7293
  const mtime = input.fileMtime(changed);
6841
7294
  if (mtime !== null && mtime < lastBuildStartedAt) return false;
7295
+ const hash = fileHash(changed);
7296
+ if (hash === null) {
7297
+ committedHash.delete(changed);
7298
+ return true;
7299
+ }
7300
+ if (committedHash.get(changed) === hash) return false;
6842
7301
  return true;
6843
7302
  },
6844
7303
  /**
@@ -6849,6 +7308,20 @@ function createChangeGate(input) {
6849
7308
  */
6850
7309
  markBuildStart() {
6851
7310
  lastBuildStartedAt = input.now();
7311
+ },
7312
+ /**
7313
+ * Baseline exactly the paths the just-succeeded build consumed (see {@link ChangeGate.commitBuilt}).
7314
+ *
7315
+ * @param changed - The paths the just-succeeded build consumed.
7316
+ * @example
7317
+ * gate.commitBuilt(["content/intro/en.md"]);
7318
+ */
7319
+ commitBuilt(changed) {
7320
+ for (const file of changed) {
7321
+ const key = node_path$1.default.resolve(file);
7322
+ const hash = fileHash(key);
7323
+ if (hash !== null) committedHash.set(key, hash);
7324
+ }
6852
7325
  }
6853
7326
  };
6854
7327
  }
@@ -7041,19 +7514,46 @@ function createDevHandler(ctx, hub) {
7041
7514
  };
7042
7515
  }
7043
7516
  /**
7517
+ * Build the per-run {@link BuildRunOverrides} for a dev build from the session feature
7518
+ * opt-ins: minification is always off in dev (no benefit, slower), and each expensive
7519
+ * output stays off unless its flag re-enables it (`ogImage: false` disables OG generation
7520
+ * regardless of the persisted config). The persisted plugin config is never mutated — the
7521
+ * overrides apply to the dev run only.
7522
+ *
7523
+ * @param features - The resolved per-session dev feature opt-ins.
7524
+ * @returns The config overrides merged into the dev build run.
7525
+ * @example
7526
+ * devBuildOverrides({ og: false, sitemap: false, feeds: false, localeRedirects: false });
7527
+ */
7528
+ function devBuildOverrides(features) {
7529
+ return {
7530
+ minify: false,
7531
+ ...features.feeds ? {} : { feeds: false },
7532
+ ...features.sitemap ? {} : { sitemap: false },
7533
+ ...features.og ? {} : { ogImage: false },
7534
+ ...features.localeRedirects ? {} : { localeRedirects: false }
7535
+ };
7536
+ }
7537
+ /**
7044
7538
  * Run the dev loop: an initial build, an in-process static server that injects the
7045
7539
  * live-reload client, a recursive watcher over `config.watchDirs`, and a debounced
7046
7540
  * rebuild that re-renders and pushes a browser reload. Resolves on SIGINT/SIGTERM,
7047
- * which stops the server, closes the watchers, and cancels any pending rebuild.
7541
+ * which stops the server, closes the watchers, and cancels any pending rebuild. The dev
7542
+ * build disables minification + expensive outputs (per {@link devBuildOverrides}); every
7543
+ * rebuild also skips the clean so caches + unchanged assets survive (no mid-rebuild 404).
7544
+ * Because rebuilds skip the clean, a DELETED or renamed content slug's stale page lingers
7545
+ * (and is served) until you restart `serve` or run a production `build`.
7048
7546
  *
7049
7547
  * @param ctx - The cli plugin context (config, state seams, `require`).
7050
7548
  * @param port - The port to bind the dev server to.
7549
+ * @param features - Per-session dev feature opt-ins (`og`/`sitemap`/`feeds`/`localeRedirects`).
7051
7550
  * @returns Resolves once the server has been torn down by a termination signal.
7052
7551
  * @example
7053
- * await runDevServer(ctx, 4173);
7552
+ * await runDevServer(ctx, 4173, { og: false, sitemap: false, feeds: false, localeRedirects: false });
7054
7553
  */
7055
- async function runDevServer(ctx, port) {
7056
- await ctx.require(buildPlugin).run();
7554
+ async function runDevServer(ctx, port, features) {
7555
+ const overrides = devBuildOverrides(features);
7556
+ await ctx.require(buildPlugin).run({ overrides });
7057
7557
  const hub = createReloadHub();
7058
7558
  const server = ctx.state.serveStatic({
7059
7559
  port,
@@ -7063,19 +7563,27 @@ async function runDevServer(ctx, port) {
7063
7563
  const gate = createChangeGate({
7064
7564
  outDir: ctx.config.outDir,
7065
7565
  fileMtime: ctx.state.fileMtime,
7066
- now: ctx.state.clock
7566
+ now: ctx.state.clock,
7567
+ fileHash: ctx.state.fileHash
7067
7568
  });
7068
7569
  const rebuilder = createRebuilder({
7069
7570
  debounceMs: ctx.config.debounceMs,
7070
7571
  /**
7071
- * Re-run the SSG build for a rebuild.
7572
+ * Re-run the SSG build for a rebuild: skip the clean so the prior assets + on-disk
7573
+ * caches survive (and no in-flight request hits an empty outDir), with the dev
7574
+ * overrides applied.
7072
7575
  *
7576
+ * @param changed - The paths changed since the last build (incremental rebuild hint).
7073
7577
  * @returns The rebuild summary.
7074
7578
  * @example
7075
- * await runBuild();
7579
+ * await runBuild(["content/intro/en.md"]);
7076
7580
  */
7077
- runBuild() {
7078
- return ctx.require(buildPlugin).run();
7581
+ runBuild(changed) {
7582
+ return ctx.require(buildPlugin).run({
7583
+ skipClean: true,
7584
+ overrides,
7585
+ changed
7586
+ });
7079
7587
  },
7080
7588
  /**
7081
7589
  * Show the compact in-place "rebuilding {label}" line before the build runs.
@@ -7092,15 +7600,18 @@ async function runDevServer(ctx, port) {
7092
7600
  * Render the reload line and push a browser reload after a rebuild.
7093
7601
  *
7094
7602
  * @param info - The changed file plus the rebuild's page count and duration.
7603
+ * @param changed - The paths this successful build consumed (baselined for no-op drops).
7095
7604
  * @example
7096
- * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 });
7605
+ * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 }, ["content/a.md"]);
7097
7606
  */
7098
- onReloaded(info) {
7607
+ onReloaded(info, changed) {
7608
+ gate.commitBuilt(changed);
7099
7609
  ctx.state.render.reload(info);
7100
7610
  hub.reloadAll();
7101
7611
  },
7102
7612
  /**
7103
- * Render a rebuild failure (the dev loop keeps running).
7613
+ * Render a rebuild failure (the dev loop keeps running). A failed build baselines
7614
+ * nothing (commitBuilt only runs on success), so an identical retry save still rebuilds.
7104
7615
  *
7105
7616
  * @param error - The thrown rebuild error.
7106
7617
  * @example
@@ -7111,7 +7622,8 @@ async function runDevServer(ctx, port) {
7111
7622
  }
7112
7623
  });
7113
7624
  const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
7114
- if (gate.accept(dir, filename)) rebuilder.schedule(dir);
7625
+ if (!gate.accept(dir, filename)) return;
7626
+ rebuilder.schedule(filename === void 0 ? dir : node_path$1.default.join(dir, filename));
7115
7627
  }));
7116
7628
  ctx.state.render.serverReady({
7117
7629
  local: `http://localhost:${port}`,
@@ -7123,6 +7635,7 @@ async function runDevServer(ctx, port) {
7123
7635
  for (const watcher of watchers) watcher.close();
7124
7636
  hub.close();
7125
7637
  server.stop();
7638
+ ctx.state.render.dispose();
7126
7639
  });
7127
7640
  }
7128
7641
  //#endregion
@@ -7323,6 +7836,28 @@ async function confirmDeploy(ctx, yes) {
7323
7836
  if (!confirmed) ctx.state.render.warn("deploy skipped");
7324
7837
  return confirmed;
7325
7838
  }
7839
+ /** Matches the prerequisite/credential failures a direct deploy most often hits (missing token/account). */
7840
+ const PREREQUISITE_ERROR = /required variable|not defined|cloudflare|token|account|unauthor|wrangler/i;
7841
+ /**
7842
+ * A short, actionable "how to fix" hint for a failed deploy, rendered under the error so
7843
+ * the user is never left at a raw stack trace. A missing-credential/prerequisite failure
7844
+ * (the common case for a first direct deploy) gets the concrete secret-setup steps;
7845
+ * anything else points at the guided deploy, which diagnoses prerequisites step by step.
7846
+ *
7847
+ * @param error - The thrown deploy error.
7848
+ * @returns The multi-line hint (newline-separated; rendered indented under a `›`).
7849
+ * @example
7850
+ * render.info(deployFailureHint(err));
7851
+ */
7852
+ function deployFailureHint(error) {
7853
+ if (PREREQUISITE_ERROR.test(String(error))) return [
7854
+ "how to fix:",
7855
+ "1. run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites and offers to create a starter .env",
7856
+ "2. or set them yourself in .env: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID",
7857
+ " token: https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
7858
+ ].join("\n");
7859
+ return "how to fix: run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites — then retry";
7860
+ }
7326
7861
  /**
7327
7862
  * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
7328
7863
  * Each method renders `state.render.header(<command>)` first, then does its work;
@@ -7355,17 +7890,25 @@ function createApi$1(ctx) {
7355
7890
  },
7356
7891
  /**
7357
7892
  * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
7358
- * `watchDirs`, debounced rebuild + reload. Resolves on SIGINT/SIGTERM.
7893
+ * `watchDirs`, debounced + incremental rebuild + reload. For a fast rebuild the dev
7894
+ * build disables minification + expensive, preview-irrelevant outputs (feeds /
7895
+ * sitemap / og-images / locale-redirects); pass `og`/`sitemap`/`feeds`/
7896
+ * `localeRedirects` to re-enable any of them for the session. Resolves on SIGINT/SIGTERM.
7359
7897
  *
7360
- * @param options - Optional port override (defaults to `config.port`).
7898
+ * @param options - Optional port override + per-session dev feature opt-ins.
7361
7899
  * @returns Resolves once the server has been torn down.
7362
7900
  * @example
7363
- * await api.serve({ port: 3000 });
7901
+ * await api.serve({ port: 3000, og: true });
7364
7902
  */
7365
7903
  serve(options = {}) {
7366
7904
  const { port = ctx.config.port } = options;
7367
7905
  ctx.state.render.header("serve");
7368
- return runDevServer(ctx, port);
7906
+ return runDevServer(ctx, port, {
7907
+ og: options.og ?? false,
7908
+ sitemap: options.sitemap ?? false,
7909
+ feeds: options.feeds ?? false,
7910
+ localeRedirects: options.localeRedirects ?? false
7911
+ });
7369
7912
  },
7370
7913
  /**
7371
7914
  * Static preview of the built `dist/` with CF-Pages clean-URL resolution.
@@ -7397,15 +7940,21 @@ function createApi$1(ctx) {
7397
7940
  const { branch, yes = false } = options;
7398
7941
  ctx.state.render.header("deploy");
7399
7942
  if (options.guided === true) return runDeployWizard(ctx, options);
7400
- await ctx.require(deployPlugin).init({ ci: true });
7401
- if (!await confirmDeploy(ctx, yes)) return {
7402
- deployed: false,
7403
- reason: "declined"
7404
- };
7405
- return {
7406
- deployed: true,
7407
- ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
7408
- };
7943
+ try {
7944
+ await ctx.require(deployPlugin).init({ ci: true });
7945
+ if (!await confirmDeploy(ctx, yes)) return {
7946
+ deployed: false,
7947
+ reason: "declined"
7948
+ };
7949
+ return {
7950
+ deployed: true,
7951
+ ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
7952
+ };
7953
+ } catch (error) {
7954
+ ctx.state.render.error("deploy failed", error);
7955
+ ctx.state.render.info(deployFailureHint(error));
7956
+ throw error;
7957
+ }
7409
7958
  }
7410
7959
  };
7411
7960
  }
@@ -7496,6 +8045,28 @@ const ANSI = {
7496
8045
  cyan: `${ESC}[36m`,
7497
8046
  gray: `${ESC}[90m`
7498
8047
  };
8048
+ /**
8049
+ * The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
8050
+ * Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
8051
+ */
8052
+ const BRAND_PINK = {
8053
+ r: 255,
8054
+ g: 30,
8055
+ b: 111
8056
+ };
8057
+ /**
8058
+ * Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
8059
+ *
8060
+ * @param r - Red channel (0–255).
8061
+ * @param g - Green channel (0–255).
8062
+ * @param b - Blue channel (0–255).
8063
+ * @returns The `ESC[38;2;r;g;bm` foreground sequence.
8064
+ * @example
8065
+ * fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
8066
+ */
8067
+ function fg24(r, g, b) {
8068
+ return `${ESC}[38;2;${r};${g};${b}m`;
8069
+ }
7499
8070
  /** ANSI: erase the entire current line, leaving the cursor where it is. */
7500
8071
  const CLEAR_LINE = `${ESC}[2K`;
7501
8072
  /** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
@@ -7567,6 +8138,36 @@ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR)
7567
8138
  return stream.isTTY === true && noColor === void 0;
7568
8139
  }
7569
8140
  /**
8141
+ * Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
8142
+ * renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
8143
+ * `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
8144
+ * is never used when color itself is disabled.
8145
+ *
8146
+ * @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
8147
+ * @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
8148
+ * @example
8149
+ * supportsTruecolor("truecolor"); // true
8150
+ */
8151
+ function supportsTruecolor(colorTerm = process.env.COLORTERM) {
8152
+ return colorTerm === "truecolor" || colorTerm === "24bit";
8153
+ }
8154
+ /**
8155
+ * The braille spinner glyph for a given elapsed time, advancing one frame per
8156
+ * `frameMs`. Deriving the frame from wall-clock elapsed (rather than a tick counter)
8157
+ * keeps the spinner correct even when the animation ticker is briefly starved by a
8158
+ * synchronous build phase and several ticks coalesce — the glyph still reflects real
8159
+ * elapsed time instead of freezing on a stale frame.
8160
+ *
8161
+ * @param elapsedMs - Milliseconds since the live region opened.
8162
+ * @param frameMs - Milliseconds per frame (defaults to `80`).
8163
+ * @returns The active spinner glyph.
8164
+ * @example
8165
+ * spinnerFrameAt(240); // "⠹" (the 4th frame at 80ms/frame)
8166
+ */
8167
+ function spinnerFrameAt(elapsedMs, frameMs = 80) {
8168
+ return SPINNER_FRAMES[Math.floor(Math.max(0, elapsedMs) / frameMs) % SPINNER_FRAMES.length] ?? "⠋";
8169
+ }
8170
+ /**
7570
8171
  * Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
7571
8172
  *
7572
8173
  * @param color - Whether color/Unicode output is enabled.
@@ -7594,12 +8195,14 @@ function visibleWidth(text) {
7594
8195
  * output in CI/pipes.
7595
8196
  *
7596
8197
  * @param color - Whether color is enabled (typically `supportsColor()`).
8198
+ * @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
8199
+ * only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
7597
8200
  * @returns The bound color palette.
7598
8201
  * @example
7599
- * const palette = makePalette(supportsColor());
8202
+ * const palette = makePalette(supportsColor(), supportsTruecolor());
7600
8203
  * const line = palette.green("done");
7601
8204
  */
7602
- function makePalette(color) {
8205
+ function makePalette(color, truecolor = false) {
7603
8206
  return {
7604
8207
  enabled: color,
7605
8208
  /**
@@ -7679,23 +8282,39 @@ function makePalette(color) {
7679
8282
  */
7680
8283
  cyan(text) {
7681
8284
  return this.paint(ANSI.cyan, text);
8285
+ },
8286
+ /**
8287
+ * Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
8288
+ * is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
8289
+ *
8290
+ * @param text - The text to colorize.
8291
+ * @returns The pink (or unchanged) text.
8292
+ * @example
8293
+ * palette.pink("▟▙ moku web");
8294
+ */
8295
+ pink(text) {
8296
+ if (!color) return text;
8297
+ if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
8298
+ return this.paint(ANSI.magenta, text);
7682
8299
  }
7683
8300
  };
7684
8301
  }
7685
8302
  /**
7686
8303
  * Frame a list of already-rendered content lines in a box, padding each line to the
7687
- * width of the widest visible line. Uses Unicode borders when `color` is enabled and
7688
- * ASCII otherwise. Visible width ignores embedded ANSI so colored lines align.
8304
+ * widest visible line (or `minInnerWidth`, whichever is larger so several boxes can be
8305
+ * forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
8306
+ * otherwise. Visible width ignores embedded ANSI so colored lines align.
7689
8307
  *
7690
8308
  * @param lines - The content lines (may contain ANSI color codes).
7691
8309
  * @param color - Whether to use Unicode borders (and assume color-capable output).
8310
+ * @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
7692
8311
  * @returns The boxed lines (top border, content rows, bottom border).
7693
8312
  * @example
7694
- * box(["Local: http://localhost:4173"], true);
8313
+ * box(["Local: http://localhost:4173"], true, 62);
7695
8314
  */
7696
- function box(lines, color) {
8315
+ function box(lines, color, minInnerWidth = 0) {
7697
8316
  const glyphs = boxGlyphs(color);
7698
- const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
8317
+ const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
7699
8318
  const horizontal = glyphs.horizontal.repeat(inner + 2);
7700
8319
  const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
7701
8320
  const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
@@ -7710,13 +8329,70 @@ function box(lines, color) {
7710
8329
  }
7711
8330
  //#endregion
7712
8331
  //#region src/plugins/cli/render/panel.ts
7713
- /** Per-command label shown in the header badge beside the logo. */
8332
+ /** Per-command label shown beside the lockup wordmark. */
7714
8333
  const COMMAND_LABEL = {
7715
8334
  build: "build",
7716
8335
  serve: "serve · dev",
7717
8336
  preview: "preview",
7718
8337
  deploy: "deploy"
7719
8338
  };
8339
+ /** Total visible width the header rule spans and the per-row timing column right-aligns to. */
8340
+ const RAIL_WIDTH = 66;
8341
+ /** Animation repaint cadence (ms) — how often the live region is redrawn when the loop is free. */
8342
+ const TICK_MS = 40;
8343
+ /** Spinner frame interval (ms) — one braille glyph advance per this many elapsed ms. */
8344
+ const SPIN_MS = 60;
8345
+ /** Inner (content) width of the BUILD/server boxes so their right edge lines up with the phase tree. */
8346
+ const BOX_INNER = RAIL_WIDTH - 4;
8347
+ /** The eight block glyphs the per-phase time-profile sparkline maps durations onto. */
8348
+ const SPARK_BARS = "▁▂▃▄▅▆▇█";
8349
+ /**
8350
+ * Build a sparkline from a list of values — one block glyph per value, height scaled to
8351
+ * the largest value so the tallest bar is `█`. A real micro-histogram (no fake data):
8352
+ * under the BUILD summary each bar is one phase's duration, so the slowest phase stands
8353
+ * out at a glance. Returns `""` for an empty list.
8354
+ *
8355
+ * @param values - The values to plot (e.g. per-phase durations in ms).
8356
+ * @returns The sparkline string.
8357
+ * @example
8358
+ * sparkline([12, 1701, 19698, 9]); // "▁▁█▁"
8359
+ */
8360
+ function sparkline(values) {
8361
+ if (values.length === 0) return "";
8362
+ const max = Math.max(...values, 1);
8363
+ return values.map((value) => {
8364
+ return SPARK_BARS[Math.min(7, Math.floor(value / max * 7))] ?? SPARK_BARS[0];
8365
+ }).join("");
8366
+ }
8367
+ /**
8368
+ * The structural glyph set for the active color mode: Unicode on a color-capable TTY,
8369
+ * ASCII fallbacks off it. Only the NEW Velocity chrome (cube, rule, tree, bar, live
8370
+ * dot) degrades here — the `✓ ✗ ~ ➜ ›` status marks stay as-is in both modes.
8371
+ *
8372
+ * @param color - Whether color/Unicode output is enabled.
8373
+ * @returns The matching glyph set.
8374
+ * @example
8375
+ * const g = glyphSet(true);
8376
+ */
8377
+ function glyphSet(color) {
8378
+ return color ? {
8379
+ cube: "▟▙",
8380
+ rule: "─",
8381
+ tree: "├─",
8382
+ barFill: "━",
8383
+ barTrack: "╴",
8384
+ liveOn: "◍",
8385
+ liveOff: "○"
8386
+ } : {
8387
+ cube: "*",
8388
+ rule: "-",
8389
+ tree: "-",
8390
+ barFill: "#",
8391
+ barTrack: "-",
8392
+ liveOn: "*",
8393
+ liveOff: "*"
8394
+ };
8395
+ }
7720
8396
  /**
7721
8397
  * Render one human-readable duration suffix (e.g. `· 84ms`).
7722
8398
  *
@@ -7731,15 +8407,49 @@ function durationSuffix(palette, durationMs) {
7731
8407
  return ` ${palette.dim(`· ${durationMs}ms`)}`;
7732
8408
  }
7733
8409
  /**
8410
+ * Right-align `right` against `left` within {@link RAIL_WIDTH}, measuring visible width
8411
+ * so embedded ANSI never throws the timing column off.
8412
+ *
8413
+ * @param left - The left segment (may contain ANSI).
8414
+ * @param right - The right segment (may contain ANSI).
8415
+ * @param width - Total visible width to fill (defaults to {@link RAIL_WIDTH}).
8416
+ * @returns The padded line.
8417
+ * @example
8418
+ * railLine(" ├─ ✓ pages", "· 12ms");
8419
+ */
8420
+ function railLine(left, right, width = RAIL_WIDTH) {
8421
+ const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
8422
+ return `${left}${" ".repeat(gap)}${right}`;
8423
+ }
8424
+ /**
8425
+ * The runtime facts line shown under the banner: the pinned core version (when known)
8426
+ * plus the live Node/Bun versions + platform — the ACTUAL running runtime, not the
8427
+ * `engines` floor. Every value is real (read from `@moku-labs/core`'s pinned dependency
8428
+ * and `process.versions`), so nothing on this line is faked.
8429
+ *
8430
+ * @param coreVersion - The pinned `@moku-labs/core` version (appended last — it rarely
8431
+ * matters — and omitted entirely when unknown).
8432
+ * @returns The facts string (e.g. `node 24.3.0 · bun 1.3.9 · darwin arm64 · core 0.1.0-alpha.6`).
8433
+ * @example
8434
+ * runtimeFacts("0.1.0-alpha.6");
8435
+ */
8436
+ function runtimeFacts(coreVersion) {
8437
+ const node = `node ${process.versions.node}`;
8438
+ const bun = process.versions.bun ? ` · bun ${process.versions.bun}` : "";
8439
+ const core = coreVersion ? ` · core ${coreVersion}` : "";
8440
+ return `${node}${bun} · ${process.platform} ${process.arch}${core}`;
8441
+ }
8442
+ /**
7734
8443
  * Create the Panel {@link CliRenderer}. Output is written through the injected sink
7735
- * (default `console.log`/`console.error`) and colorized only when color is enabled,
7736
- * so the identical render path yields box-drawn color panels on a TTY and plain
7737
- * ASCII lines in CI/pipes.
8444
+ * (default `console.log`/`console.error`) and colorized only when color is enabled, so
8445
+ * the identical render path yields the animated, box-free Velocity UI on a TTY and
8446
+ * plain ASCII lines in CI/pipes.
7738
8447
  *
7739
- * @param options - Optional sinks + a color override (see {@link PanelOptions}).
8448
+ * @param options - Optional sinks, color/truecolor overrides, clock, and version (see
8449
+ * {@link PanelOptions}).
7740
8450
  * @returns The renderer mounted on `state.render` and driven by the API + hooks.
7741
8451
  * @example
7742
- * const render = createPanelRenderer();
8452
+ * const render = createPanelRenderer({ version: "0.1.0-alpha" });
7743
8453
  * render.header("build");
7744
8454
  */
7745
8455
  function createPanelRenderer(options = {}) {
@@ -7750,26 +8460,24 @@ function createPanelRenderer(options = {}) {
7750
8460
  });
7751
8461
  const now = options.now ?? Date.now;
7752
8462
  const color = options.color ?? supportsColor();
7753
- const palette = makePalette(color);
8463
+ const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
8464
+ const version = options.version ?? "dev";
8465
+ const coreVersion = options.coreVersion;
8466
+ const g = glyphSet(color);
7754
8467
  let phaseRows = [];
7755
8468
  let phaseDrawn = 0;
7756
8469
  let phaseOpen = false;
8470
+ let blockStartedAt = 0;
7757
8471
  let rebuilding = false;
7758
8472
  let rebuildLabel = "";
7759
8473
  let rebuildStartedAt = 0;
7760
- let spinnerFrame = 0;
8474
+ let idle = false;
8475
+ let idleStartedAt = 0;
8476
+ let serveMode = false;
7761
8477
  let ticker;
7762
8478
  /**
7763
- * The current spinner glyph (with a static fallback under `noUncheckedIndexedAccess`).
7764
- *
7765
- * @returns The active braille spinner frame.
7766
- * @example
7767
- * frameGlyph(); // "⠙"
7768
- */
7769
- const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
7770
- /**
7771
- * Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
7772
- * before the dim name.
8479
+ * Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
8480
+ * `✓` + name with the duration right-aligned in the dim timing column once done.
7773
8481
  *
7774
8482
  * @param row - The phase row to render.
7775
8483
  * @returns The rendered row line (no trailing newline).
@@ -7777,12 +8485,34 @@ function createPanelRenderer(options = {}) {
7777
8485
  * renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
7778
8486
  */
7779
8487
  const renderPhaseRow = (row) => {
7780
- if (row.done) return ` ${palette.green("✓")} ${row.name}${durationSuffix(palette, row.durationMs)}`;
7781
- return ` ${palette.cyan(frameGlyph())} ${palette.dim(row.name)}`;
8488
+ const branch = palette.dim(g.tree);
8489
+ if (row.done) return railLine(` ${branch} ${palette.green("✓")} ${row.name}`, palette.dim(`· ${row.durationMs}ms`));
8490
+ return ` ${branch} ${palette.cyan(spinnerFrameAt(now() - blockStartedAt, SPIN_MS))} ${palette.dim(row.name)}`;
7782
8491
  };
7783
8492
  /**
7784
- * Repaint the live phase block in place: move up over the prior draw, then rewrite each
7785
- * row (clearing any stale trailing lines).
8493
+ * Render the indeterminate "comet" build bar a short pink fill window sweeping across
8494
+ * a dim track for the given elapsed time. Animated purely from wall-clock elapsed so
8495
+ * it never needs a known phase total.
8496
+ *
8497
+ * @param elapsedMs - Milliseconds since the phase block opened.
8498
+ * @returns The rendered bar row (no trailing newline).
8499
+ * @example
8500
+ * renderBuildBar(300);
8501
+ */
8502
+ const renderBuildBar = (elapsedMs) => {
8503
+ const length = 28;
8504
+ const window = 6;
8505
+ const head = Math.floor(elapsedMs / 28) % 34;
8506
+ let bar = "";
8507
+ for (let index = 0; index < length; index++) {
8508
+ const lit = index <= head && index > head - window;
8509
+ bar += lit ? palette.pink(g.barFill) : palette.dim(g.barTrack);
8510
+ }
8511
+ return ` ${bar}`;
8512
+ };
8513
+ /**
8514
+ * Repaint the live phase block in place (tree rows + animated build bar): move up over
8515
+ * the prior draw, rewrite each row, then the bar, clearing any stale trailing lines.
7786
8516
  *
7787
8517
  * @example
7788
8518
  * paintPhaseBlock();
@@ -7790,8 +8520,9 @@ function createPanelRenderer(options = {}) {
7790
8520
  const paintPhaseBlock = () => {
7791
8521
  let frame = cursorUp(phaseDrawn);
7792
8522
  for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
8523
+ frame += `${CLEAR_LINE}${renderBuildBar(now() - blockStartedAt)}\n`;
7793
8524
  writeRaw(frame + CLEAR_BELOW);
7794
- phaseDrawn = phaseRows.length;
8525
+ phaseDrawn = phaseRows.length + 1;
7795
8526
  };
7796
8527
  /**
7797
8528
  * Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
@@ -7800,20 +8531,31 @@ function createPanelRenderer(options = {}) {
7800
8531
  * paintRebuildLine();
7801
8532
  */
7802
8533
  const paintRebuildLine = () => {
7803
- const elapsed = ((now() - rebuildStartedAt) / 1e3).toFixed(1);
7804
- const meta = palette.dim(`· ${elapsed}s`);
7805
- writeRaw(`\r${CLEAR_LINE} ${palette.cyan(frameGlyph())} rebuilding ${rebuildLabel} ${meta}`);
8534
+ const spinner = palette.cyan(spinnerFrameAt(now() - rebuildStartedAt, SPIN_MS));
8535
+ const elapsed = palette.dim(`· ${((now() - rebuildStartedAt) / 1e3).toFixed(1)}s`);
8536
+ writeRaw(`\r${CLEAR_LINE} ${spinner} rebuilding ${rebuildLabel} ${elapsed}`);
7806
8537
  };
7807
8538
  /**
7808
- * Advance the spinner one frame and repaint whichever live region is active.
8539
+ * Repaint the persistent in-place `◍ live` idle pulse beneath the serve panel — the
8540
+ * dot breathes (pink → dim) on a calm ~0.6s cycle so a quiet dev session always reads
8541
+ * as alive without strobing.
8542
+ *
8543
+ * @example
8544
+ * paintIdleLine();
8545
+ */
8546
+ const paintIdleLine = () => {
8547
+ writeRaw(`\r${CLEAR_LINE} ${Math.floor((now() - idleStartedAt) / 450) % 2 === 0 ? palette.pink(g.liveOn) : palette.dim(g.liveOff)} ${palette.dim("live · waiting for changes…")}`);
8548
+ };
8549
+ /**
8550
+ * Advance whichever live region is active by one frame (driven by the shared ticker).
7809
8551
  *
7810
8552
  * @example
7811
8553
  * onTick();
7812
8554
  */
7813
8555
  const onTick = () => {
7814
- spinnerFrame += 1;
7815
8556
  if (rebuilding) paintRebuildLine();
7816
8557
  else if (phaseOpen) paintPhaseBlock();
8558
+ else if (idle) paintIdleLine();
7817
8559
  };
7818
8560
  /**
7819
8561
  * Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
@@ -7823,7 +8565,7 @@ function createPanelRenderer(options = {}) {
7823
8565
  */
7824
8566
  const startTicker = () => {
7825
8567
  if (!color || ticker) return;
7826
- ticker = setInterval(onTick, 80);
8568
+ ticker = setInterval(onTick, TICK_MS);
7827
8569
  ticker.unref?.();
7828
8570
  };
7829
8571
  /**
@@ -7846,22 +8588,47 @@ function createPanelRenderer(options = {}) {
7846
8588
  const writeBlock = (lines) => {
7847
8589
  for (const line of lines) write(line);
7848
8590
  };
8591
+ /**
8592
+ * Resume the serve idle pulse on a fresh bottom line (TTY serve sessions only). A no-op
8593
+ * outside serve so standalone rebuild/error calls in unit tests never leave a ticker
8594
+ * running.
8595
+ *
8596
+ * @example
8597
+ * resumeIdle();
8598
+ */
8599
+ const resumeIdle = () => {
8600
+ if (!(color && serveMode)) {
8601
+ stopTicker();
8602
+ return;
8603
+ }
8604
+ idle = true;
8605
+ idleStartedAt = now();
8606
+ paintIdleLine();
8607
+ startTicker();
8608
+ };
7849
8609
  return {
7850
8610
  /**
7851
- * Render the boxed `MOKU WEB` logo + command label.
8611
+ * Render the `▟▙ moku web` lockup + per-command label, a dim rule, and the runtime
8612
+ * facts line (live Node/Bun versions + platform). Called once per command (one
8613
+ * command = one process), so it never repeats within a run.
7852
8614
  *
7853
- * @param command - The command being run, shown beside the logo.
8615
+ * @param command - The command being run, shown beside the wordmark.
7854
8616
  * @example
7855
8617
  * render.header("serve");
7856
8618
  */
7857
8619
  header(command) {
7858
- writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
8620
+ writeBlock([
8621
+ railLine(` ${palette.pink(g.cube)} ${palette.pink(palette.bold("moku web"))} ${palette.dim(COMMAND_LABEL[command])}`, palette.dim(version)),
8622
+ ` ${palette.dim(g.rule.repeat(RAIL_WIDTH - 1))}`,
8623
+ ` ${palette.dim(runtimeFacts(coreVersion))}`
8624
+ ]);
7859
8625
  },
7860
8626
  /**
7861
- * Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE row
7862
- * that updates in place (spinning glyph while running → green ✓ + duration when done);
7863
- * off a TTY one line is printed per completed phase (no start/done duplication). A
7864
- * no-op while a serve() rebuild is in flight — those show the compact rebuild line.
8627
+ * Render a live per-phase row from a `build:phase` event. On a TTY each phase is ONE
8628
+ * tree row that updates in place (spinning glyph while running → green ✓ + duration
8629
+ * when done) beneath an animated indeterminate build bar; off a TTY one line is
8630
+ * printed per completed phase (no start/done duplication). A no-op while a serve()
8631
+ * rebuild is in flight — those show the compact rebuild line.
7865
8632
  *
7866
8633
  * @param phase - The `build:phase` payload.
7867
8634
  * @example
@@ -7877,6 +8644,7 @@ function createPanelRenderer(options = {}) {
7877
8644
  phaseRows = [];
7878
8645
  phaseDrawn = 0;
7879
8646
  phaseOpen = true;
8647
+ blockStartedAt = now();
7880
8648
  }
7881
8649
  const done = phase.status === "done";
7882
8650
  const existing = phaseRows.find((row) => row.name === phase.phase);
@@ -7892,7 +8660,9 @@ function createPanelRenderer(options = {}) {
7892
8660
  startTicker();
7893
8661
  },
7894
8662
  /**
7895
- * Render the BUILD summary block from a `build:complete` event.
8663
+ * Render the BUILD summary line + a one-shot throughput sparkline from a
8664
+ * `build:complete` event, finalizing the live phase tree (dropping its animated bar)
8665
+ * first.
7896
8666
  *
7897
8667
  * @param summary - The `build:complete` payload.
7898
8668
  * @example
@@ -7900,33 +8670,52 @@ function createPanelRenderer(options = {}) {
7900
8670
  */
7901
8671
  built(summary) {
7902
8672
  if (rebuilding) return;
8673
+ if (color && phaseOpen) {
8674
+ let frame = cursorUp(phaseDrawn);
8675
+ for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
8676
+ writeRaw(frame + CLEAR_BELOW);
8677
+ }
8678
+ const phaseDurations = phaseRows.map((row) => row.durationMs).filter((value) => value !== void 0);
7903
8679
  phaseOpen = false;
7904
8680
  phaseDrawn = 0;
7905
8681
  stopTicker();
7906
8682
  const pages = palette.bold(String(summary.pageCount));
7907
- writeBlock(box([
7908
- `${palette.green("✓")} ${palette.bold("BUILD")} complete`,
7909
- `${palette.dim("pages")} ${pages}`,
7910
- `${palette.dim("time")} ${summary.durationMs}ms`,
7911
- `${palette.dim("out")} ${summary.outDir}/`
7912
- ], color));
8683
+ const dot = palette.dim("·");
8684
+ const lines = [railLine(`${palette.green("✓")} ${palette.bold("BUILD")} ${dot} ${pages} pages`, `${summary.durationMs}ms ${dot} ${summary.outDir}/`, BOX_INNER)];
8685
+ if (color && summary.durationMs > 0) {
8686
+ const rate = Math.max(1, Math.round(summary.pageCount / (summary.durationMs / 1e3)));
8687
+ const spark = phaseDurations.length > 0 ? palette.pink(sparkline(phaseDurations)) : "";
8688
+ const rateLabel = palette.dim(`${rate} pages/s`);
8689
+ lines.push(railLine(spark, rateLabel, BOX_INNER));
8690
+ }
8691
+ writeBlock(box(lines, color, BOX_INNER));
7913
8692
  },
7914
8693
  /**
7915
- * Render the bordered server-ready panel (Local / Network URLs + watched dirs).
8694
+ * Render the server-ready rail (Local / Network URLs + watched dirs) and, on a TTY,
8695
+ * begin the persistent breathing `◍ live` idle pulse beneath it.
7916
8696
  *
7917
8697
  * @param info - Local/Network URLs and optionally the watched directories.
7918
8698
  * @example
7919
8699
  * render.serverReady({ local: "http://localhost:4173", network: null });
7920
8700
  */
7921
8701
  serverReady(info) {
7922
- const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${info.network ? palette.cyan(info.network) : palette.dim("unavailable")}`];
7923
- if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
7924
- writeBlock(box(lines, color));
8702
+ const network = info.network ? palette.cyan(info.network) : palette.dim("unavailable");
8703
+ const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${network}`];
8704
+ if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
8705
+ writeBlock(box(lines, color, BOX_INNER));
8706
+ if (color) {
8707
+ serveMode = true;
8708
+ idle = true;
8709
+ idleStartedAt = now();
8710
+ paintIdleLine();
8711
+ startTicker();
8712
+ }
7925
8713
  },
7926
8714
  /**
7927
8715
  * Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
7928
- * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise) and mute
7929
- * the verbose phase rows + BUILD box until {@link reload}/{@link error} settles it.
8716
+ * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise), taking over
8717
+ * the idle-pulse line, and mute the verbose phase tree + BUILD summary until
8718
+ * {@link reload}/{@link error} settles it.
7930
8719
  *
7931
8720
  * @param label - The changed watch target shown in the line.
7932
8721
  * @example
@@ -7934,9 +8723,9 @@ function createPanelRenderer(options = {}) {
7934
8723
  */
7935
8724
  rebuildStart(label) {
7936
8725
  rebuilding = true;
8726
+ idle = false;
7937
8727
  rebuildLabel = label;
7938
8728
  rebuildStartedAt = now();
7939
- spinnerFrame = 0;
7940
8729
  if (!color) {
7941
8730
  write(` ${palette.yellow("~")} ${label}`);
7942
8731
  return;
@@ -7946,9 +8735,9 @@ function createPanelRenderer(options = {}) {
7946
8735
  },
7947
8736
  /**
7948
8737
  * Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
7949
- * "✓ rebuilt N pages · Xms · reloaded" (on a TTY) and re-enable verbose build output.
7950
- * Called standalone (no preceding {@link rebuildStart}) it also prints the "~ file"
7951
- * line so the changed target stays visible.
8738
+ * "✓ rebuilt N pages · Xms · reloaded", then (in a serve session) resume the idle pulse
8739
+ * on a fresh bottom line. Called standalone (no preceding {@link rebuildStart}) it also
8740
+ * prints the "~ file" line so the changed target stays visible.
7952
8741
  *
7953
8742
  * @param info - The changed file plus the rebuild's page count and duration.
7954
8743
  * @example
@@ -7957,30 +8746,26 @@ function createPanelRenderer(options = {}) {
7957
8746
  reload(info) {
7958
8747
  const settledRebuild = rebuilding;
7959
8748
  rebuilding = false;
7960
- stopTicker();
7961
8749
  const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
7962
8750
  if (settledRebuild && color) {
7963
8751
  writeRaw(`\r${CLEAR_LINE}${line}\n`);
8752
+ resumeIdle();
7964
8753
  return;
7965
8754
  }
7966
8755
  if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
7967
8756
  write(line);
7968
8757
  },
7969
8758
  /**
7970
- * Render the deploy result panel from a `deploy:complete` event.
8759
+ * Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
8760
+ * with the URL the hero value, then a dim `branch · id · time` line beneath it.
7971
8761
  *
7972
8762
  * @param result - The `deploy:complete` payload.
7973
8763
  * @example
7974
8764
  * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
7975
8765
  */
7976
8766
  deployed(result) {
7977
- writeBlock(box([
7978
- `${palette.green("✓")} ${palette.bold("DEPLOYED")}`,
7979
- `${palette.dim("url")} ${palette.cyan(result.url)}`,
7980
- `${palette.dim("branch")} ${result.branch}`,
7981
- `${palette.dim("id")} ${result.deploymentId}`,
7982
- `${palette.dim("time")} ${result.durationMs}ms`
7983
- ], color));
8767
+ const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
8768
+ writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
7984
8769
  },
7985
8770
  /**
7986
8771
  * Render a neutral informational line.
@@ -8005,7 +8790,8 @@ function createPanelRenderer(options = {}) {
8005
8790
  writeError(` ${palette.yellow("⚠")} ${message}`);
8006
8791
  },
8007
8792
  /**
8008
- * Render an error line (to stderr), optionally with a cause.
8793
+ * Render an error line (to stderr), optionally with a cause. A failing rebuild settles
8794
+ * its in-place spinner line first; in a serve session the idle pulse then resumes.
8009
8795
  *
8010
8796
  * @param message - The error summary to print.
8011
8797
  * @param cause - Optional underlying error/value to print beneath the summary.
@@ -8013,16 +8799,18 @@ function createPanelRenderer(options = {}) {
8013
8799
  * render.error("build failed", err);
8014
8800
  */
8015
8801
  error(message, cause) {
8802
+ const wasRebuilding = rebuilding;
8016
8803
  if (rebuilding) {
8017
8804
  rebuilding = false;
8018
- stopTicker();
8019
8805
  if (color) writeRaw(`\r${CLEAR_LINE}`);
8020
8806
  }
8021
8807
  writeError(` ${palette.red("✗")} ${message}`);
8022
8808
  if (cause !== void 0) writeError(String(cause));
8809
+ if (wasRebuilding) resumeIdle();
8810
+ else stopTicker();
8023
8811
  },
8024
8812
  /**
8025
- * Render a section heading (a blank line + a bold cyan label) for a multi-step flow.
8813
+ * Render a section heading (a blank line + a bold pink label) for a multi-step flow.
8026
8814
  *
8027
8815
  * @param text - The heading label.
8028
8816
  * @example
@@ -8030,7 +8818,7 @@ function createPanelRenderer(options = {}) {
8030
8818
  */
8031
8819
  heading(text) {
8032
8820
  write("");
8033
- write(` ${palette.bold(palette.cyan(text))}`);
8821
+ write(` ${palette.bold(palette.pink(text))}`);
8034
8822
  },
8035
8823
  /**
8036
8824
  * Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
@@ -8045,6 +8833,19 @@ function createPanelRenderer(options = {}) {
8045
8833
  check(ok, label, detail) {
8046
8834
  write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
8047
8835
  if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
8836
+ },
8837
+ /**
8838
+ * Stop every animation and release the interval timer (serve()'s teardown calls this).
8839
+ *
8840
+ * @example
8841
+ * render.dispose();
8842
+ */
8843
+ dispose() {
8844
+ stopTicker();
8845
+ idle = false;
8846
+ rebuilding = false;
8847
+ phaseOpen = false;
8848
+ serveMode = false;
8048
8849
  }
8049
8850
  };
8050
8851
  }
@@ -8190,6 +8991,23 @@ function defaultFileMtime(filePath) {
8190
8991
  }
8191
8992
  }
8192
8993
  /**
8994
+ * Default file-content-hash probe — `sha256` of the file bytes, returning `null` for a
8995
+ * missing/unreadable path. serve()'s change gate compares this against the last
8996
+ * successfully-built bytes to drop a no-op save (a byte-identical double Ctrl-S).
8997
+ *
8998
+ * @param filePath - The absolute path to hash.
8999
+ * @returns The hex SHA-256 of the file's bytes, or `null` when it cannot be read.
9000
+ * @example
9001
+ * const hash = defaultFileHash("/abs/content/a.md");
9002
+ */
9003
+ function defaultFileHash(filePath) {
9004
+ try {
9005
+ return (0, node_crypto.createHash)("sha256").update((0, node_fs.readFileSync)(filePath)).digest("hex");
9006
+ } catch {
9007
+ return null;
9008
+ }
9009
+ }
9010
+ /**
8193
9011
  * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
8194
9012
  * reads `node:os` interfaces while tests can inject a deterministic value.
8195
9013
  *
@@ -8201,6 +9019,101 @@ function defaultFileMtime(filePath) {
8201
9019
  function defaultNetworkUrl(port) {
8202
9020
  return networkUrl(port);
8203
9021
  }
9022
+ /** Memoized banner facts — resolution touches the filesystem + git once, then caches. */
9023
+ let cachedBanner;
9024
+ /**
9025
+ * Run a read-only `git` command in `dir`, returning its trimmed stdout (`undefined` on
9026
+ * any failure — not a checkout, git missing, etc.). A thin wrapper so the version
9027
+ * resolver can issue a couple of git queries without repeating the spawn boilerplate.
9028
+ *
9029
+ * @param dir - The working directory to run git in.
9030
+ * @param args - The git arguments (no user input is ever interpolated).
9031
+ * @returns The trimmed command output, or `undefined` on failure.
9032
+ * @example
9033
+ * git("/Users/me/moku/web", ["tag", "--list", "v*"]);
9034
+ */
9035
+ function git(dir, args) {
9036
+ try {
9037
+ return (0, node_child_process.execFileSync)("git", args, {
9038
+ cwd: dir,
9039
+ encoding: "utf8",
9040
+ stdio: [
9041
+ "ignore",
9042
+ "pipe",
9043
+ "ignore"
9044
+ ]
9045
+ }).trim();
9046
+ } catch {
9047
+ return;
9048
+ }
9049
+ }
9050
+ /**
9051
+ * The framework's source/dev version, derived the SAME way the publish workflow computes
9052
+ * a release: the highest semver `v*` tag is the source of truth (`@moku-labs/web` is
9053
+ * released tag-only — the working-tree `package.json` deliberately carries no `version`).
9054
+ * A `-dev` suffix marks it as a local build off that release line (e.g. `v1.1.0-dev`), so
9055
+ * it never masquerades as the published release. Falls back to the short commit (then
9056
+ * `undefined`) only when no tags exist. `undefined` when `dir` is not a git checkout (a
9057
+ * published npm install — which carries its real `version` instead).
9058
+ *
9059
+ * @param dir - A directory inside the framework's own repository (the realpath of the
9060
+ * package root, so a symlinked local checkout reports the framework's tag — not the
9061
+ * consumer's).
9062
+ * @returns The dev version (e.g. `v1.1.0-dev`), or `undefined`.
9063
+ * @example
9064
+ * devVersion("/Users/me/moku/web"); // "v1.1.0-dev"
9065
+ */
9066
+ function devVersion(dir) {
9067
+ const latestTag = git(dir, [
9068
+ "tag",
9069
+ "--list",
9070
+ "v*",
9071
+ "--sort=-v:refname"
9072
+ ])?.split("\n")[0]?.trim();
9073
+ if (latestTag) return `${latestTag}-dev`;
9074
+ const sha = git(dir, [
9075
+ "rev-parse",
9076
+ "--short",
9077
+ "HEAD"
9078
+ ]);
9079
+ return sha ? `${sha}-dev` : void 0;
9080
+ }
9081
+ /**
9082
+ * Resolve the real version/runtime facts shown in the Panel banner (memoized). Reads the
9083
+ * `package.json` shipped beside the built bundle (`dist/../package.json`): a PUBLISHED
9084
+ * release carries a `version` and reports `v{version}`; a source/dev build (no `version`
9085
+ * field — `@moku-labs/web` is released tag-only) reports the latest semver tag + `-dev`
9086
+ * (e.g. `v1.1.0-dev`, the same tag the publish workflow treats as the version source), or
9087
+ * `"dev"` when git is unavailable. The pinned `@moku-labs/core` version comes from the
9088
+ * same file's `dependencies`.
9089
+ *
9090
+ * @returns The resolved {@link BannerFacts}.
9091
+ * @example
9092
+ * resolveBanner(); // { version: "v1.1.0-dev", coreVersion: "0.1.0-alpha.6" }
9093
+ */
9094
+ function resolveBanner() {
9095
+ if (cachedBanner) return cachedBanner;
9096
+ let pkg = {};
9097
+ let pkgDir;
9098
+ try {
9099
+ const pkgUrl = new URL("../package.json", require("url").pathToFileURL(__filename).href);
9100
+ pkgDir = (0, node_fs.realpathSync)(node_path$1.default.dirname((0, node_url.fileURLToPath)(pkgUrl)));
9101
+ pkg = JSON.parse((0, node_fs.readFileSync)(pkgUrl, "utf8"));
9102
+ } catch {}
9103
+ const coreVersion = (pkg.dependencies?.["@moku-labs/core"] ?? "").replace(/^\D*/, "") || "unknown";
9104
+ const released = pkg.version;
9105
+ let version = "dev";
9106
+ if (released) version = `v${released}`;
9107
+ else {
9108
+ const dev = devVersion(pkgDir ?? process.cwd());
9109
+ if (dev) version = dev;
9110
+ }
9111
+ cachedBanner = {
9112
+ version,
9113
+ coreVersion
9114
+ };
9115
+ return cachedBanner;
9116
+ }
8204
9117
  /**
8205
9118
  * Create the initial cli plugin state with the production seams wired. Every field is
8206
9119
  * an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
@@ -8214,8 +9127,12 @@ function defaultNetworkUrl(port) {
8214
9127
  * const state = createState({ global: {}, config });
8215
9128
  */
8216
9129
  function createState$1(_ctx) {
9130
+ const banner = resolveBanner();
8217
9131
  return {
8218
- render: createPanelRenderer(),
9132
+ render: createPanelRenderer({
9133
+ version: banner.version,
9134
+ coreVersion: banner.coreVersion
9135
+ }),
8219
9136
  confirm: defaultConfirm,
8220
9137
  select: defaultSelect,
8221
9138
  clock: Date.now,
@@ -8223,7 +9140,8 @@ function createState$1(_ctx) {
8223
9140
  serveStatic: defaultServeStatic,
8224
9141
  fileResponse: defaultFileResponse,
8225
9142
  networkUrl: defaultNetworkUrl,
8226
- fileMtime: defaultFileMtime
9143
+ fileMtime: defaultFileMtime,
9144
+ fileHash: defaultFileHash
8227
9145
  };
8228
9146
  }
8229
9147
  //#endregion