@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.mjs CHANGED
@@ -1,14 +1,16 @@
1
1
  import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
2
2
  import { n as relativeDataFile, t as dataSuffix } from "./convention-CepUwWmT.mjs";
3
3
  import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
4
- import { existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
4
+ import { appendFileSync, existsSync, readFileSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { Feed } from "feed";
9
9
  import { h } from "preact";
10
10
  import { renderToString } from "preact-render-to-string";
11
+ import { execFileSync } from "node:child_process";
11
12
  import { createInterface } from "node:readline";
13
+ import { fileURLToPath } from "node:url";
12
14
  import { networkInterfaces } from "node:os";
13
15
  import matter from "gray-matter";
14
16
  import rehypeShiki from "@shikijs/rehype";
@@ -1280,18 +1282,24 @@ function isPublished(article, isProduction) {
1280
1282
  * locale collection: existing files only, drafts dropped in production, sorted
1281
1283
  * date-descending. The single load+filter+sort step behind {@link createContentApi.loadAll}.
1282
1284
  *
1285
+ * When a `cached` map is supplied (incremental dev rebuild), a slug already present in it
1286
+ * is reused as-is (skipping the re-read + Markdown/Shiki re-render); only slugs absent
1287
+ * from it — the ones a preceding `invalidate()` dropped, plus any never-loaded — are
1288
+ * resolved fresh. With no `cached` map every slug is resolved (the full load).
1289
+ *
1283
1290
  * @param ctx - Kernel-free domain context (provider + i18n helpers + stage).
1284
1291
  * @param slugs - Every known article slug from the provider.
1285
1292
  * @param locale - The locale to resolve and collect.
1293
+ * @param cached - Optional per-locale article cache to reuse for unchanged slugs.
1286
1294
  * @returns The published (date-descending) articles for this locale.
1287
1295
  * @example
1288
1296
  * ```ts
1289
1297
  * const present = await loadAndFilterArticles(ctx, slugs, "en");
1290
1298
  * ```
1291
1299
  */
1292
- async function loadAndFilterArticles(ctx, slugs, locale) {
1300
+ async function loadAndFilterArticles(ctx, slugs, locale, cached) {
1293
1301
  const isProduction = ctx.global.stage === "production";
1294
- return (await Promise.all(slugs.map((slug) => resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
1302
+ 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);
1295
1303
  }
1296
1304
  /**
1297
1305
  * Derive the article slug from a source file path — the parent directory name
@@ -1328,19 +1336,26 @@ function createContentApi(ctx) {
1328
1336
  * Load every article across every active locale (locale fallback, production
1329
1337
  * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
1330
1338
  *
1339
+ * With `{ reuse: true }` (dev incremental rebuild) cached articles are reused for
1340
+ * every slug a preceding `invalidate()` did not drop, so only the dirty articles
1341
+ * re-read + re-run the Markdown/Shiki pipeline; the `contentId` ordinals are still
1342
+ * recomputed across the FULL sorted set, so ids + order match a full load.
1343
+ *
1344
+ * @param options - Optional load behaviour (`reuse`); omit for a full load.
1331
1345
  * @returns A locale-keyed map of date-descending articles.
1332
1346
  * @example
1333
1347
  * ```ts
1334
1348
  * const byLocale = await api.loadAll();
1335
1349
  * ```
1336
1350
  */
1337
- async loadAll() {
1351
+ async loadAll(options) {
1352
+ const reuse = options?.reuse === true;
1338
1353
  const slugs = await ctx.provider.slugs();
1339
1354
  const locales = ctx.locales();
1340
1355
  const result = /* @__PURE__ */ new Map();
1341
1356
  let total = 0;
1342
1357
  for (const locale of locales) {
1343
- const present = await loadAndFilterArticles(ctx, slugs, locale);
1358
+ const present = await loadAndFilterArticles(ctx, slugs, locale, reuse ? ctx.state.articles.get(locale) : void 0);
1344
1359
  const cache = /* @__PURE__ */ new Map();
1345
1360
  let index = 0;
1346
1361
  for (const article of present) {
@@ -3341,7 +3356,11 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3341
3356
  /**
3342
3357
  * Bundles CSS and JS into the output directory via two separate runner passes
3343
3358
  * (dodging Bun's mixed-entrypoint segfault), honoring `config.minify`, and caches
3344
- * the resulting hashed asset paths in `state.buildCache` for downstream phases.
3359
+ * the resulting hashed asset paths in `state.buildCache` for downstream phases. The
3360
+ * two passes run CONCURRENTLY (`Promise.all`) — they target disjoint hashed outputs
3361
+ * and distinct `buildCache` keys (`css`/`js`), so overlapping them ~halves bundle
3362
+ * wall-time with no shared-state hazard. The CSS pass is still dispatched first, so
3363
+ * the runner's invocation order stays css-then-js.
3345
3364
  *
3346
3365
  * @param ctx - Plugin context (provides `state`, `config`, `log`).
3347
3366
  * @param options - Optional dependency-injection seam (runner + entrypoints).
@@ -3354,10 +3373,10 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3354
3373
  async function bundle(ctx, options = {}) {
3355
3374
  const runner = options.runner ?? defaultRunner;
3356
3375
  const { minify, outDir } = ctx.config;
3376
+ const assetsDir = path.join(outDir, "assets");
3357
3377
  const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
3358
3378
  const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
3359
- await runOne(ctx, runner, "css", cssEntrypoints, outDir, path.join(outDir, "assets"), minify);
3360
- await runOne(ctx, runner, "js", jsEntrypoints, outDir, path.join(outDir, "assets"), minify);
3379
+ await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
3361
3380
  }
3362
3381
  //#endregion
3363
3382
  //#region src/plugins/build/phases/content.ts
@@ -3374,15 +3393,24 @@ const CONTENT_CACHE_KEY = "content";
3374
3393
  * pages/feeds/og-images phases. Performs NO Markdown parsing itself — the
3375
3394
  * content plugin owns rendering (god-plugin invariant).
3376
3395
  *
3396
+ * On a dev incremental rebuild (`options.changed` set) it first `invalidate()`s the
3397
+ * changed Markdown so `loadAll({ reuse: true })` re-reads + re-renders ONLY those
3398
+ * articles, reusing the cached HTML for the rest. With no options it does a full load.
3399
+ *
3377
3400
  * @param ctx - Plugin context (provides `require`, `state`, `log`).
3401
+ * @param options - Optional incremental hints; omit for a full load.
3402
+ * @param options.reuse - Reuse cached content for slugs not invalidated (dev incremental rebuild).
3403
+ * @param options.changed - The changed Markdown paths to invalidate before loading.
3378
3404
  * @returns The locale-keyed article map returned by the content plugin.
3379
3405
  * @example
3380
3406
  * ```ts
3381
3407
  * const byLocale = await loadContent(ctx);
3382
3408
  * ```
3383
3409
  */
3384
- async function loadContent(ctx) {
3385
- const byLocale = await ctx.require(contentPlugin).loadAll();
3410
+ async function loadContent(ctx, options) {
3411
+ const content = ctx.require(contentPlugin);
3412
+ if (options?.changed && options.changed.length > 0) content.invalidate(options.changed);
3413
+ const byLocale = await content.loadAll({ reuse: options?.reuse ?? false });
3386
3414
  ctx.state.buildCache.set(CONTENT_CACHE_KEY, byLocale);
3387
3415
  ctx.log.debug("build:content", { locales: byLocale.size });
3388
3416
  return byLocale;
@@ -4736,6 +4764,71 @@ function renderBody(definition, routeContext) {
4736
4764
  return renderToString(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
4737
4765
  }
4738
4766
  /**
4767
+ * Hash a page's render inputs (its loaded data) for the render cache. `null` when the
4768
+ * data is not JSON-serializable — such a page is never cached and always re-renders.
4769
+ *
4770
+ * @param data - The route's loaded data (the only per-page input besides params/locale/code).
4771
+ * @returns The hex SHA-256 of the serialized data, or `null` when it cannot be serialized.
4772
+ * @example
4773
+ * ```ts
4774
+ * hashData({ title: "Hi" }); // "9f8e…"
4775
+ * ```
4776
+ */
4777
+ function hashData(data) {
4778
+ try {
4779
+ const serialized = JSON.stringify(data) ?? "";
4780
+ return createHash("sha256").update(serialized).digest("hex");
4781
+ } catch {
4782
+ return null;
4783
+ }
4784
+ }
4785
+ /**
4786
+ * The render-cache key for one page instance: name + params + locale (the stable identity
4787
+ * that, together with the data hash, determines its body). NUL-joined so no value collides.
4788
+ *
4789
+ * @param instance - The page instance.
4790
+ * @returns The cache key string.
4791
+ * @example
4792
+ * ```ts
4793
+ * renderCacheKey(instance); // "article{\"slug\":\"x\"}en"
4794
+ * ```
4795
+ */
4796
+ function renderCacheKey(instance) {
4797
+ return `${instance.name}${JSON.stringify(instance.params)}${instance.locale}`;
4798
+ }
4799
+ /**
4800
+ * Render one page's body, reusing the cached body when this page's data is unchanged.
4801
+ * The body is the synchronous, dominant-cost step ({@link renderBody}); an incremental
4802
+ * dev rebuild (`reuse`, code unchanged) skips it for every page whose data hash matches
4803
+ * the cache, and a changed page (or a non-`reuse` run) renders + refreshes the cache.
4804
+ *
4805
+ * @param ctx - Plugin context (provides the cross-run `state.renderCache`).
4806
+ * @param instance - The page instance being rendered.
4807
+ * @param routeContext - The route context passed to `.render()`/`.layout()`.
4808
+ * @param data - The route's loaded data (hashed to detect a change).
4809
+ * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
4810
+ * @returns The SSR-rendered body HTML.
4811
+ * @example
4812
+ * ```ts
4813
+ * const body = renderBodyCached(ctx, instance, routeContext, data, true);
4814
+ * ```
4815
+ */
4816
+ function renderBodyCached(ctx, instance, routeContext, data, reuse) {
4817
+ const cache = ctx.state.renderCache;
4818
+ const key = renderCacheKey(instance);
4819
+ const hash = hashData(data);
4820
+ if (reuse && hash !== null) {
4821
+ const hit = cache.get(key);
4822
+ if (hit?.dataHash === hash) return hit.body;
4823
+ }
4824
+ const body = renderBody(instance.definition, routeContext);
4825
+ if (hash !== null) cache.set(key, {
4826
+ dataHash: hash,
4827
+ body
4828
+ });
4829
+ return body;
4830
+ }
4831
+ /**
4739
4832
  * Write a rendered page document to its on-disk path. The path comes from the
4740
4833
  * compiled `TypedRoute.toFile(params)` (honoring any route-level `.toFile()`
4741
4834
  * override), resolved under the build `outDir`; parent directories are created first.
@@ -4764,13 +4857,14 @@ async function writeDocument(outDir, entry, params, html) {
4764
4857
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
4765
4858
  * @param instance - The concrete page instance to render.
4766
4859
  * @param shell - Per-build wiring shared across instances (asset tags + template).
4860
+ * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
4767
4861
  * @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
4768
4862
  * @example
4769
4863
  * ```ts
4770
- * await renderInstance(ctx, instance, { assets: "", template: null });
4864
+ * await renderInstance(ctx, instance, { assets: "", template: null }, false);
4771
4865
  * ```
4772
4866
  */
4773
- async function renderInstance(ctx, instance, shell) {
4867
+ async function renderInstance(ctx, instance, shell, reuse) {
4774
4868
  const { definition, entry, params, locale } = instance;
4775
4869
  const router = ctx.require(routerPlugin);
4776
4870
  const data = await loadRouteData(definition, params, locale, ctx);
@@ -4783,7 +4877,7 @@ async function renderInstance(ctx, instance, shell) {
4783
4877
  };
4784
4878
  const parts = {
4785
4879
  head: composeHeadHtml(ctx, instance, url, routeContext, data),
4786
- body: renderBody(definition, routeContext),
4880
+ body: renderBodyCached(ctx, instance, routeContext, data, reuse),
4787
4881
  assets: shell.assets,
4788
4882
  locale
4789
4883
  };
@@ -4877,27 +4971,77 @@ function findRootHtml(rendered) {
4877
4971
  return rendered.find((page) => page.url === "/" || page.url === "")?.html ?? null;
4878
4972
  }
4879
4973
  /**
4974
+ * Pages rendered concurrently per batch. Kept small so the macrotask yield between
4975
+ * batches fires frequently — a large batch renders for seconds before yielding, which
4976
+ * leaves a watching dev server's spinner repainting only every few seconds (sluggish).
4977
+ * Smaller batches trade a little write-concurrency for a smooth, responsive spinner.
4978
+ */
4979
+ const RENDER_BATCH_SIZE = 2;
4980
+ /**
4981
+ * Batch size for an incremental (`reuse`) rebuild. Most instances are cheap cache hits, so
4982
+ * a larger batch cuts the per-batch `setImmediate` round-trips (which would otherwise add
4983
+ * pure latency to an otherwise-fast rebuild) without starving the dev spinner.
4984
+ */
4985
+ const INCREMENTAL_BATCH_SIZE = 32;
4986
+ /**
4987
+ * Render `items` through `worker` in bounded-size batches, yielding a macrotask
4988
+ * (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
4989
+ * sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
4990
+ * hundreds of synchronous `renderToString` calls starves the event loop, which freezes a
4991
+ * watching dev server's progress spinner until the whole phase resolves. Output order is
4992
+ * preserved (batch order + `Promise.all` order within a batch).
4993
+ *
4994
+ * @template Item - The input item type.
4995
+ * @template Out - The rendered output type.
4996
+ * @param items - The items to render.
4997
+ * @param batchSize - Maximum items rendered concurrently per batch.
4998
+ * @param worker - Renders one item to its output.
4999
+ * @returns All rendered outputs in input order.
5000
+ * @example
5001
+ * ```ts
5002
+ * const pages = await renderInBatches(instances, 32, i => renderInstance(ctx, i, shell));
5003
+ * ```
5004
+ */
5005
+ async function renderInBatches(items, batchSize, worker) {
5006
+ const out = [];
5007
+ for (let start = 0; start < items.length; start += batchSize) {
5008
+ const batch = items.slice(start, start + batchSize);
5009
+ out.push(...await Promise.all(batch.map((item) => worker(item))));
5010
+ if (start + batchSize < items.length) await new Promise((resolve) => {
5011
+ setImmediate(resolve);
5012
+ });
5013
+ }
5014
+ return out;
5015
+ }
5016
+ /**
4880
5017
  * Renders every route in the manifest to `outDir/<path>/index.html`. Reads as a
4881
- * pipeline: resolve deps → prepare the shared shell → expand instances → render all
4882
- * concurrently (`Promise.all`, legal intra-plugin concurrency) → write data sidecars
4883
- * (hybrid/spa) → capture the root page's HTML for the root-index phase.
5018
+ * pipeline: resolve deps → prepare the shared shell → expand instances → render in
5019
+ * bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
5020
+ * capture the root page's HTML for the root-index phase.
5021
+ *
5022
+ * On an incremental rebuild (`options.reuse`) the cross-run render cache is kept and each
5023
+ * unchanged-data page reuses its cached body; a full render clears the cache first so a
5024
+ * removed/renamed route's stale body never lingers.
4884
5025
  *
4885
5026
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`, `has`).
5027
+ * @param options - Optional incremental hint; omit for a full render.
5028
+ * @param options.reuse - Reuse cached page bodies for unchanged-data pages (dev incremental rebuild).
4886
5029
  * @returns The number of pages rendered and the captured default-page HTML.
4887
5030
  * @example
4888
5031
  * ```ts
4889
5032
  * const { pageCount, rootHtml } = await renderPages(ctx);
4890
5033
  * ```
4891
5034
  */
4892
- async function renderPages(ctx) {
5035
+ async function renderPages(ctx, options) {
5036
+ const reuse = options?.reuse === true;
4893
5037
  const router = ctx.require(routerPlugin);
4894
5038
  const manifest = router.manifest();
4895
5039
  ctx.state.manifest = [...manifest];
4896
5040
  const locales = ctx.require(i18nPlugin).locales();
4897
5041
  const byPattern = makeEntryMap(router);
5042
+ if (!reuse) ctx.state.renderCache.clear();
4898
5043
  const shell = await prepareShell(ctx);
4899
- const instances = await expandAllInstances(manifest, locales, byPattern, ctx);
4900
- const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
5044
+ const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
4901
5045
  await writeDataSidecars(ctx, rendered, router.mode());
4902
5046
  ctx.log.debug("build:pages", { count: rendered.length });
4903
5047
  return {
@@ -5105,6 +5249,40 @@ async function generateSitemap(ctx) {
5105
5249
  * @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
5106
5250
  * emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
5107
5251
  */
5252
+ /** Matches a Markdown source path (a content edit). */
5253
+ const MARKDOWN_PATH = /\.md$/;
5254
+ /** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
5255
+ const STYLE_PATH = /\.css$/;
5256
+ /** Matches a code path (TS/JS/JSON — may change ANY page's render output). */
5257
+ const CODE_PATH = /\.(?:tsx?|jsx?|mjs|cjs|json)$/;
5258
+ /**
5259
+ * Derive the {@link ChangePlan} for a run from its changed-path set (see the type docs
5260
+ * for the rules).
5261
+ *
5262
+ * @param changed - Absolute/relative changed paths, or `undefined` for a full build.
5263
+ * @returns The reuse plan for this run.
5264
+ * @example
5265
+ * ```ts
5266
+ * const plan = planIncrementalRebuild(options?.changed);
5267
+ * ```
5268
+ */
5269
+ function planIncrementalRebuild(changed) {
5270
+ if (changed === void 0 || changed.length === 0) return {
5271
+ contentChanged: [],
5272
+ contentReuse: false,
5273
+ renderReuse: false
5274
+ };
5275
+ if (!changed.every((file) => MARKDOWN_PATH.test(file) || STYLE_PATH.test(file) || CODE_PATH.test(file))) return {
5276
+ contentChanged: [],
5277
+ contentReuse: false,
5278
+ renderReuse: false
5279
+ };
5280
+ return {
5281
+ contentChanged: changed.filter((file) => MARKDOWN_PATH.test(file)),
5282
+ contentReuse: true,
5283
+ renderReuse: !changed.some((file) => CODE_PATH.test(file))
5284
+ };
5285
+ }
5108
5286
  /**
5109
5287
  * The static ordered list of pipeline phase names.
5110
5288
  *
@@ -5218,8 +5396,7 @@ async function runOutputs(ctx) {
5218
5396
  * `build:phase` boundary per phase and `build:complete` once at the end.
5219
5397
  *
5220
5398
  * @param ctx - Plugin context (provides `require`, `emit`, `state`, `config`, `log`).
5221
- * @param options - Optional run overrides.
5222
- * @param options.outDir - Override the configured output directory for this run.
5399
+ * @param options - Optional per-run overrides ({@link RunOptions}).
5223
5400
  * @returns The build result (outDir, pageCount, durationMs).
5224
5401
  * @example
5225
5402
  * ```ts
@@ -5234,17 +5411,22 @@ async function runPipeline(ctx, options) {
5234
5411
  ...ctx,
5235
5412
  config: {
5236
5413
  ...ctx.config,
5237
- outDir
5414
+ outDir,
5415
+ ...options?.overrides
5238
5416
  }
5239
5417
  };
5240
- await rm(outDir, {
5418
+ const plan = planIncrementalRebuild(options?.changed);
5419
+ if (!options?.skipClean) await rm(outDir, {
5241
5420
  recursive: true,
5242
5421
  force: true
5243
5422
  });
5244
5423
  await mkdir(outDir, { recursive: true });
5245
5424
  await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
5246
- await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext)), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
5247
- const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext));
5425
+ await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
5426
+ reuse: plan.contentReuse,
5427
+ changed: plan.contentChanged
5428
+ })), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
5429
+ const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
5248
5430
  await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
5249
5431
  await runOutputs(phaseContext);
5250
5432
  await withPhase(phaseContext, "root-index", async () => {
@@ -5297,10 +5479,12 @@ const defaultConfig$2 = {
5297
5479
  function createApi$3(ctx) {
5298
5480
  return {
5299
5481
  /**
5300
- * Run the full SSG pipeline and write the site to disk.
5482
+ * Run the full SSG pipeline and write the site to disk. With no options a full
5483
+ * production build runs; dev callers pass `skipClean`/`overrides`/`changed` for a
5484
+ * fast incremental rebuild (all gated behind opt-in fields — the default path is
5485
+ * unchanged).
5301
5486
  *
5302
- * @param options - Optional run overrides.
5303
- * @param options.outDir - Override the configured output directory for this run.
5487
+ * @param options - Optional per-run overrides (outDir / skipClean / overrides / changed).
5304
5488
  * @returns The build result (outDir, pageCount, durationMs).
5305
5489
  * @example
5306
5490
  * ```ts
@@ -5393,8 +5577,8 @@ function createEvents(register) {
5393
5577
  /**
5394
5578
  * Creates initial `build` plugin state: a frozen config snapshot plus empty
5395
5579
  * per-run caches (`manifest`, `buildCache`, `runId`) and the cross-run OG
5396
- * content-hash cache. Holds caches and config only — no domain data is
5397
- * duplicated here (pulled fresh via `ctx.require` each run).
5580
+ * content-hash + page-render caches. Holds caches and config only — no domain data
5581
+ * is duplicated here (pulled fresh via `ctx.require` each run).
5398
5582
  *
5399
5583
  * @param ctx - Minimal context with global and config.
5400
5584
  * @param ctx.global - Global plugin registry (unused; caches are config-driven).
@@ -5411,7 +5595,8 @@ function createState$3(ctx) {
5411
5595
  manifest: null,
5412
5596
  buildCache: /* @__PURE__ */ new Map(),
5413
5597
  runId: null,
5414
- ogImageHashCache: /* @__PURE__ */ new Map()
5598
+ ogImageHashCache: /* @__PURE__ */ new Map(),
5599
+ renderCache: /* @__PURE__ */ new Map()
5415
5600
  };
5416
5601
  }
5417
5602
  //#endregion
@@ -5635,12 +5820,37 @@ function buildWranglerArgs(input) {
5635
5820
  branch
5636
5821
  ];
5637
5822
  }
5823
+ /**
5824
+ * Assemble the argv for `wrangler pages project create` (no shell). Guards the
5825
+ * production branch against flag injection; the slug is already a safe `toSlug` output.
5826
+ *
5827
+ * @param input - The resolved project-create inputs.
5828
+ * @param input.slug - Cloudflare project-name slug (`toSlug(site.name())`).
5829
+ * @param input.branch - Production branch (guarded by `/^[a-zA-Z0-9/_.-]+$/`).
5830
+ * @returns The wrangler argv array.
5831
+ * @throws {Error} `ERR_DEPLOY_INVALID_BRANCH` when the branch fails the guard.
5832
+ * @example
5833
+ * buildProjectCreateArgs({ slug: "my-site", branch: "main" });
5834
+ */
5835
+ function buildProjectCreateArgs(input) {
5836
+ const branch = guardBranch(input.branch);
5837
+ return [
5838
+ "bunx",
5839
+ "wrangler",
5840
+ "pages",
5841
+ "project",
5842
+ "create",
5843
+ input.slug,
5844
+ "--production-branch",
5845
+ branch
5846
+ ];
5847
+ }
5638
5848
  /** Lowercased substring matchers for the wrangler error taxonomy. */
5639
5849
  const ERROR_SIGNATURES = [
5640
5850
  {
5641
5851
  match: ["could not find project", "project not found"],
5642
5852
  kind: "ERR_DEPLOY_PROJECT_NOT_FOUND",
5643
- advice: "The Cloudflare Pages project does not exist. Run `app.deploy.init()` or create it in the dashboard, then retry."
5853
+ 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.)"
5644
5854
  },
5645
5855
  {
5646
5856
  match: [
@@ -6219,15 +6429,16 @@ function validateConfig$1(ctx) {
6219
6429
  * Run wrangler for the prepared argv and surface its scrubbed result, translating
6220
6430
  * a non-zero exit into the classified deploy error. The API token is read from env
6221
6431
  * here so it never crosses a logging boundary; only scrubbed output is returned.
6432
+ * Shared by `run()` (deploy) and `createProject()` (project create).
6222
6433
  *
6223
6434
  * @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
6224
6435
  * @param args - The fully-built, pre-validated wrangler argv.
6225
6436
  * @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
6226
6437
  * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6227
6438
  * @example
6228
- * const { stdout, scrubbedStderr } = await executeDeploy(ctx, args);
6439
+ * const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6229
6440
  */
6230
- async function executeDeploy(ctx, args) {
6441
+ async function executeWrangler(ctx, args) {
6231
6442
  const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
6232
6443
  const { stdout, scrubbedStderr, exitCode } = await runWrangler({
6233
6444
  spawn: ctx.state.spawn,
@@ -6299,7 +6510,7 @@ function createApi$2(ctx) {
6299
6510
  root
6300
6511
  });
6301
6512
  const start = Date.now();
6302
- const { stdout, scrubbedStderr } = await executeDeploy(ctx, args);
6513
+ const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6303
6514
  ctx.log.info(scrubbedStderr);
6304
6515
  const result = buildDeployResult(stdout, branch, start);
6305
6516
  ctx.state.lastDeployment = result;
@@ -6338,6 +6549,38 @@ function createApi$2(ctx) {
6338
6549
  cwd: process.cwd(),
6339
6550
  options
6340
6551
  });
6552
+ },
6553
+ /**
6554
+ * The Cloudflare Pages project name this app deploys to (`toSlug(site.name())`).
6555
+ *
6556
+ * @returns The project-name slug.
6557
+ * @example
6558
+ * api.projectName(); // "my-site"
6559
+ */
6560
+ projectName() {
6561
+ return toSlug(ctx.require(sitePlugin).name());
6562
+ },
6563
+ /**
6564
+ * Create the remote Cloudflare Pages project via wrangler, so a first deploy has a
6565
+ * target. Derives the slug from `site.name()` and the production branch from config.
6566
+ *
6567
+ * @returns The created project name + production branch.
6568
+ * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6569
+ * @example
6570
+ * await api.createProject(); // { name: "my-site", branch: "main" }
6571
+ */
6572
+ async createProject() {
6573
+ const name = toSlug(ctx.require(sitePlugin).name());
6574
+ const branch = ctx.config.productionBranch ?? "main";
6575
+ const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
6576
+ slug: name,
6577
+ branch
6578
+ }));
6579
+ ctx.log.info(scrubbedStderr);
6580
+ return {
6581
+ name,
6582
+ branch
6583
+ };
6341
6584
  }
6342
6585
  };
6343
6586
  }
@@ -6449,13 +6692,14 @@ const deployPlugin = createPlugin$1("deploy", {
6449
6692
  //#endregion
6450
6693
  //#region src/plugins/cli/deploy-wizard.ts
6451
6694
  /**
6452
- * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`). Walks a
6453
- * human through a Cloudflare Pages deploy: checks prerequisites (wrangler config + the
6454
- * Cloudflare credentials) with concrete fix guidance, offers to scaffold/build what is
6455
- * missing, HARD-GATES the deploy on everything being green, runs a local build smoke
6456
- * test, confirms, deploys, then offers to scaffold a GitHub Actions workflow (auto on
6457
- * push to main, or a versioned/manual trigger). The non-guided `--cli` path stays in
6458
- * `api.ts`. Every prompt + line of output flows through injectable `state` seams.
6695
+ * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`, the default
6696
+ * for `bun run deploy`; the direct `--cli` path stays in `api.ts`). Walks a human through a
6697
+ * Cloudflare Pages deploy: checks prerequisites (wrangler config + the Cloudflare
6698
+ * credentials) with concrete fix guidance, offers to scaffold what is missing (a
6699
+ * `wrangler.jsonc`, and a placeholder `.env` for any missing credentials), HARD-GATES the
6700
+ * deploy on everything being green, runs a local build smoke test, confirms, deploys, then
6701
+ * offers to scaffold a GitHub Actions workflow (auto on push to main, or a versioned/manual
6702
+ * trigger). Every prompt + line of output flows through injectable `state` seams.
6459
6703
  */
6460
6704
  /** How to create a Cloudflare API token + where to make it available locally. */
6461
6705
  const TOKEN_HELP = [
@@ -6470,21 +6714,56 @@ const ACCOUNT_HELP = [
6470
6714
  "right-hand sidebar (also in the dashboard URL). Then make it available:",
6471
6715
  " export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
6472
6716
  ].join("\n");
6717
+ /** Shown when a credential is in the raw environment but the app's env providers did not resolve it. */
6718
+ const PROVIDERS_HELP = [
6719
+ "Found in your shell/.env but the app's env plugin did not resolve it — its providers",
6720
+ "are not wired. Add the Node providers in createApp so the deploy can read it:",
6721
+ " pluginConfigs.env = { providers: [processEnv(), dotenv()] } (import them from @moku-labs/web)."
6722
+ ].join("\n");
6473
6723
  /** The GitHub repo secrets the generated workflow consumes. */
6474
6724
  const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
6475
6725
  /**
6726
+ * Build one credential prerequisite by reading the SAME source the deploy reads — the
6727
+ * resolved `ctx.env` table — so a ✓ guarantees `ctx.env.require(key)` will succeed. When
6728
+ * the value is present in the raw `process.env` but unresolved by the app's providers
6729
+ * (the silent "deploy can't see it" trap a bare `process.env` check would mark green),
6730
+ * the fix hint points at wiring the providers instead of re-adding the value.
6731
+ *
6732
+ * @param ctx - The cli plugin context (provides the resolved `ctx.env`).
6733
+ * @param key - The credential variable name.
6734
+ * @param label - The diagnostic line label.
6735
+ * @param missingHelp - The fix hint when the credential is genuinely absent everywhere.
6736
+ * @returns The credential prerequisite check.
6737
+ * @example
6738
+ * credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP);
6739
+ */
6740
+ function credentialPrereq(ctx, key, label, missingHelp) {
6741
+ if ((ctx.env.get(key) ?? "") !== "") return {
6742
+ ok: true,
6743
+ label,
6744
+ detail: void 0,
6745
+ scaffoldable: false
6746
+ };
6747
+ return {
6748
+ ok: false,
6749
+ label,
6750
+ detail: (process.env[key] ?? "") !== "" ? PROVIDERS_HELP : missingHelp,
6751
+ scaffoldable: false
6752
+ };
6753
+ }
6754
+ /**
6476
6755
  * Evaluate the three deploy prerequisites against the current project: the Cloudflare
6477
- * wrangler config exists, and both Cloudflare credentials are present in the environment.
6756
+ * wrangler config exists, and both Cloudflare credentials resolve through `ctx.env` (the
6757
+ * deploy's own source of truth — not a bare `process.env` read that can diverge from it).
6478
6758
  *
6759
+ * @param ctx - The cli plugin context (provides the resolved `ctx.env`).
6479
6760
  * @param cwd - The project root (where `wrangler.jsonc` lives).
6480
6761
  * @returns The ordered prerequisite checks.
6481
6762
  * @example
6482
- * const prereqs = diagnose(process.cwd());
6763
+ * const prereqs = diagnose(ctx, process.cwd());
6483
6764
  */
6484
- function diagnose(cwd) {
6765
+ function diagnose(ctx, cwd) {
6485
6766
  const wranglerOk = existsSync(path.join(cwd, "wrangler.jsonc"));
6486
- const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
6487
- const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
6488
6767
  return [
6489
6768
  {
6490
6769
  ok: wranglerOk,
@@ -6492,18 +6771,8 @@ function diagnose(cwd) {
6492
6771
  detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
6493
6772
  scaffoldable: true
6494
6773
  },
6495
- {
6496
- ok: tokenOk,
6497
- label: "CLOUDFLARE_API_TOKEN is set",
6498
- detail: tokenOk ? void 0 : TOKEN_HELP,
6499
- scaffoldable: false
6500
- },
6501
- {
6502
- ok: accountOk,
6503
- label: "CLOUDFLARE_ACCOUNT_ID is set",
6504
- detail: accountOk ? void 0 : ACCOUNT_HELP,
6505
- scaffoldable: false
6506
- }
6774
+ credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP),
6775
+ credentialPrereq(ctx, "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_ACCOUNT_ID is set", ACCOUNT_HELP)
6507
6776
  ];
6508
6777
  }
6509
6778
  /**
@@ -6522,6 +6791,43 @@ async function offerScaffold(ctx, prereqs) {
6522
6791
  await ctx.require(deployPlugin).init({});
6523
6792
  ctx.state.render.check(true, "wrangler.jsonc scaffolded");
6524
6793
  }
6794
+ /** The Cloudflare credentials the deploy needs, with the comment written above each in a scaffolded `.env`. */
6795
+ const ENV_CREDENTIALS = [{
6796
+ key: "CLOUDFLARE_API_TOKEN",
6797
+ comment: "# Cloudflare API token — https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
6798
+ }, {
6799
+ key: "CLOUDFLARE_ACCOUNT_ID",
6800
+ comment: "# Cloudflare account id — dashboard → Workers & Pages → right-hand sidebar"
6801
+ }];
6802
+ /**
6803
+ * Offer to scaffold a `.env` with placeholders for whichever Cloudflare credentials are
6804
+ * missing — created when absent, appended to (never clobbering a key already present)
6805
+ * when it exists. The placeholders are empty, so the deploy still hard-gates until the
6806
+ * user fills them in; this just removes the "where do I even put these?" friction.
6807
+ *
6808
+ * @param ctx - The cli plugin context.
6809
+ * @param cwd - The project root (where `.env` lives).
6810
+ * @returns Resolves once any accepted scaffold has been written.
6811
+ * @example
6812
+ * await offerEnvScaffold(ctx, process.cwd());
6813
+ */
6814
+ async function offerEnvScaffold(ctx, cwd) {
6815
+ const missing = ENV_CREDENTIALS.filter(({ key }) => (process.env[key] ?? "") === "");
6816
+ if (missing.length === 0) return;
6817
+ const envPath = path.join(cwd, ".env");
6818
+ const exists = existsSync(envPath);
6819
+ const verb = exists ? "Add placeholders for the missing secret(s) to" : "Create";
6820
+ if (!await ctx.state.confirm(`${verb} .env?`)) return;
6821
+ const lines = exists ? readFileSync(envPath, "utf8").split(/\r?\n/) : [];
6822
+ const toAdd = missing.filter(({ key }) => !lines.some((line) => line.trimStart().startsWith(`${key}=`)));
6823
+ if (toAdd.length === 0) {
6824
+ ctx.state.render.info(".env already lists those keys — fill in their values, then re-run.");
6825
+ return;
6826
+ }
6827
+ 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`);
6828
+ const names = toAdd.map(({ key }) => key).join(", ");
6829
+ ctx.state.render.check(true, `${exists ? "added placeholders to" : "created"} .env`, `fill in ${names}, then re-run \`bun run deploy\`.`);
6830
+ }
6525
6831
  /**
6526
6832
  * Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
6527
6833
  * concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
@@ -6564,8 +6870,139 @@ async function offerWorkflowSetup(ctx) {
6564
6870
  ctx.state.render.info(SECRETS_HELP);
6565
6871
  }
6566
6872
  /**
6567
- * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
6568
- * report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
6873
+ * Read the taxonomy `code` off a thrown value, when present. Deploy errors carry a
6874
+ * `code` (e.g. `ERR_DEPLOY_PROJECT_NOT_FOUND`) so the wizard can tailor the fix hint.
6875
+ *
6876
+ * @param error - The thrown value.
6877
+ * @returns The `code` string, or `undefined` when absent.
6878
+ * @example
6879
+ * codeOf(deployError("ERR_DEPLOY_AUTH", "…")); // "ERR_DEPLOY_AUTH"
6880
+ */
6881
+ function codeOf(error) {
6882
+ if (typeof error === "object" && error !== null && "code" in error) {
6883
+ const { code } = error;
6884
+ return typeof code === "string" ? code : void 0;
6885
+ }
6886
+ }
6887
+ /**
6888
+ * A copy-pasteable "create the project yourself" hint, shown when the user declines the
6889
+ * offer to auto-create. Spells out that the remote project is what's missing (init only
6890
+ * scaffolds local config).
6891
+ *
6892
+ * @param name - The Cloudflare Pages project name (the deploy slug).
6893
+ * @returns The multi-line hint (newline-separated; rendered indented under a `›`).
6894
+ * @example
6895
+ * ctx.state.render.info(projectNotFoundHint("my-site"));
6896
+ */
6897
+ function projectNotFoundHint(name) {
6898
+ return [
6899
+ "how to fix: the Cloudflare Pages project does not exist yet — create it once, then",
6900
+ "re-run `bun run deploy`. (app.deploy.init() only scaffolds local config; it does not",
6901
+ "create the remote project.)",
6902
+ ` • CLI: bunx wrangler pages project create ${name} --production-branch main`,
6903
+ " • Dashboard: Cloudflare → Workers & Pages → Create → Pages"
6904
+ ].join("\n");
6905
+ }
6906
+ /**
6907
+ * An actionable, error-specific "how to fix" hint for a failed deploy (other than the
6908
+ * project-not-found case, which the wizard handles interactively), so the user never
6909
+ * lands on a raw stack trace.
6910
+ *
6911
+ * @param error - The thrown deploy error.
6912
+ * @returns The fix hint line.
6913
+ * @example
6914
+ * ctx.state.render.info(deployFailureHint(err));
6915
+ */
6916
+ function deployFailureHint$1(error) {
6917
+ const code = codeOf(error);
6918
+ 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`.";
6919
+ if (code === "ERR_DEPLOY_NETWORK") return "how to fix: a network error reached Cloudflare — check connectivity, then re-run `bun run deploy`.";
6920
+ return "how to fix: resolve the error above, then re-run `bun run deploy`.";
6921
+ }
6922
+ /**
6923
+ * Render a styled deploy failure (✗ + fix hint) and return the `"failed"` outcome, so a
6924
+ * caught error surfaces consistently instead of as a raw throw.
6925
+ *
6926
+ * @param ctx - The cli plugin context.
6927
+ * @param error - The thrown deploy error.
6928
+ * @returns The `"failed"` deploy outcome.
6929
+ * @example
6930
+ * return renderFailure(ctx, error);
6931
+ */
6932
+ function renderFailure(ctx, error) {
6933
+ ctx.state.render.error("deploy failed", error);
6934
+ ctx.state.render.info(deployFailureHint$1(error));
6935
+ return {
6936
+ deployed: false,
6937
+ reason: "failed"
6938
+ };
6939
+ }
6940
+ /**
6941
+ * Deploy once via the deploy plugin and wrap the result as a successful outcome. Throws
6942
+ * the classified deploy error on failure (the caller decides how to surface it).
6943
+ *
6944
+ * @param ctx - The cli plugin context.
6945
+ * @param options - The deploy options (branch override).
6946
+ * @returns The successful deploy outcome.
6947
+ * @throws {Error} With a `code` from the deploy error taxonomy on any failure.
6948
+ * @example
6949
+ * const outcome = await deployOnce(ctx, { branch: "main" });
6950
+ */
6951
+ async function deployOnce(ctx, options) {
6952
+ return {
6953
+ deployed: true,
6954
+ ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6955
+ };
6956
+ }
6957
+ /**
6958
+ * Handle a project-not-found deploy failure interactively: ask (a confirmation step)
6959
+ * before creating a real Cloudflare resource, create the Pages project via the deploy
6960
+ * plugin, then retry the deploy once. A declined offer (or a create failure) returns the
6961
+ * `"failed"` outcome with an actionable hint — never a raw stack trace.
6962
+ *
6963
+ * @param ctx - The cli plugin context.
6964
+ * @param options - The deploy options (branch override).
6965
+ * @param originalError - The project-not-found error from the first attempt.
6966
+ * @returns The deploy outcome (deployed after a successful create + retry, else failed).
6967
+ * @example
6968
+ * return createProjectThenRetry(ctx, options, error);
6969
+ */
6970
+ async function createProjectThenRetry(ctx, options, originalError) {
6971
+ const deploy = ctx.require(deployPlugin);
6972
+ const name = deploy.projectName();
6973
+ ctx.state.render.warn(`The Cloudflare Pages project "${name}" does not exist yet.`);
6974
+ if (!await ctx.state.confirm(`Create the Cloudflare Pages project "${name}" now?`)) {
6975
+ ctx.state.render.error("deploy failed", originalError);
6976
+ ctx.state.render.info(projectNotFoundHint(name));
6977
+ return {
6978
+ deployed: false,
6979
+ reason: "failed"
6980
+ };
6981
+ }
6982
+ try {
6983
+ const created = await deploy.createProject();
6984
+ ctx.state.render.check(true, `created Cloudflare Pages project "${created.name}"`);
6985
+ } catch (error) {
6986
+ ctx.state.render.error("could not create the Pages project", error);
6987
+ ctx.state.render.info(deployFailureHint$1(error));
6988
+ return {
6989
+ deployed: false,
6990
+ reason: "failed"
6991
+ };
6992
+ }
6993
+ ctx.state.render.info("project created — retrying the deploy…");
6994
+ try {
6995
+ return await deployOnce(ctx, options);
6996
+ } catch (error) {
6997
+ return renderFailure(ctx, error);
6998
+ }
6999
+ }
7000
+ /**
7001
+ * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin. A
7002
+ * declined confirm returns `{ deployed: false, reason: "declined" }`. A project-not-found
7003
+ * failure offers to create the project (with a confirmation step) and retries; any other
7004
+ * runtime failure is surfaced as a styled error + fix hint, returning
7005
+ * `{ deployed: false, reason: "failed" }` — never a raw stack trace.
6569
7006
  *
6570
7007
  * @param ctx - The cli plugin context.
6571
7008
  * @param options - The deploy options (branch override + `yes`).
@@ -6582,10 +7019,12 @@ async function runDeployStep(ctx, options) {
6582
7019
  reason: "declined"
6583
7020
  };
6584
7021
  }
6585
- return {
6586
- deployed: true,
6587
- ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6588
- };
7022
+ try {
7023
+ return await deployOnce(ctx, options);
7024
+ } catch (error) {
7025
+ if (codeOf(error) === "ERR_DEPLOY_PROJECT_NOT_FOUND") return createProjectThenRetry(ctx, options, error);
7026
+ return renderFailure(ctx, error);
7027
+ }
6589
7028
  }
6590
7029
  /**
6591
7030
  * Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
@@ -6603,9 +7042,10 @@ async function runDeployStep(ctx, options) {
6603
7042
  async function runDeployWizard(ctx, options) {
6604
7043
  const cwd = process.cwd();
6605
7044
  ctx.state.render.heading("Checking prerequisites");
6606
- for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
6607
- await offerScaffold(ctx, diagnose(cwd));
6608
- const blockers = diagnose(cwd).filter((item) => !item.ok);
7045
+ for (const item of diagnose(ctx, cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
7046
+ await offerScaffold(ctx, diagnose(ctx, cwd));
7047
+ await offerEnvScaffold(ctx, cwd);
7048
+ const blockers = diagnose(ctx, cwd).filter((item) => !item.ok);
6609
7049
  if (blockers.length > 0) {
6610
7050
  ctx.state.render.heading("Not ready to deploy");
6611
7051
  for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
@@ -6622,7 +7062,7 @@ async function runDeployWizard(ctx, options) {
6622
7062
  ctx.state.render.check(notFoundOk, `${ctx.config.notFoundFile} present`, notFoundOk ? void 0 : "Set build.notFound so the SSG emits it (CF Pages else flips to SPA mode).");
6623
7063
  ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
6624
7064
  const outcome = await runDeployStep(ctx, options);
6625
- await offerWorkflowSetup(ctx);
7065
+ if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx);
6626
7066
  return outcome;
6627
7067
  }
6628
7068
  //#endregion
@@ -6662,25 +7102,26 @@ function injectReloadClient(html) {
6662
7102
  * Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
6663
7103
  * routes success to `onReloaded` and failure to `onError`.
6664
7104
  *
6665
- * @param input - The rebuild dependencies + the changed file.
6666
- * @param input.runBuild - Runs one build and resolves with its summary.
7105
+ * @param input - The rebuild dependencies + the changed file/paths.
7106
+ * @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
6667
7107
  * @param input.onRebuildStart - Called with the changed file just before the build runs.
6668
- * @param input.onReloaded - Called with the changed file + summary after a rebuild.
7108
+ * @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
6669
7109
  * @param input.onError - Called when a rebuild throws.
6670
7110
  * @param input.file - The changed file to report alongside the summary.
7111
+ * @param input.changed - The accumulated changed paths handed to `runBuild` (incremental).
6671
7112
  * @returns Resolves once the rebuild settles (always — errors are routed, not thrown).
6672
7113
  * @example
6673
- * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
7114
+ * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md", changed: ["a.md"] });
6674
7115
  */
6675
7116
  async function runOneRebuild(input) {
6676
7117
  input.onRebuildStart?.(input.file);
6677
7118
  try {
6678
- const summary = await input.runBuild();
7119
+ const summary = await input.runBuild(input.changed);
6679
7120
  input.onReloaded({
6680
7121
  file: input.file,
6681
7122
  pageCount: summary.pageCount,
6682
7123
  durationMs: summary.durationMs
6683
- });
7124
+ }, input.changed);
6684
7125
  } catch (error) {
6685
7126
  input.onError(error);
6686
7127
  }
@@ -6694,9 +7135,9 @@ async function runOneRebuild(input) {
6694
7135
  *
6695
7136
  * @param input - The rebuild dependencies.
6696
7137
  * @param input.debounceMs - Debounce window in milliseconds.
6697
- * @param input.runBuild - Runs one build and resolves with its summary.
7138
+ * @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
6698
7139
  * @param input.onRebuildStart - Called with the changed file just before each build runs.
6699
- * @param input.onReloaded - Called with the changed file + summary after a rebuild.
7140
+ * @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
6700
7141
  * @param input.onError - Called when a rebuild throws.
6701
7142
  * @returns The debounced rebuild driver.
6702
7143
  * @example
@@ -6705,12 +7146,13 @@ async function runOneRebuild(input) {
6705
7146
  function createRebuilder(input) {
6706
7147
  let timer;
6707
7148
  let pendingFile = "";
7149
+ const pendingChanged = /* @__PURE__ */ new Set();
6708
7150
  let building = false;
6709
7151
  let dirty = false;
6710
7152
  /**
6711
- * Rebuild repeatedly until no change arrived mid-flight: each pass clears `dirty`,
6712
- * runs one build, then loops again if a `schedule()` set `dirty` while it ran, so
6713
- * no change is dropped.
7153
+ * Rebuild repeatedly until no change arrived mid-flight: each pass snapshots + clears
7154
+ * the accumulated changed paths, runs one build over them, then loops again if a
7155
+ * `schedule()` set `dirty` (and added more paths) while it ran, so no change is dropped.
6714
7156
  *
6715
7157
  * @returns Resolves once a pass completes with no pending change (errors are routed,
6716
7158
  * never thrown).
@@ -6720,12 +7162,15 @@ function createRebuilder(input) {
6720
7162
  const drainPendingRebuilds = async () => {
6721
7163
  do {
6722
7164
  dirty = false;
7165
+ const changed = [...pendingChanged];
7166
+ pendingChanged.clear();
6723
7167
  await runOneRebuild({
6724
7168
  runBuild: input.runBuild,
6725
7169
  ...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
6726
7170
  onReloaded: input.onReloaded,
6727
7171
  onError: input.onError,
6728
- file: pendingFile
7172
+ file: pendingFile,
7173
+ changed
6729
7174
  });
6730
7175
  } while (dirty);
6731
7176
  };
@@ -6752,14 +7197,15 @@ function createRebuilder(input) {
6752
7197
  };
6753
7198
  return {
6754
7199
  /**
6755
- * Queue a rebuild for the given label (debounced + coalesced).
7200
+ * Queue a rebuild for the given changed path (debounced + coalesced + accumulated).
6756
7201
  *
6757
- * @param file - The label reported as `ReloadInfo.file` the watched directory in `serve()`.
7202
+ * @param file - The changed path reported as `ReloadInfo.file` and added to the changed set.
6758
7203
  * @example
6759
- * rebuilder.schedule("content");
7204
+ * rebuilder.schedule("content/intro/en.md");
6760
7205
  */
6761
7206
  schedule(file) {
6762
7207
  pendingFile = file;
7208
+ pendingChanged.add(file);
6763
7209
  if (timer) clearTimeout(timer);
6764
7210
  timer = setTimeout(fire, input.debounceMs);
6765
7211
  },
@@ -6789,26 +7235,33 @@ function isNoisePath(filename) {
6789
7235
  return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
6790
7236
  }
6791
7237
  /**
6792
- * Create a {@link ChangeGate} that drops three kinds of spurious change events before
6793
- * they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
6794
- * `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
6795
- * echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
6796
- * a change whose file mtime is at or before the last build we started was already
6797
- * captured (or is a late echo), so it is ignored while a genuinely newer edit (even
6798
- * one made mid-build) and a deletion (missing file) always pass. The single timestamp
6799
- * also means no per-path map grows over a long session.
7238
+ * Create a {@link ChangeGate} that drops four kinds of spurious change events before they
7239
+ * reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
7240
+ * `outDir` (the build's own output — a loop guard), the stale duplicate/parent-dir echoes
7241
+ * macOS fires for one save (a build-start high-water mark — a change whose mtime is at or
7242
+ * before the last build we started was already captured), and when a `fileHash` seam is
7243
+ * supplied a NO-OP save whose bytes are identical to the last successfully-built version
7244
+ * (a double Ctrl-S, a `touch`, a format-on-save that reverts). The no-op baseline is
7245
+ * recorded ONLY by {@link ChangeGate.commitBuilt} on build SUCCESS, scoped to that build's
7246
+ * paths — so a failed build commits nothing (a retry save always rebuilds) and a file
7247
+ * edited mid-build is never falsely baselined by another file's success. A genuinely newer
7248
+ * edit (even mid-build) and a deletion (missing file) always pass.
6800
7249
  *
6801
7250
  * @param input - The gate dependencies.
6802
7251
  * @param input.outDir - The build output directory whose writes must never re-trigger a build.
6803
7252
  * @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
6804
7253
  * @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
7254
+ * @param input.fileHash - Resolves a path's content hash (or `null` when missing). Optional;
7255
+ * defaults to `() => null`, which disables the no-op-save short-circuit (every edit passes).
6805
7256
  * @returns The change gate.
6806
7257
  * @example
6807
- * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
7258
+ * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock, fileHash: state.fileHash });
6808
7259
  */
6809
7260
  function createChangeGate(input) {
6810
7261
  const outDirAbs = path.resolve(input.outDir);
7262
+ const fileHash = input.fileHash ?? (() => null);
6811
7263
  let lastBuildStartedAt = input.now();
7264
+ const committedHash = /* @__PURE__ */ new Map();
6812
7265
  return {
6813
7266
  /**
6814
7267
  * Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
@@ -6826,6 +7279,12 @@ function createChangeGate(input) {
6826
7279
  if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${path.sep}`)) return false;
6827
7280
  const mtime = input.fileMtime(changed);
6828
7281
  if (mtime !== null && mtime < lastBuildStartedAt) return false;
7282
+ const hash = fileHash(changed);
7283
+ if (hash === null) {
7284
+ committedHash.delete(changed);
7285
+ return true;
7286
+ }
7287
+ if (committedHash.get(changed) === hash) return false;
6829
7288
  return true;
6830
7289
  },
6831
7290
  /**
@@ -6836,6 +7295,20 @@ function createChangeGate(input) {
6836
7295
  */
6837
7296
  markBuildStart() {
6838
7297
  lastBuildStartedAt = input.now();
7298
+ },
7299
+ /**
7300
+ * Baseline exactly the paths the just-succeeded build consumed (see {@link ChangeGate.commitBuilt}).
7301
+ *
7302
+ * @param changed - The paths the just-succeeded build consumed.
7303
+ * @example
7304
+ * gate.commitBuilt(["content/intro/en.md"]);
7305
+ */
7306
+ commitBuilt(changed) {
7307
+ for (const file of changed) {
7308
+ const key = path.resolve(file);
7309
+ const hash = fileHash(key);
7310
+ if (hash !== null) committedHash.set(key, hash);
7311
+ }
6839
7312
  }
6840
7313
  };
6841
7314
  }
@@ -7028,19 +7501,46 @@ function createDevHandler(ctx, hub) {
7028
7501
  };
7029
7502
  }
7030
7503
  /**
7504
+ * Build the per-run {@link BuildRunOverrides} for a dev build from the session feature
7505
+ * opt-ins: minification is always off in dev (no benefit, slower), and each expensive
7506
+ * output stays off unless its flag re-enables it (`ogImage: false` disables OG generation
7507
+ * regardless of the persisted config). The persisted plugin config is never mutated — the
7508
+ * overrides apply to the dev run only.
7509
+ *
7510
+ * @param features - The resolved per-session dev feature opt-ins.
7511
+ * @returns The config overrides merged into the dev build run.
7512
+ * @example
7513
+ * devBuildOverrides({ og: false, sitemap: false, feeds: false, localeRedirects: false });
7514
+ */
7515
+ function devBuildOverrides(features) {
7516
+ return {
7517
+ minify: false,
7518
+ ...features.feeds ? {} : { feeds: false },
7519
+ ...features.sitemap ? {} : { sitemap: false },
7520
+ ...features.og ? {} : { ogImage: false },
7521
+ ...features.localeRedirects ? {} : { localeRedirects: false }
7522
+ };
7523
+ }
7524
+ /**
7031
7525
  * Run the dev loop: an initial build, an in-process static server that injects the
7032
7526
  * live-reload client, a recursive watcher over `config.watchDirs`, and a debounced
7033
7527
  * rebuild that re-renders and pushes a browser reload. Resolves on SIGINT/SIGTERM,
7034
- * which stops the server, closes the watchers, and cancels any pending rebuild.
7528
+ * which stops the server, closes the watchers, and cancels any pending rebuild. The dev
7529
+ * build disables minification + expensive outputs (per {@link devBuildOverrides}); every
7530
+ * rebuild also skips the clean so caches + unchanged assets survive (no mid-rebuild 404).
7531
+ * Because rebuilds skip the clean, a DELETED or renamed content slug's stale page lingers
7532
+ * (and is served) until you restart `serve` or run a production `build`.
7035
7533
  *
7036
7534
  * @param ctx - The cli plugin context (config, state seams, `require`).
7037
7535
  * @param port - The port to bind the dev server to.
7536
+ * @param features - Per-session dev feature opt-ins (`og`/`sitemap`/`feeds`/`localeRedirects`).
7038
7537
  * @returns Resolves once the server has been torn down by a termination signal.
7039
7538
  * @example
7040
- * await runDevServer(ctx, 4173);
7539
+ * await runDevServer(ctx, 4173, { og: false, sitemap: false, feeds: false, localeRedirects: false });
7041
7540
  */
7042
- async function runDevServer(ctx, port) {
7043
- await ctx.require(buildPlugin).run();
7541
+ async function runDevServer(ctx, port, features) {
7542
+ const overrides = devBuildOverrides(features);
7543
+ await ctx.require(buildPlugin).run({ overrides });
7044
7544
  const hub = createReloadHub();
7045
7545
  const server = ctx.state.serveStatic({
7046
7546
  port,
@@ -7050,19 +7550,27 @@ async function runDevServer(ctx, port) {
7050
7550
  const gate = createChangeGate({
7051
7551
  outDir: ctx.config.outDir,
7052
7552
  fileMtime: ctx.state.fileMtime,
7053
- now: ctx.state.clock
7553
+ now: ctx.state.clock,
7554
+ fileHash: ctx.state.fileHash
7054
7555
  });
7055
7556
  const rebuilder = createRebuilder({
7056
7557
  debounceMs: ctx.config.debounceMs,
7057
7558
  /**
7058
- * Re-run the SSG build for a rebuild.
7559
+ * Re-run the SSG build for a rebuild: skip the clean so the prior assets + on-disk
7560
+ * caches survive (and no in-flight request hits an empty outDir), with the dev
7561
+ * overrides applied.
7059
7562
  *
7563
+ * @param changed - The paths changed since the last build (incremental rebuild hint).
7060
7564
  * @returns The rebuild summary.
7061
7565
  * @example
7062
- * await runBuild();
7566
+ * await runBuild(["content/intro/en.md"]);
7063
7567
  */
7064
- runBuild() {
7065
- return ctx.require(buildPlugin).run();
7568
+ runBuild(changed) {
7569
+ return ctx.require(buildPlugin).run({
7570
+ skipClean: true,
7571
+ overrides,
7572
+ changed
7573
+ });
7066
7574
  },
7067
7575
  /**
7068
7576
  * Show the compact in-place "rebuilding {label}" line before the build runs.
@@ -7079,15 +7587,18 @@ async function runDevServer(ctx, port) {
7079
7587
  * Render the reload line and push a browser reload after a rebuild.
7080
7588
  *
7081
7589
  * @param info - The changed file plus the rebuild's page count and duration.
7590
+ * @param changed - The paths this successful build consumed (baselined for no-op drops).
7082
7591
  * @example
7083
- * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 });
7592
+ * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 }, ["content/a.md"]);
7084
7593
  */
7085
- onReloaded(info) {
7594
+ onReloaded(info, changed) {
7595
+ gate.commitBuilt(changed);
7086
7596
  ctx.state.render.reload(info);
7087
7597
  hub.reloadAll();
7088
7598
  },
7089
7599
  /**
7090
- * Render a rebuild failure (the dev loop keeps running).
7600
+ * Render a rebuild failure (the dev loop keeps running). A failed build baselines
7601
+ * nothing (commitBuilt only runs on success), so an identical retry save still rebuilds.
7091
7602
  *
7092
7603
  * @param error - The thrown rebuild error.
7093
7604
  * @example
@@ -7098,7 +7609,8 @@ async function runDevServer(ctx, port) {
7098
7609
  }
7099
7610
  });
7100
7611
  const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
7101
- if (gate.accept(dir, filename)) rebuilder.schedule(dir);
7612
+ if (!gate.accept(dir, filename)) return;
7613
+ rebuilder.schedule(filename === void 0 ? dir : path.join(dir, filename));
7102
7614
  }));
7103
7615
  ctx.state.render.serverReady({
7104
7616
  local: `http://localhost:${port}`,
@@ -7110,6 +7622,7 @@ async function runDevServer(ctx, port) {
7110
7622
  for (const watcher of watchers) watcher.close();
7111
7623
  hub.close();
7112
7624
  server.stop();
7625
+ ctx.state.render.dispose();
7113
7626
  });
7114
7627
  }
7115
7628
  //#endregion
@@ -7310,6 +7823,28 @@ async function confirmDeploy(ctx, yes) {
7310
7823
  if (!confirmed) ctx.state.render.warn("deploy skipped");
7311
7824
  return confirmed;
7312
7825
  }
7826
+ /** Matches the prerequisite/credential failures a direct deploy most often hits (missing token/account). */
7827
+ const PREREQUISITE_ERROR = /required variable|not defined|cloudflare|token|account|unauthor|wrangler/i;
7828
+ /**
7829
+ * A short, actionable "how to fix" hint for a failed deploy, rendered under the error so
7830
+ * the user is never left at a raw stack trace. A missing-credential/prerequisite failure
7831
+ * (the common case for a first direct deploy) gets the concrete secret-setup steps;
7832
+ * anything else points at the guided deploy, which diagnoses prerequisites step by step.
7833
+ *
7834
+ * @param error - The thrown deploy error.
7835
+ * @returns The multi-line hint (newline-separated; rendered indented under a `›`).
7836
+ * @example
7837
+ * render.info(deployFailureHint(err));
7838
+ */
7839
+ function deployFailureHint(error) {
7840
+ if (PREREQUISITE_ERROR.test(String(error))) return [
7841
+ "how to fix:",
7842
+ "1. run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites and offers to create a starter .env",
7843
+ "2. or set them yourself in .env: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID",
7844
+ " token: https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
7845
+ ].join("\n");
7846
+ return "how to fix: run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites — then retry";
7847
+ }
7313
7848
  /**
7314
7849
  * Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
7315
7850
  * Each method renders `state.render.header(<command>)` first, then does its work;
@@ -7342,17 +7877,25 @@ function createApi$1(ctx) {
7342
7877
  },
7343
7878
  /**
7344
7879
  * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
7345
- * `watchDirs`, debounced rebuild + reload. Resolves on SIGINT/SIGTERM.
7880
+ * `watchDirs`, debounced + incremental rebuild + reload. For a fast rebuild the dev
7881
+ * build disables minification + expensive, preview-irrelevant outputs (feeds /
7882
+ * sitemap / og-images / locale-redirects); pass `og`/`sitemap`/`feeds`/
7883
+ * `localeRedirects` to re-enable any of them for the session. Resolves on SIGINT/SIGTERM.
7346
7884
  *
7347
- * @param options - Optional port override (defaults to `config.port`).
7885
+ * @param options - Optional port override + per-session dev feature opt-ins.
7348
7886
  * @returns Resolves once the server has been torn down.
7349
7887
  * @example
7350
- * await api.serve({ port: 3000 });
7888
+ * await api.serve({ port: 3000, og: true });
7351
7889
  */
7352
7890
  serve(options = {}) {
7353
7891
  const { port = ctx.config.port } = options;
7354
7892
  ctx.state.render.header("serve");
7355
- return runDevServer(ctx, port);
7893
+ return runDevServer(ctx, port, {
7894
+ og: options.og ?? false,
7895
+ sitemap: options.sitemap ?? false,
7896
+ feeds: options.feeds ?? false,
7897
+ localeRedirects: options.localeRedirects ?? false
7898
+ });
7356
7899
  },
7357
7900
  /**
7358
7901
  * Static preview of the built `dist/` with CF-Pages clean-URL resolution.
@@ -7384,15 +7927,21 @@ function createApi$1(ctx) {
7384
7927
  const { branch, yes = false } = options;
7385
7928
  ctx.state.render.header("deploy");
7386
7929
  if (options.guided === true) return runDeployWizard(ctx, options);
7387
- await ctx.require(deployPlugin).init({ ci: true });
7388
- if (!await confirmDeploy(ctx, yes)) return {
7389
- deployed: false,
7390
- reason: "declined"
7391
- };
7392
- return {
7393
- deployed: true,
7394
- ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
7395
- };
7930
+ try {
7931
+ await ctx.require(deployPlugin).init({ ci: true });
7932
+ if (!await confirmDeploy(ctx, yes)) return {
7933
+ deployed: false,
7934
+ reason: "declined"
7935
+ };
7936
+ return {
7937
+ deployed: true,
7938
+ ...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
7939
+ };
7940
+ } catch (error) {
7941
+ ctx.state.render.error("deploy failed", error);
7942
+ ctx.state.render.info(deployFailureHint(error));
7943
+ throw error;
7944
+ }
7396
7945
  }
7397
7946
  };
7398
7947
  }
@@ -7483,6 +8032,28 @@ const ANSI = {
7483
8032
  cyan: `${ESC}[36m`,
7484
8033
  gray: `${ESC}[90m`
7485
8034
  };
8035
+ /**
8036
+ * The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
8037
+ * Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
8038
+ */
8039
+ const BRAND_PINK = {
8040
+ r: 255,
8041
+ g: 30,
8042
+ b: 111
8043
+ };
8044
+ /**
8045
+ * Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
8046
+ *
8047
+ * @param r - Red channel (0–255).
8048
+ * @param g - Green channel (0–255).
8049
+ * @param b - Blue channel (0–255).
8050
+ * @returns The `ESC[38;2;r;g;bm` foreground sequence.
8051
+ * @example
8052
+ * fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
8053
+ */
8054
+ function fg24(r, g, b) {
8055
+ return `${ESC}[38;2;${r};${g};${b}m`;
8056
+ }
7486
8057
  /** ANSI: erase the entire current line, leaving the cursor where it is. */
7487
8058
  const CLEAR_LINE = `${ESC}[2K`;
7488
8059
  /** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
@@ -7554,6 +8125,36 @@ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR)
7554
8125
  return stream.isTTY === true && noColor === void 0;
7555
8126
  }
7556
8127
  /**
8128
+ * Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
8129
+ * renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
8130
+ * `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
8131
+ * is never used when color itself is disabled.
8132
+ *
8133
+ * @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
8134
+ * @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
8135
+ * @example
8136
+ * supportsTruecolor("truecolor"); // true
8137
+ */
8138
+ function supportsTruecolor(colorTerm = process.env.COLORTERM) {
8139
+ return colorTerm === "truecolor" || colorTerm === "24bit";
8140
+ }
8141
+ /**
8142
+ * The braille spinner glyph for a given elapsed time, advancing one frame per
8143
+ * `frameMs`. Deriving the frame from wall-clock elapsed (rather than a tick counter)
8144
+ * keeps the spinner correct even when the animation ticker is briefly starved by a
8145
+ * synchronous build phase and several ticks coalesce — the glyph still reflects real
8146
+ * elapsed time instead of freezing on a stale frame.
8147
+ *
8148
+ * @param elapsedMs - Milliseconds since the live region opened.
8149
+ * @param frameMs - Milliseconds per frame (defaults to `80`).
8150
+ * @returns The active spinner glyph.
8151
+ * @example
8152
+ * spinnerFrameAt(240); // "⠹" (the 4th frame at 80ms/frame)
8153
+ */
8154
+ function spinnerFrameAt(elapsedMs, frameMs = 80) {
8155
+ return SPINNER_FRAMES[Math.floor(Math.max(0, elapsedMs) / frameMs) % SPINNER_FRAMES.length] ?? "⠋";
8156
+ }
8157
+ /**
7557
8158
  * Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
7558
8159
  *
7559
8160
  * @param color - Whether color/Unicode output is enabled.
@@ -7581,12 +8182,14 @@ function visibleWidth(text) {
7581
8182
  * output in CI/pipes.
7582
8183
  *
7583
8184
  * @param color - Whether color is enabled (typically `supportsColor()`).
8185
+ * @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
8186
+ * only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
7584
8187
  * @returns The bound color palette.
7585
8188
  * @example
7586
- * const palette = makePalette(supportsColor());
8189
+ * const palette = makePalette(supportsColor(), supportsTruecolor());
7587
8190
  * const line = palette.green("done");
7588
8191
  */
7589
- function makePalette(color) {
8192
+ function makePalette(color, truecolor = false) {
7590
8193
  return {
7591
8194
  enabled: color,
7592
8195
  /**
@@ -7666,23 +8269,39 @@ function makePalette(color) {
7666
8269
  */
7667
8270
  cyan(text) {
7668
8271
  return this.paint(ANSI.cyan, text);
8272
+ },
8273
+ /**
8274
+ * Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
8275
+ * is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
8276
+ *
8277
+ * @param text - The text to colorize.
8278
+ * @returns The pink (or unchanged) text.
8279
+ * @example
8280
+ * palette.pink("▟▙ moku web");
8281
+ */
8282
+ pink(text) {
8283
+ if (!color) return text;
8284
+ if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
8285
+ return this.paint(ANSI.magenta, text);
7669
8286
  }
7670
8287
  };
7671
8288
  }
7672
8289
  /**
7673
8290
  * Frame a list of already-rendered content lines in a box, padding each line to the
7674
- * width of the widest visible line. Uses Unicode borders when `color` is enabled and
7675
- * ASCII otherwise. Visible width ignores embedded ANSI so colored lines align.
8291
+ * widest visible line (or `minInnerWidth`, whichever is larger so several boxes can be
8292
+ * forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
8293
+ * otherwise. Visible width ignores embedded ANSI so colored lines align.
7676
8294
  *
7677
8295
  * @param lines - The content lines (may contain ANSI color codes).
7678
8296
  * @param color - Whether to use Unicode borders (and assume color-capable output).
8297
+ * @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
7679
8298
  * @returns The boxed lines (top border, content rows, bottom border).
7680
8299
  * @example
7681
- * box(["Local: http://localhost:4173"], true);
8300
+ * box(["Local: http://localhost:4173"], true, 62);
7682
8301
  */
7683
- function box(lines, color) {
8302
+ function box(lines, color, minInnerWidth = 0) {
7684
8303
  const glyphs = boxGlyphs(color);
7685
- const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
8304
+ const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
7686
8305
  const horizontal = glyphs.horizontal.repeat(inner + 2);
7687
8306
  const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
7688
8307
  const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
@@ -7697,13 +8316,70 @@ function box(lines, color) {
7697
8316
  }
7698
8317
  //#endregion
7699
8318
  //#region src/plugins/cli/render/panel.ts
7700
- /** Per-command label shown in the header badge beside the logo. */
8319
+ /** Per-command label shown beside the lockup wordmark. */
7701
8320
  const COMMAND_LABEL = {
7702
8321
  build: "build",
7703
8322
  serve: "serve · dev",
7704
8323
  preview: "preview",
7705
8324
  deploy: "deploy"
7706
8325
  };
8326
+ /** Total visible width the header rule spans and the per-row timing column right-aligns to. */
8327
+ const RAIL_WIDTH = 66;
8328
+ /** Animation repaint cadence (ms) — how often the live region is redrawn when the loop is free. */
8329
+ const TICK_MS = 40;
8330
+ /** Spinner frame interval (ms) — one braille glyph advance per this many elapsed ms. */
8331
+ const SPIN_MS = 60;
8332
+ /** Inner (content) width of the BUILD/server boxes so their right edge lines up with the phase tree. */
8333
+ const BOX_INNER = RAIL_WIDTH - 4;
8334
+ /** The eight block glyphs the per-phase time-profile sparkline maps durations onto. */
8335
+ const SPARK_BARS = "▁▂▃▄▅▆▇█";
8336
+ /**
8337
+ * Build a sparkline from a list of values — one block glyph per value, height scaled to
8338
+ * the largest value so the tallest bar is `█`. A real micro-histogram (no fake data):
8339
+ * under the BUILD summary each bar is one phase's duration, so the slowest phase stands
8340
+ * out at a glance. Returns `""` for an empty list.
8341
+ *
8342
+ * @param values - The values to plot (e.g. per-phase durations in ms).
8343
+ * @returns The sparkline string.
8344
+ * @example
8345
+ * sparkline([12, 1701, 19698, 9]); // "▁▁█▁"
8346
+ */
8347
+ function sparkline(values) {
8348
+ if (values.length === 0) return "";
8349
+ const max = Math.max(...values, 1);
8350
+ return values.map((value) => {
8351
+ return SPARK_BARS[Math.min(7, Math.floor(value / max * 7))] ?? SPARK_BARS[0];
8352
+ }).join("");
8353
+ }
8354
+ /**
8355
+ * The structural glyph set for the active color mode: Unicode on a color-capable TTY,
8356
+ * ASCII fallbacks off it. Only the NEW Velocity chrome (cube, rule, tree, bar, live
8357
+ * dot) degrades here — the `✓ ✗ ~ ➜ ›` status marks stay as-is in both modes.
8358
+ *
8359
+ * @param color - Whether color/Unicode output is enabled.
8360
+ * @returns The matching glyph set.
8361
+ * @example
8362
+ * const g = glyphSet(true);
8363
+ */
8364
+ function glyphSet(color) {
8365
+ return color ? {
8366
+ cube: "▟▙",
8367
+ rule: "─",
8368
+ tree: "├─",
8369
+ barFill: "━",
8370
+ barTrack: "╴",
8371
+ liveOn: "◍",
8372
+ liveOff: "○"
8373
+ } : {
8374
+ cube: "*",
8375
+ rule: "-",
8376
+ tree: "-",
8377
+ barFill: "#",
8378
+ barTrack: "-",
8379
+ liveOn: "*",
8380
+ liveOff: "*"
8381
+ };
8382
+ }
7707
8383
  /**
7708
8384
  * Render one human-readable duration suffix (e.g. `· 84ms`).
7709
8385
  *
@@ -7718,15 +8394,49 @@ function durationSuffix(palette, durationMs) {
7718
8394
  return ` ${palette.dim(`· ${durationMs}ms`)}`;
7719
8395
  }
7720
8396
  /**
8397
+ * Right-align `right` against `left` within {@link RAIL_WIDTH}, measuring visible width
8398
+ * so embedded ANSI never throws the timing column off.
8399
+ *
8400
+ * @param left - The left segment (may contain ANSI).
8401
+ * @param right - The right segment (may contain ANSI).
8402
+ * @param width - Total visible width to fill (defaults to {@link RAIL_WIDTH}).
8403
+ * @returns The padded line.
8404
+ * @example
8405
+ * railLine(" ├─ ✓ pages", "· 12ms");
8406
+ */
8407
+ function railLine(left, right, width = RAIL_WIDTH) {
8408
+ const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
8409
+ return `${left}${" ".repeat(gap)}${right}`;
8410
+ }
8411
+ /**
8412
+ * The runtime facts line shown under the banner: the pinned core version (when known)
8413
+ * plus the live Node/Bun versions + platform — the ACTUAL running runtime, not the
8414
+ * `engines` floor. Every value is real (read from `@moku-labs/core`'s pinned dependency
8415
+ * and `process.versions`), so nothing on this line is faked.
8416
+ *
8417
+ * @param coreVersion - The pinned `@moku-labs/core` version (appended last — it rarely
8418
+ * matters — and omitted entirely when unknown).
8419
+ * @returns The facts string (e.g. `node 24.3.0 · bun 1.3.9 · darwin arm64 · core 0.1.0-alpha.6`).
8420
+ * @example
8421
+ * runtimeFacts("0.1.0-alpha.6");
8422
+ */
8423
+ function runtimeFacts(coreVersion) {
8424
+ const node = `node ${process.versions.node}`;
8425
+ const bun = process.versions.bun ? ` · bun ${process.versions.bun}` : "";
8426
+ const core = coreVersion ? ` · core ${coreVersion}` : "";
8427
+ return `${node}${bun} · ${process.platform} ${process.arch}${core}`;
8428
+ }
8429
+ /**
7721
8430
  * Create the Panel {@link CliRenderer}. Output is written through the injected sink
7722
- * (default `console.log`/`console.error`) and colorized only when color is enabled,
7723
- * so the identical render path yields box-drawn color panels on a TTY and plain
7724
- * ASCII lines in CI/pipes.
8431
+ * (default `console.log`/`console.error`) and colorized only when color is enabled, so
8432
+ * the identical render path yields the animated, box-free Velocity UI on a TTY and
8433
+ * plain ASCII lines in CI/pipes.
7725
8434
  *
7726
- * @param options - Optional sinks + a color override (see {@link PanelOptions}).
8435
+ * @param options - Optional sinks, color/truecolor overrides, clock, and version (see
8436
+ * {@link PanelOptions}).
7727
8437
  * @returns The renderer mounted on `state.render` and driven by the API + hooks.
7728
8438
  * @example
7729
- * const render = createPanelRenderer();
8439
+ * const render = createPanelRenderer({ version: "0.1.0-alpha" });
7730
8440
  * render.header("build");
7731
8441
  */
7732
8442
  function createPanelRenderer(options = {}) {
@@ -7737,26 +8447,24 @@ function createPanelRenderer(options = {}) {
7737
8447
  });
7738
8448
  const now = options.now ?? Date.now;
7739
8449
  const color = options.color ?? supportsColor();
7740
- const palette = makePalette(color);
8450
+ const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
8451
+ const version = options.version ?? "dev";
8452
+ const coreVersion = options.coreVersion;
8453
+ const g = glyphSet(color);
7741
8454
  let phaseRows = [];
7742
8455
  let phaseDrawn = 0;
7743
8456
  let phaseOpen = false;
8457
+ let blockStartedAt = 0;
7744
8458
  let rebuilding = false;
7745
8459
  let rebuildLabel = "";
7746
8460
  let rebuildStartedAt = 0;
7747
- let spinnerFrame = 0;
8461
+ let idle = false;
8462
+ let idleStartedAt = 0;
8463
+ let serveMode = false;
7748
8464
  let ticker;
7749
8465
  /**
7750
- * The current spinner glyph (with a static fallback under `noUncheckedIndexedAccess`).
7751
- *
7752
- * @returns The active braille spinner frame.
7753
- * @example
7754
- * frameGlyph(); // "⠙"
7755
- */
7756
- const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
7757
- /**
7758
- * Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
7759
- * before the dim name.
8466
+ * Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
8467
+ * `✓` + name with the duration right-aligned in the dim timing column once done.
7760
8468
  *
7761
8469
  * @param row - The phase row to render.
7762
8470
  * @returns The rendered row line (no trailing newline).
@@ -7764,12 +8472,34 @@ function createPanelRenderer(options = {}) {
7764
8472
  * renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
7765
8473
  */
7766
8474
  const renderPhaseRow = (row) => {
7767
- if (row.done) return ` ${palette.green("✓")} ${row.name}${durationSuffix(palette, row.durationMs)}`;
7768
- return ` ${palette.cyan(frameGlyph())} ${palette.dim(row.name)}`;
8475
+ const branch = palette.dim(g.tree);
8476
+ if (row.done) return railLine(` ${branch} ${palette.green("✓")} ${row.name}`, palette.dim(`· ${row.durationMs}ms`));
8477
+ return ` ${branch} ${palette.cyan(spinnerFrameAt(now() - blockStartedAt, SPIN_MS))} ${palette.dim(row.name)}`;
7769
8478
  };
7770
8479
  /**
7771
- * Repaint the live phase block in place: move up over the prior draw, then rewrite each
7772
- * row (clearing any stale trailing lines).
8480
+ * Render the indeterminate "comet" build bar a short pink fill window sweeping across
8481
+ * a dim track for the given elapsed time. Animated purely from wall-clock elapsed so
8482
+ * it never needs a known phase total.
8483
+ *
8484
+ * @param elapsedMs - Milliseconds since the phase block opened.
8485
+ * @returns The rendered bar row (no trailing newline).
8486
+ * @example
8487
+ * renderBuildBar(300);
8488
+ */
8489
+ const renderBuildBar = (elapsedMs) => {
8490
+ const length = 28;
8491
+ const window = 6;
8492
+ const head = Math.floor(elapsedMs / 28) % 34;
8493
+ let bar = "";
8494
+ for (let index = 0; index < length; index++) {
8495
+ const lit = index <= head && index > head - window;
8496
+ bar += lit ? palette.pink(g.barFill) : palette.dim(g.barTrack);
8497
+ }
8498
+ return ` ${bar}`;
8499
+ };
8500
+ /**
8501
+ * Repaint the live phase block in place (tree rows + animated build bar): move up over
8502
+ * the prior draw, rewrite each row, then the bar, clearing any stale trailing lines.
7773
8503
  *
7774
8504
  * @example
7775
8505
  * paintPhaseBlock();
@@ -7777,8 +8507,9 @@ function createPanelRenderer(options = {}) {
7777
8507
  const paintPhaseBlock = () => {
7778
8508
  let frame = cursorUp(phaseDrawn);
7779
8509
  for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
8510
+ frame += `${CLEAR_LINE}${renderBuildBar(now() - blockStartedAt)}\n`;
7780
8511
  writeRaw(frame + CLEAR_BELOW);
7781
- phaseDrawn = phaseRows.length;
8512
+ phaseDrawn = phaseRows.length + 1;
7782
8513
  };
7783
8514
  /**
7784
8515
  * Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
@@ -7787,20 +8518,31 @@ function createPanelRenderer(options = {}) {
7787
8518
  * paintRebuildLine();
7788
8519
  */
7789
8520
  const paintRebuildLine = () => {
7790
- const elapsed = ((now() - rebuildStartedAt) / 1e3).toFixed(1);
7791
- const meta = palette.dim(`· ${elapsed}s`);
7792
- writeRaw(`\r${CLEAR_LINE} ${palette.cyan(frameGlyph())} rebuilding ${rebuildLabel} ${meta}`);
8521
+ const spinner = palette.cyan(spinnerFrameAt(now() - rebuildStartedAt, SPIN_MS));
8522
+ const elapsed = palette.dim(`· ${((now() - rebuildStartedAt) / 1e3).toFixed(1)}s`);
8523
+ writeRaw(`\r${CLEAR_LINE} ${spinner} rebuilding ${rebuildLabel} ${elapsed}`);
7793
8524
  };
7794
8525
  /**
7795
- * Advance the spinner one frame and repaint whichever live region is active.
8526
+ * Repaint the persistent in-place `◍ live` idle pulse beneath the serve panel — the
8527
+ * dot breathes (pink → dim) on a calm ~0.6s cycle so a quiet dev session always reads
8528
+ * as alive without strobing.
8529
+ *
8530
+ * @example
8531
+ * paintIdleLine();
8532
+ */
8533
+ const paintIdleLine = () => {
8534
+ 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…")}`);
8535
+ };
8536
+ /**
8537
+ * Advance whichever live region is active by one frame (driven by the shared ticker).
7796
8538
  *
7797
8539
  * @example
7798
8540
  * onTick();
7799
8541
  */
7800
8542
  const onTick = () => {
7801
- spinnerFrame += 1;
7802
8543
  if (rebuilding) paintRebuildLine();
7803
8544
  else if (phaseOpen) paintPhaseBlock();
8545
+ else if (idle) paintIdleLine();
7804
8546
  };
7805
8547
  /**
7806
8548
  * Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
@@ -7810,7 +8552,7 @@ function createPanelRenderer(options = {}) {
7810
8552
  */
7811
8553
  const startTicker = () => {
7812
8554
  if (!color || ticker) return;
7813
- ticker = setInterval(onTick, 80);
8555
+ ticker = setInterval(onTick, TICK_MS);
7814
8556
  ticker.unref?.();
7815
8557
  };
7816
8558
  /**
@@ -7833,22 +8575,47 @@ function createPanelRenderer(options = {}) {
7833
8575
  const writeBlock = (lines) => {
7834
8576
  for (const line of lines) write(line);
7835
8577
  };
8578
+ /**
8579
+ * Resume the serve idle pulse on a fresh bottom line (TTY serve sessions only). A no-op
8580
+ * outside serve so standalone rebuild/error calls in unit tests never leave a ticker
8581
+ * running.
8582
+ *
8583
+ * @example
8584
+ * resumeIdle();
8585
+ */
8586
+ const resumeIdle = () => {
8587
+ if (!(color && serveMode)) {
8588
+ stopTicker();
8589
+ return;
8590
+ }
8591
+ idle = true;
8592
+ idleStartedAt = now();
8593
+ paintIdleLine();
8594
+ startTicker();
8595
+ };
7836
8596
  return {
7837
8597
  /**
7838
- * Render the boxed `MOKU WEB` logo + command label.
8598
+ * Render the `▟▙ moku web` lockup + per-command label, a dim rule, and the runtime
8599
+ * facts line (live Node/Bun versions + platform). Called once per command (one
8600
+ * command = one process), so it never repeats within a run.
7839
8601
  *
7840
- * @param command - The command being run, shown beside the logo.
8602
+ * @param command - The command being run, shown beside the wordmark.
7841
8603
  * @example
7842
8604
  * render.header("serve");
7843
8605
  */
7844
8606
  header(command) {
7845
- writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
8607
+ writeBlock([
8608
+ railLine(` ${palette.pink(g.cube)} ${palette.pink(palette.bold("moku web"))} ${palette.dim(COMMAND_LABEL[command])}`, palette.dim(version)),
8609
+ ` ${palette.dim(g.rule.repeat(RAIL_WIDTH - 1))}`,
8610
+ ` ${palette.dim(runtimeFacts(coreVersion))}`
8611
+ ]);
7846
8612
  },
7847
8613
  /**
7848
- * Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE row
7849
- * that updates in place (spinning glyph while running → green ✓ + duration when done);
7850
- * off a TTY one line is printed per completed phase (no start/done duplication). A
7851
- * no-op while a serve() rebuild is in flight — those show the compact rebuild line.
8614
+ * Render a live per-phase row from a `build:phase` event. On a TTY each phase is ONE
8615
+ * tree row that updates in place (spinning glyph while running → green ✓ + duration
8616
+ * when done) beneath an animated indeterminate build bar; off a TTY one line is
8617
+ * printed per completed phase (no start/done duplication). A no-op while a serve()
8618
+ * rebuild is in flight — those show the compact rebuild line.
7852
8619
  *
7853
8620
  * @param phase - The `build:phase` payload.
7854
8621
  * @example
@@ -7864,6 +8631,7 @@ function createPanelRenderer(options = {}) {
7864
8631
  phaseRows = [];
7865
8632
  phaseDrawn = 0;
7866
8633
  phaseOpen = true;
8634
+ blockStartedAt = now();
7867
8635
  }
7868
8636
  const done = phase.status === "done";
7869
8637
  const existing = phaseRows.find((row) => row.name === phase.phase);
@@ -7879,7 +8647,9 @@ function createPanelRenderer(options = {}) {
7879
8647
  startTicker();
7880
8648
  },
7881
8649
  /**
7882
- * Render the BUILD summary block from a `build:complete` event.
8650
+ * Render the BUILD summary line + a one-shot throughput sparkline from a
8651
+ * `build:complete` event, finalizing the live phase tree (dropping its animated bar)
8652
+ * first.
7883
8653
  *
7884
8654
  * @param summary - The `build:complete` payload.
7885
8655
  * @example
@@ -7887,33 +8657,52 @@ function createPanelRenderer(options = {}) {
7887
8657
  */
7888
8658
  built(summary) {
7889
8659
  if (rebuilding) return;
8660
+ if (color && phaseOpen) {
8661
+ let frame = cursorUp(phaseDrawn);
8662
+ for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
8663
+ writeRaw(frame + CLEAR_BELOW);
8664
+ }
8665
+ const phaseDurations = phaseRows.map((row) => row.durationMs).filter((value) => value !== void 0);
7890
8666
  phaseOpen = false;
7891
8667
  phaseDrawn = 0;
7892
8668
  stopTicker();
7893
8669
  const pages = palette.bold(String(summary.pageCount));
7894
- writeBlock(box([
7895
- `${palette.green("✓")} ${palette.bold("BUILD")} complete`,
7896
- `${palette.dim("pages")} ${pages}`,
7897
- `${palette.dim("time")} ${summary.durationMs}ms`,
7898
- `${palette.dim("out")} ${summary.outDir}/`
7899
- ], color));
8670
+ const dot = palette.dim("·");
8671
+ const lines = [railLine(`${palette.green("✓")} ${palette.bold("BUILD")} ${dot} ${pages} pages`, `${summary.durationMs}ms ${dot} ${summary.outDir}/`, BOX_INNER)];
8672
+ if (color && summary.durationMs > 0) {
8673
+ const rate = Math.max(1, Math.round(summary.pageCount / (summary.durationMs / 1e3)));
8674
+ const spark = phaseDurations.length > 0 ? palette.pink(sparkline(phaseDurations)) : "";
8675
+ const rateLabel = palette.dim(`${rate} pages/s`);
8676
+ lines.push(railLine(spark, rateLabel, BOX_INNER));
8677
+ }
8678
+ writeBlock(box(lines, color, BOX_INNER));
7900
8679
  },
7901
8680
  /**
7902
- * Render the bordered server-ready panel (Local / Network URLs + watched dirs).
8681
+ * Render the server-ready rail (Local / Network URLs + watched dirs) and, on a TTY,
8682
+ * begin the persistent breathing `◍ live` idle pulse beneath it.
7903
8683
  *
7904
8684
  * @param info - Local/Network URLs and optionally the watched directories.
7905
8685
  * @example
7906
8686
  * render.serverReady({ local: "http://localhost:4173", network: null });
7907
8687
  */
7908
8688
  serverReady(info) {
7909
- 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")}`];
7910
- if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
7911
- writeBlock(box(lines, color));
8689
+ const network = info.network ? palette.cyan(info.network) : palette.dim("unavailable");
8690
+ const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${network}`];
8691
+ if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
8692
+ writeBlock(box(lines, color, BOX_INNER));
8693
+ if (color) {
8694
+ serveMode = true;
8695
+ idle = true;
8696
+ idleStartedAt = now();
8697
+ paintIdleLine();
8698
+ startTicker();
8699
+ }
7912
8700
  },
7913
8701
  /**
7914
8702
  * Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
7915
- * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise) and mute
7916
- * the verbose phase rows + BUILD box until {@link reload}/{@link error} settles it.
8703
+ * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise), taking over
8704
+ * the idle-pulse line, and mute the verbose phase tree + BUILD summary until
8705
+ * {@link reload}/{@link error} settles it.
7917
8706
  *
7918
8707
  * @param label - The changed watch target shown in the line.
7919
8708
  * @example
@@ -7921,9 +8710,9 @@ function createPanelRenderer(options = {}) {
7921
8710
  */
7922
8711
  rebuildStart(label) {
7923
8712
  rebuilding = true;
8713
+ idle = false;
7924
8714
  rebuildLabel = label;
7925
8715
  rebuildStartedAt = now();
7926
- spinnerFrame = 0;
7927
8716
  if (!color) {
7928
8717
  write(` ${palette.yellow("~")} ${label}`);
7929
8718
  return;
@@ -7933,9 +8722,9 @@ function createPanelRenderer(options = {}) {
7933
8722
  },
7934
8723
  /**
7935
8724
  * Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
7936
- * "✓ rebuilt N pages · Xms · reloaded" (on a TTY) and re-enable verbose build output.
7937
- * Called standalone (no preceding {@link rebuildStart}) it also prints the "~ file"
7938
- * line so the changed target stays visible.
8725
+ * "✓ rebuilt N pages · Xms · reloaded", then (in a serve session) resume the idle pulse
8726
+ * on a fresh bottom line. Called standalone (no preceding {@link rebuildStart}) it also
8727
+ * prints the "~ file" line so the changed target stays visible.
7939
8728
  *
7940
8729
  * @param info - The changed file plus the rebuild's page count and duration.
7941
8730
  * @example
@@ -7944,30 +8733,26 @@ function createPanelRenderer(options = {}) {
7944
8733
  reload(info) {
7945
8734
  const settledRebuild = rebuilding;
7946
8735
  rebuilding = false;
7947
- stopTicker();
7948
8736
  const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
7949
8737
  if (settledRebuild && color) {
7950
8738
  writeRaw(`\r${CLEAR_LINE}${line}\n`);
8739
+ resumeIdle();
7951
8740
  return;
7952
8741
  }
7953
8742
  if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
7954
8743
  write(line);
7955
8744
  },
7956
8745
  /**
7957
- * Render the deploy result panel from a `deploy:complete` event.
8746
+ * Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
8747
+ * with the URL the hero value, then a dim `branch · id · time` line beneath it.
7958
8748
  *
7959
8749
  * @param result - The `deploy:complete` payload.
7960
8750
  * @example
7961
8751
  * render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
7962
8752
  */
7963
8753
  deployed(result) {
7964
- writeBlock(box([
7965
- `${palette.green("✓")} ${palette.bold("DEPLOYED")}`,
7966
- `${palette.dim("url")} ${palette.cyan(result.url)}`,
7967
- `${palette.dim("branch")} ${result.branch}`,
7968
- `${palette.dim("id")} ${result.deploymentId}`,
7969
- `${palette.dim("time")} ${result.durationMs}ms`
7970
- ], color));
8754
+ const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
8755
+ writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
7971
8756
  },
7972
8757
  /**
7973
8758
  * Render a neutral informational line.
@@ -7992,7 +8777,8 @@ function createPanelRenderer(options = {}) {
7992
8777
  writeError(` ${palette.yellow("⚠")} ${message}`);
7993
8778
  },
7994
8779
  /**
7995
- * Render an error line (to stderr), optionally with a cause.
8780
+ * Render an error line (to stderr), optionally with a cause. A failing rebuild settles
8781
+ * its in-place spinner line first; in a serve session the idle pulse then resumes.
7996
8782
  *
7997
8783
  * @param message - The error summary to print.
7998
8784
  * @param cause - Optional underlying error/value to print beneath the summary.
@@ -8000,16 +8786,18 @@ function createPanelRenderer(options = {}) {
8000
8786
  * render.error("build failed", err);
8001
8787
  */
8002
8788
  error(message, cause) {
8789
+ const wasRebuilding = rebuilding;
8003
8790
  if (rebuilding) {
8004
8791
  rebuilding = false;
8005
- stopTicker();
8006
8792
  if (color) writeRaw(`\r${CLEAR_LINE}`);
8007
8793
  }
8008
8794
  writeError(` ${palette.red("✗")} ${message}`);
8009
8795
  if (cause !== void 0) writeError(String(cause));
8796
+ if (wasRebuilding) resumeIdle();
8797
+ else stopTicker();
8010
8798
  },
8011
8799
  /**
8012
- * Render a section heading (a blank line + a bold cyan label) for a multi-step flow.
8800
+ * Render a section heading (a blank line + a bold pink label) for a multi-step flow.
8013
8801
  *
8014
8802
  * @param text - The heading label.
8015
8803
  * @example
@@ -8017,7 +8805,7 @@ function createPanelRenderer(options = {}) {
8017
8805
  */
8018
8806
  heading(text) {
8019
8807
  write("");
8020
- write(` ${palette.bold(palette.cyan(text))}`);
8808
+ write(` ${palette.bold(palette.pink(text))}`);
8021
8809
  },
8022
8810
  /**
8023
8811
  * Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
@@ -8032,6 +8820,19 @@ function createPanelRenderer(options = {}) {
8032
8820
  check(ok, label, detail) {
8033
8821
  write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
8034
8822
  if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
8823
+ },
8824
+ /**
8825
+ * Stop every animation and release the interval timer (serve()'s teardown calls this).
8826
+ *
8827
+ * @example
8828
+ * render.dispose();
8829
+ */
8830
+ dispose() {
8831
+ stopTicker();
8832
+ idle = false;
8833
+ rebuilding = false;
8834
+ phaseOpen = false;
8835
+ serveMode = false;
8035
8836
  }
8036
8837
  };
8037
8838
  }
@@ -8177,6 +8978,23 @@ function defaultFileMtime(filePath) {
8177
8978
  }
8178
8979
  }
8179
8980
  /**
8981
+ * Default file-content-hash probe — `sha256` of the file bytes, returning `null` for a
8982
+ * missing/unreadable path. serve()'s change gate compares this against the last
8983
+ * successfully-built bytes to drop a no-op save (a byte-identical double Ctrl-S).
8984
+ *
8985
+ * @param filePath - The absolute path to hash.
8986
+ * @returns The hex SHA-256 of the file's bytes, or `null` when it cannot be read.
8987
+ * @example
8988
+ * const hash = defaultFileHash("/abs/content/a.md");
8989
+ */
8990
+ function defaultFileHash(filePath) {
8991
+ try {
8992
+ return createHash("sha256").update(readFileSync(filePath)).digest("hex");
8993
+ } catch {
8994
+ return null;
8995
+ }
8996
+ }
8997
+ /**
8180
8998
  * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
8181
8999
  * reads `node:os` interfaces while tests can inject a deterministic value.
8182
9000
  *
@@ -8188,6 +9006,101 @@ function defaultFileMtime(filePath) {
8188
9006
  function defaultNetworkUrl(port) {
8189
9007
  return networkUrl(port);
8190
9008
  }
9009
+ /** Memoized banner facts — resolution touches the filesystem + git once, then caches. */
9010
+ let cachedBanner;
9011
+ /**
9012
+ * Run a read-only `git` command in `dir`, returning its trimmed stdout (`undefined` on
9013
+ * any failure — not a checkout, git missing, etc.). A thin wrapper so the version
9014
+ * resolver can issue a couple of git queries without repeating the spawn boilerplate.
9015
+ *
9016
+ * @param dir - The working directory to run git in.
9017
+ * @param args - The git arguments (no user input is ever interpolated).
9018
+ * @returns The trimmed command output, or `undefined` on failure.
9019
+ * @example
9020
+ * git("/Users/me/moku/web", ["tag", "--list", "v*"]);
9021
+ */
9022
+ function git(dir, args) {
9023
+ try {
9024
+ return execFileSync("git", args, {
9025
+ cwd: dir,
9026
+ encoding: "utf8",
9027
+ stdio: [
9028
+ "ignore",
9029
+ "pipe",
9030
+ "ignore"
9031
+ ]
9032
+ }).trim();
9033
+ } catch {
9034
+ return;
9035
+ }
9036
+ }
9037
+ /**
9038
+ * The framework's source/dev version, derived the SAME way the publish workflow computes
9039
+ * a release: the highest semver `v*` tag is the source of truth (`@moku-labs/web` is
9040
+ * released tag-only — the working-tree `package.json` deliberately carries no `version`).
9041
+ * A `-dev` suffix marks it as a local build off that release line (e.g. `v1.1.0-dev`), so
9042
+ * it never masquerades as the published release. Falls back to the short commit (then
9043
+ * `undefined`) only when no tags exist. `undefined` when `dir` is not a git checkout (a
9044
+ * published npm install — which carries its real `version` instead).
9045
+ *
9046
+ * @param dir - A directory inside the framework's own repository (the realpath of the
9047
+ * package root, so a symlinked local checkout reports the framework's tag — not the
9048
+ * consumer's).
9049
+ * @returns The dev version (e.g. `v1.1.0-dev`), or `undefined`.
9050
+ * @example
9051
+ * devVersion("/Users/me/moku/web"); // "v1.1.0-dev"
9052
+ */
9053
+ function devVersion(dir) {
9054
+ const latestTag = git(dir, [
9055
+ "tag",
9056
+ "--list",
9057
+ "v*",
9058
+ "--sort=-v:refname"
9059
+ ])?.split("\n")[0]?.trim();
9060
+ if (latestTag) return `${latestTag}-dev`;
9061
+ const sha = git(dir, [
9062
+ "rev-parse",
9063
+ "--short",
9064
+ "HEAD"
9065
+ ]);
9066
+ return sha ? `${sha}-dev` : void 0;
9067
+ }
9068
+ /**
9069
+ * Resolve the real version/runtime facts shown in the Panel banner (memoized). Reads the
9070
+ * `package.json` shipped beside the built bundle (`dist/../package.json`): a PUBLISHED
9071
+ * release carries a `version` and reports `v{version}`; a source/dev build (no `version`
9072
+ * field — `@moku-labs/web` is released tag-only) reports the latest semver tag + `-dev`
9073
+ * (e.g. `v1.1.0-dev`, the same tag the publish workflow treats as the version source), or
9074
+ * `"dev"` when git is unavailable. The pinned `@moku-labs/core` version comes from the
9075
+ * same file's `dependencies`.
9076
+ *
9077
+ * @returns The resolved {@link BannerFacts}.
9078
+ * @example
9079
+ * resolveBanner(); // { version: "v1.1.0-dev", coreVersion: "0.1.0-alpha.6" }
9080
+ */
9081
+ function resolveBanner() {
9082
+ if (cachedBanner) return cachedBanner;
9083
+ let pkg = {};
9084
+ let pkgDir;
9085
+ try {
9086
+ const pkgUrl = new URL("../package.json", import.meta.url);
9087
+ pkgDir = realpathSync(path.dirname(fileURLToPath(pkgUrl)));
9088
+ pkg = JSON.parse(readFileSync(pkgUrl, "utf8"));
9089
+ } catch {}
9090
+ const coreVersion = (pkg.dependencies?.["@moku-labs/core"] ?? "").replace(/^\D*/, "") || "unknown";
9091
+ const released = pkg.version;
9092
+ let version = "dev";
9093
+ if (released) version = `v${released}`;
9094
+ else {
9095
+ const dev = devVersion(pkgDir ?? process.cwd());
9096
+ if (dev) version = dev;
9097
+ }
9098
+ cachedBanner = {
9099
+ version,
9100
+ coreVersion
9101
+ };
9102
+ return cachedBanner;
9103
+ }
8191
9104
  /**
8192
9105
  * Create the initial cli plugin state with the production seams wired. Every field is
8193
9106
  * an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
@@ -8201,8 +9114,12 @@ function defaultNetworkUrl(port) {
8201
9114
  * const state = createState({ global: {}, config });
8202
9115
  */
8203
9116
  function createState$1(_ctx) {
9117
+ const banner = resolveBanner();
8204
9118
  return {
8205
- render: createPanelRenderer(),
9119
+ render: createPanelRenderer({
9120
+ version: banner.version,
9121
+ coreVersion: banner.coreVersion
9122
+ }),
8206
9123
  confirm: defaultConfirm,
8207
9124
  select: defaultSelect,
8208
9125
  clock: Date.now,
@@ -8210,7 +9127,8 @@ function createState$1(_ctx) {
8210
9127
  serveStatic: defaultServeStatic,
8211
9128
  fileResponse: defaultFileResponse,
8212
9129
  networkUrl: defaultNetworkUrl,
8213
- fileMtime: defaultFileMtime
9130
+ fileMtime: defaultFileMtime,
9131
+ fileHash: defaultFileHash
8214
9132
  };
8215
9133
  }
8216
9134
  //#endregion