@moku-labs/web 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1295,18 +1295,24 @@ function isPublished(article, isProduction) {
1295
1295
  * locale collection: existing files only, drafts dropped in production, sorted
1296
1296
  * date-descending. The single load+filter+sort step behind {@link createContentApi.loadAll}.
1297
1297
  *
1298
+ * When a `cached` map is supplied (incremental dev rebuild), a slug already present in it
1299
+ * is reused as-is (skipping the re-read + Markdown/Shiki re-render); only slugs absent
1300
+ * from it — the ones a preceding `invalidate()` dropped, plus any never-loaded — are
1301
+ * resolved fresh. With no `cached` map every slug is resolved (the full load).
1302
+ *
1298
1303
  * @param ctx - Kernel-free domain context (provider + i18n helpers + stage).
1299
1304
  * @param slugs - Every known article slug from the provider.
1300
1305
  * @param locale - The locale to resolve and collect.
1306
+ * @param cached - Optional per-locale article cache to reuse for unchanged slugs.
1301
1307
  * @returns The published (date-descending) articles for this locale.
1302
1308
  * @example
1303
1309
  * ```ts
1304
1310
  * const present = await loadAndFilterArticles(ctx, slugs, "en");
1305
1311
  * ```
1306
1312
  */
1307
- async function loadAndFilterArticles(ctx, slugs, locale) {
1313
+ async function loadAndFilterArticles(ctx, slugs, locale, cached) {
1308
1314
  const isProduction = ctx.global.stage === "production";
1309
- return (await Promise.all(slugs.map((slug) => resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
1315
+ return (await Promise.all(slugs.map((slug) => cached?.get(slug) ?? resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
1310
1316
  }
1311
1317
  /**
1312
1318
  * Derive the article slug from a source file path — the parent directory name
@@ -1343,19 +1349,26 @@ function createContentApi(ctx) {
1343
1349
  * Load every article across every active locale (locale fallback, production
1344
1350
  * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
1345
1351
  *
1352
+ * With `{ reuse: true }` (dev incremental rebuild) cached articles are reused for
1353
+ * every slug a preceding `invalidate()` did not drop, so only the dirty articles
1354
+ * re-read + re-run the Markdown/Shiki pipeline; the `contentId` ordinals are still
1355
+ * recomputed across the FULL sorted set, so ids + order match a full load.
1356
+ *
1357
+ * @param options - Optional load behaviour (`reuse`); omit for a full load.
1346
1358
  * @returns A locale-keyed map of date-descending articles.
1347
1359
  * @example
1348
1360
  * ```ts
1349
1361
  * const byLocale = await api.loadAll();
1350
1362
  * ```
1351
1363
  */
1352
- async loadAll() {
1364
+ async loadAll(options) {
1365
+ const reuse = options?.reuse === true;
1353
1366
  const slugs = await ctx.provider.slugs();
1354
1367
  const locales = ctx.locales();
1355
1368
  const result = /* @__PURE__ */ new Map();
1356
1369
  let total = 0;
1357
1370
  for (const locale of locales) {
1358
- const present = await loadAndFilterArticles(ctx, slugs, locale);
1371
+ const present = await loadAndFilterArticles(ctx, slugs, locale, reuse ? ctx.state.articles.get(locale) : void 0);
1359
1372
  const cache = /* @__PURE__ */ new Map();
1360
1373
  let index = 0;
1361
1374
  for (const article of present) {
@@ -3356,7 +3369,11 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3356
3369
  /**
3357
3370
  * Bundles CSS and JS into the output directory via two separate runner passes
3358
3371
  * (dodging Bun's mixed-entrypoint segfault), honoring `config.minify`, and caches
3359
- * the resulting hashed asset paths in `state.buildCache` for downstream phases.
3372
+ * the resulting hashed asset paths in `state.buildCache` for downstream phases. The
3373
+ * two passes run CONCURRENTLY (`Promise.all`) — they target disjoint hashed outputs
3374
+ * and distinct `buildCache` keys (`css`/`js`), so overlapping them ~halves bundle
3375
+ * wall-time with no shared-state hazard. The CSS pass is still dispatched first, so
3376
+ * the runner's invocation order stays css-then-js.
3360
3377
  *
3361
3378
  * @param ctx - Plugin context (provides `state`, `config`, `log`).
3362
3379
  * @param options - Optional dependency-injection seam (runner + entrypoints).
@@ -3369,10 +3386,10 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3369
3386
  async function bundle(ctx, options = {}) {
3370
3387
  const runner = options.runner ?? defaultRunner;
3371
3388
  const { minify, outDir } = ctx.config;
3389
+ const assetsDir = node_path$1.default.join(outDir, "assets");
3372
3390
  const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
3373
3391
  const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
3374
- await runOne(ctx, runner, "css", cssEntrypoints, outDir, node_path$1.default.join(outDir, "assets"), minify);
3375
- await runOne(ctx, runner, "js", jsEntrypoints, outDir, node_path$1.default.join(outDir, "assets"), minify);
3392
+ await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
3376
3393
  }
3377
3394
  //#endregion
3378
3395
  //#region src/plugins/build/phases/content.ts
@@ -3389,15 +3406,24 @@ const CONTENT_CACHE_KEY = "content";
3389
3406
  * pages/feeds/og-images phases. Performs NO Markdown parsing itself — the
3390
3407
  * content plugin owns rendering (god-plugin invariant).
3391
3408
  *
3409
+ * On a dev incremental rebuild (`options.changed` set) it first `invalidate()`s the
3410
+ * changed Markdown so `loadAll({ reuse: true })` re-reads + re-renders ONLY those
3411
+ * articles, reusing the cached HTML for the rest. With no options it does a full load.
3412
+ *
3392
3413
  * @param ctx - Plugin context (provides `require`, `state`, `log`).
3414
+ * @param options - Optional incremental hints; omit for a full load.
3415
+ * @param options.reuse - Reuse cached content for slugs not invalidated (dev incremental rebuild).
3416
+ * @param options.changed - The changed Markdown paths to invalidate before loading.
3393
3417
  * @returns The locale-keyed article map returned by the content plugin.
3394
3418
  * @example
3395
3419
  * ```ts
3396
3420
  * const byLocale = await loadContent(ctx);
3397
3421
  * ```
3398
3422
  */
3399
- async function loadContent(ctx) {
3400
- const byLocale = await ctx.require(contentPlugin).loadAll();
3423
+ async function loadContent(ctx, options) {
3424
+ const content = ctx.require(contentPlugin);
3425
+ if (options?.changed && options.changed.length > 0) content.invalidate(options.changed);
3426
+ const byLocale = await content.loadAll({ reuse: options?.reuse ?? false });
3401
3427
  ctx.state.buildCache.set(CONTENT_CACHE_KEY, byLocale);
3402
3428
  ctx.log.debug("build:content", { locales: byLocale.size });
3403
3429
  return byLocale;
@@ -4751,6 +4777,71 @@ function renderBody(definition, routeContext) {
4751
4777
  return (0, preact_render_to_string.renderToString)(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
4752
4778
  }
4753
4779
  /**
4780
+ * Hash a page's render inputs (its loaded data) for the render cache. `null` when the
4781
+ * data is not JSON-serializable — such a page is never cached and always re-renders.
4782
+ *
4783
+ * @param data - The route's loaded data (the only per-page input besides params/locale/code).
4784
+ * @returns The hex SHA-256 of the serialized data, or `null` when it cannot be serialized.
4785
+ * @example
4786
+ * ```ts
4787
+ * hashData({ title: "Hi" }); // "9f8e…"
4788
+ * ```
4789
+ */
4790
+ function hashData(data) {
4791
+ try {
4792
+ const serialized = JSON.stringify(data) ?? "";
4793
+ return (0, node_crypto.createHash)("sha256").update(serialized).digest("hex");
4794
+ } catch {
4795
+ return null;
4796
+ }
4797
+ }
4798
+ /**
4799
+ * The render-cache key for one page instance: name + params + locale (the stable identity
4800
+ * that, together with the data hash, determines its body). NUL-joined so no value collides.
4801
+ *
4802
+ * @param instance - The page instance.
4803
+ * @returns The cache key string.
4804
+ * @example
4805
+ * ```ts
4806
+ * renderCacheKey(instance); // "article{\"slug\":\"x\"}en"
4807
+ * ```
4808
+ */
4809
+ function renderCacheKey(instance) {
4810
+ return `${instance.name}${JSON.stringify(instance.params)}${instance.locale}`;
4811
+ }
4812
+ /**
4813
+ * Render one page's body, reusing the cached body when this page's data is unchanged.
4814
+ * The body is the synchronous, dominant-cost step ({@link renderBody}); an incremental
4815
+ * dev rebuild (`reuse`, code unchanged) skips it for every page whose data hash matches
4816
+ * the cache, and a changed page (or a non-`reuse` run) renders + refreshes the cache.
4817
+ *
4818
+ * @param ctx - Plugin context (provides the cross-run `state.renderCache`).
4819
+ * @param instance - The page instance being rendered.
4820
+ * @param routeContext - The route context passed to `.render()`/`.layout()`.
4821
+ * @param data - The route's loaded data (hashed to detect a change).
4822
+ * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
4823
+ * @returns The SSR-rendered body HTML.
4824
+ * @example
4825
+ * ```ts
4826
+ * const body = renderBodyCached(ctx, instance, routeContext, data, true);
4827
+ * ```
4828
+ */
4829
+ function renderBodyCached(ctx, instance, routeContext, data, reuse) {
4830
+ const cache = ctx.state.renderCache;
4831
+ const key = renderCacheKey(instance);
4832
+ const hash = hashData(data);
4833
+ if (reuse && hash !== null) {
4834
+ const hit = cache.get(key);
4835
+ if (hit?.dataHash === hash) return hit.body;
4836
+ }
4837
+ const body = renderBody(instance.definition, routeContext);
4838
+ if (hash !== null) cache.set(key, {
4839
+ dataHash: hash,
4840
+ body
4841
+ });
4842
+ return body;
4843
+ }
4844
+ /**
4754
4845
  * Write a rendered page document to its on-disk path. The path comes from the
4755
4846
  * compiled `TypedRoute.toFile(params)` (honoring any route-level `.toFile()`
4756
4847
  * override), resolved under the build `outDir`; parent directories are created first.
@@ -4779,13 +4870,14 @@ async function writeDocument(outDir, entry, params, html) {
4779
4870
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
4780
4871
  * @param instance - The concrete page instance to render.
4781
4872
  * @param shell - Per-build wiring shared across instances (asset tags + template).
4873
+ * @param reuse - Whether this run may reuse a cached body (incremental, no code change).
4782
4874
  * @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
4783
4875
  * @example
4784
4876
  * ```ts
4785
- * await renderInstance(ctx, instance, { assets: "", template: null });
4877
+ * await renderInstance(ctx, instance, { assets: "", template: null }, false);
4786
4878
  * ```
4787
4879
  */
4788
- async function renderInstance(ctx, instance, shell) {
4880
+ async function renderInstance(ctx, instance, shell, reuse) {
4789
4881
  const { definition, entry, params, locale } = instance;
4790
4882
  const router = ctx.require(routerPlugin);
4791
4883
  const data = await loadRouteData(definition, params, locale, ctx);
@@ -4798,7 +4890,7 @@ async function renderInstance(ctx, instance, shell) {
4798
4890
  };
4799
4891
  const parts = {
4800
4892
  head: composeHeadHtml(ctx, instance, url, routeContext, data),
4801
- body: renderBody(definition, routeContext),
4893
+ body: renderBodyCached(ctx, instance, routeContext, data, reuse),
4802
4894
  assets: shell.assets,
4803
4895
  locale
4804
4896
  };
@@ -4899,6 +4991,12 @@ function findRootHtml(rendered) {
4899
4991
  */
4900
4992
  const RENDER_BATCH_SIZE = 2;
4901
4993
  /**
4994
+ * Batch size for an incremental (`reuse`) rebuild. Most instances are cheap cache hits, so
4995
+ * a larger batch cuts the per-batch `setImmediate` round-trips (which would otherwise add
4996
+ * pure latency to an otherwise-fast rebuild) without starving the dev spinner.
4997
+ */
4998
+ const INCREMENTAL_BATCH_SIZE = 32;
4999
+ /**
4902
5000
  * Render `items` through `worker` in bounded-size batches, yielding a macrotask
4903
5001
  * (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
4904
5002
  * sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
@@ -4934,21 +5032,29 @@ async function renderInBatches(items, batchSize, worker) {
4934
5032
  * bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
4935
5033
  * capture the root page's HTML for the root-index phase.
4936
5034
  *
5035
+ * On an incremental rebuild (`options.reuse`) the cross-run render cache is kept and each
5036
+ * unchanged-data page reuses its cached body; a full render clears the cache first so a
5037
+ * removed/renamed route's stale body never lingers.
5038
+ *
4937
5039
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `log`, `has`).
5040
+ * @param options - Optional incremental hint; omit for a full render.
5041
+ * @param options.reuse - Reuse cached page bodies for unchanged-data pages (dev incremental rebuild).
4938
5042
  * @returns The number of pages rendered and the captured default-page HTML.
4939
5043
  * @example
4940
5044
  * ```ts
4941
5045
  * const { pageCount, rootHtml } = await renderPages(ctx);
4942
5046
  * ```
4943
5047
  */
4944
- async function renderPages(ctx) {
5048
+ async function renderPages(ctx, options) {
5049
+ const reuse = options?.reuse === true;
4945
5050
  const router = ctx.require(routerPlugin);
4946
5051
  const manifest = router.manifest();
4947
5052
  ctx.state.manifest = [...manifest];
4948
5053
  const locales = ctx.require(i18nPlugin).locales();
4949
5054
  const byPattern = makeEntryMap(router);
5055
+ if (!reuse) ctx.state.renderCache.clear();
4950
5056
  const shell = await prepareShell(ctx);
4951
- const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell));
5057
+ const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
4952
5058
  await writeDataSidecars(ctx, rendered, router.mode());
4953
5059
  ctx.log.debug("build:pages", { count: rendered.length });
4954
5060
  return {
@@ -5156,6 +5262,40 @@ async function generateSitemap(ctx) {
5156
5262
  * @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
5157
5263
  * emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
5158
5264
  */
5265
+ /** Matches a Markdown source path (a content edit). */
5266
+ const MARKDOWN_PATH = /\.md$/;
5267
+ /** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
5268
+ const STYLE_PATH = /\.css$/;
5269
+ /** Matches a code path (TS/JS/JSON — may change ANY page's render output). */
5270
+ const CODE_PATH = /\.(?:tsx?|jsx?|mjs|cjs|json)$/;
5271
+ /**
5272
+ * Derive the {@link ChangePlan} for a run from its changed-path set (see the type docs
5273
+ * for the rules).
5274
+ *
5275
+ * @param changed - Absolute/relative changed paths, or `undefined` for a full build.
5276
+ * @returns The reuse plan for this run.
5277
+ * @example
5278
+ * ```ts
5279
+ * const plan = planIncrementalRebuild(options?.changed);
5280
+ * ```
5281
+ */
5282
+ function planIncrementalRebuild(changed) {
5283
+ if (changed === void 0 || changed.length === 0) return {
5284
+ contentChanged: [],
5285
+ contentReuse: false,
5286
+ renderReuse: false
5287
+ };
5288
+ if (!changed.every((file) => MARKDOWN_PATH.test(file) || STYLE_PATH.test(file) || CODE_PATH.test(file))) return {
5289
+ contentChanged: [],
5290
+ contentReuse: false,
5291
+ renderReuse: false
5292
+ };
5293
+ return {
5294
+ contentChanged: changed.filter((file) => MARKDOWN_PATH.test(file)),
5295
+ contentReuse: true,
5296
+ renderReuse: !changed.some((file) => CODE_PATH.test(file))
5297
+ };
5298
+ }
5159
5299
  /**
5160
5300
  * The static ordered list of pipeline phase names.
5161
5301
  *
@@ -5269,8 +5409,7 @@ async function runOutputs(ctx) {
5269
5409
  * `build:phase` boundary per phase and `build:complete` once at the end.
5270
5410
  *
5271
5411
  * @param ctx - Plugin context (provides `require`, `emit`, `state`, `config`, `log`).
5272
- * @param options - Optional run overrides.
5273
- * @param options.outDir - Override the configured output directory for this run.
5412
+ * @param options - Optional per-run overrides ({@link RunOptions}).
5274
5413
  * @returns The build result (outDir, pageCount, durationMs).
5275
5414
  * @example
5276
5415
  * ```ts
@@ -5285,17 +5424,22 @@ async function runPipeline(ctx, options) {
5285
5424
  ...ctx,
5286
5425
  config: {
5287
5426
  ...ctx.config,
5288
- outDir
5427
+ outDir,
5428
+ ...options?.overrides
5289
5429
  }
5290
5430
  };
5291
- await (0, node_fs_promises.rm)(outDir, {
5431
+ const plan = planIncrementalRebuild(options?.changed);
5432
+ if (!options?.skipClean) await (0, node_fs_promises.rm)(outDir, {
5292
5433
  recursive: true,
5293
5434
  force: true
5294
5435
  });
5295
5436
  await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
5296
5437
  await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
5297
- await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext)), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
5298
- const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext));
5438
+ await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
5439
+ reuse: plan.contentReuse,
5440
+ changed: plan.contentChanged
5441
+ })), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
5442
+ const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
5299
5443
  await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
5300
5444
  await runOutputs(phaseContext);
5301
5445
  await withPhase(phaseContext, "root-index", async () => {
@@ -5348,10 +5492,12 @@ const defaultConfig$2 = {
5348
5492
  function createApi$3(ctx) {
5349
5493
  return {
5350
5494
  /**
5351
- * Run the full SSG pipeline and write the site to disk.
5495
+ * Run the full SSG pipeline and write the site to disk. With no options a full
5496
+ * production build runs; dev callers pass `skipClean`/`overrides`/`changed` for a
5497
+ * fast incremental rebuild (all gated behind opt-in fields — the default path is
5498
+ * unchanged).
5352
5499
  *
5353
- * @param options - Optional run overrides.
5354
- * @param options.outDir - Override the configured output directory for this run.
5500
+ * @param options - Optional per-run overrides (outDir / skipClean / overrides / changed).
5355
5501
  * @returns The build result (outDir, pageCount, durationMs).
5356
5502
  * @example
5357
5503
  * ```ts
@@ -5444,8 +5590,8 @@ function createEvents(register) {
5444
5590
  /**
5445
5591
  * Creates initial `build` plugin state: a frozen config snapshot plus empty
5446
5592
  * per-run caches (`manifest`, `buildCache`, `runId`) and the cross-run OG
5447
- * content-hash cache. Holds caches and config only — no domain data is
5448
- * duplicated here (pulled fresh via `ctx.require` each run).
5593
+ * content-hash + page-render caches. Holds caches and config only — no domain data
5594
+ * is duplicated here (pulled fresh via `ctx.require` each run).
5449
5595
  *
5450
5596
  * @param ctx - Minimal context with global and config.
5451
5597
  * @param ctx.global - Global plugin registry (unused; caches are config-driven).
@@ -5462,7 +5608,8 @@ function createState$3(ctx) {
5462
5608
  manifest: null,
5463
5609
  buildCache: /* @__PURE__ */ new Map(),
5464
5610
  runId: null,
5465
- ogImageHashCache: /* @__PURE__ */ new Map()
5611
+ ogImageHashCache: /* @__PURE__ */ new Map(),
5612
+ renderCache: /* @__PURE__ */ new Map()
5466
5613
  };
5467
5614
  }
5468
5615
  //#endregion
@@ -5686,12 +5833,37 @@ function buildWranglerArgs(input) {
5686
5833
  branch
5687
5834
  ];
5688
5835
  }
5836
+ /**
5837
+ * Assemble the argv for `wrangler pages project create` (no shell). Guards the
5838
+ * production branch against flag injection; the slug is already a safe `toSlug` output.
5839
+ *
5840
+ * @param input - The resolved project-create inputs.
5841
+ * @param input.slug - Cloudflare project-name slug (`toSlug(site.name())`).
5842
+ * @param input.branch - Production branch (guarded by `/^[a-zA-Z0-9/_.-]+$/`).
5843
+ * @returns The wrangler argv array.
5844
+ * @throws {Error} `ERR_DEPLOY_INVALID_BRANCH` when the branch fails the guard.
5845
+ * @example
5846
+ * buildProjectCreateArgs({ slug: "my-site", branch: "main" });
5847
+ */
5848
+ function buildProjectCreateArgs(input) {
5849
+ const branch = guardBranch(input.branch);
5850
+ return [
5851
+ "bunx",
5852
+ "wrangler",
5853
+ "pages",
5854
+ "project",
5855
+ "create",
5856
+ input.slug,
5857
+ "--production-branch",
5858
+ branch
5859
+ ];
5860
+ }
5689
5861
  /** Lowercased substring matchers for the wrangler error taxonomy. */
5690
5862
  const ERROR_SIGNATURES = [
5691
5863
  {
5692
5864
  match: ["could not find project", "project not found"],
5693
5865
  kind: "ERR_DEPLOY_PROJECT_NOT_FOUND",
5694
- advice: "The Cloudflare Pages project does not exist. Run `app.deploy.init()` or create it in the dashboard, then retry."
5866
+ advice: "The Cloudflare Pages project does not exist yet. Create it in the dashboard (Workers & Pages → Create → Pages) or with `bunx wrangler pages project create <name>`, then retry. (app.deploy.init() only scaffolds local config — it does not create the remote project.)"
5695
5867
  },
5696
5868
  {
5697
5869
  match: [
@@ -6270,15 +6442,16 @@ function validateConfig$1(ctx) {
6270
6442
  * Run wrangler for the prepared argv and surface its scrubbed result, translating
6271
6443
  * a non-zero exit into the classified deploy error. The API token is read from env
6272
6444
  * here so it never crosses a logging boundary; only scrubbed output is returned.
6445
+ * Shared by `run()` (deploy) and `createProject()` (project create).
6273
6446
  *
6274
6447
  * @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
6275
6448
  * @param args - The fully-built, pre-validated wrangler argv.
6276
6449
  * @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
6277
6450
  * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6278
6451
  * @example
6279
- * const { stdout, scrubbedStderr } = await executeDeploy(ctx, args);
6452
+ * const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6280
6453
  */
6281
- async function executeDeploy(ctx, args) {
6454
+ async function executeWrangler(ctx, args) {
6282
6455
  const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
6283
6456
  const { stdout, scrubbedStderr, exitCode } = await runWrangler({
6284
6457
  spawn: ctx.state.spawn,
@@ -6350,7 +6523,7 @@ function createApi$2(ctx) {
6350
6523
  root
6351
6524
  });
6352
6525
  const start = Date.now();
6353
- const { stdout, scrubbedStderr } = await executeDeploy(ctx, args);
6526
+ const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6354
6527
  ctx.log.info(scrubbedStderr);
6355
6528
  const result = buildDeployResult(stdout, branch, start);
6356
6529
  ctx.state.lastDeployment = result;
@@ -6389,6 +6562,38 @@ function createApi$2(ctx) {
6389
6562
  cwd: process.cwd(),
6390
6563
  options
6391
6564
  });
6565
+ },
6566
+ /**
6567
+ * The Cloudflare Pages project name this app deploys to (`toSlug(site.name())`).
6568
+ *
6569
+ * @returns The project-name slug.
6570
+ * @example
6571
+ * api.projectName(); // "my-site"
6572
+ */
6573
+ projectName() {
6574
+ return toSlug(ctx.require(sitePlugin).name());
6575
+ },
6576
+ /**
6577
+ * Create the remote Cloudflare Pages project via wrangler, so a first deploy has a
6578
+ * target. Derives the slug from `site.name()` and the production branch from config.
6579
+ *
6580
+ * @returns The created project name + production branch.
6581
+ * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6582
+ * @example
6583
+ * await api.createProject(); // { name: "my-site", branch: "main" }
6584
+ */
6585
+ async createProject() {
6586
+ const name = toSlug(ctx.require(sitePlugin).name());
6587
+ const branch = ctx.config.productionBranch ?? "main";
6588
+ const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
6589
+ slug: name,
6590
+ branch
6591
+ }));
6592
+ ctx.log.info(scrubbedStderr);
6593
+ return {
6594
+ name,
6595
+ branch
6596
+ };
6392
6597
  }
6393
6598
  };
6394
6599
  }
@@ -6522,21 +6727,56 @@ const ACCOUNT_HELP = [
6522
6727
  "right-hand sidebar (also in the dashboard URL). Then make it available:",
6523
6728
  " export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
6524
6729
  ].join("\n");
6730
+ /** Shown when a credential is in the raw environment but the app's env providers did not resolve it. */
6731
+ const PROVIDERS_HELP = [
6732
+ "Found in your shell/.env but the app's env plugin did not resolve it — its providers",
6733
+ "are not wired. Add the Node providers in createApp so the deploy can read it:",
6734
+ " pluginConfigs.env = { providers: [processEnv(), dotenv()] } (import them from @moku-labs/web)."
6735
+ ].join("\n");
6525
6736
  /** The GitHub repo secrets the generated workflow consumes. */
6526
6737
  const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
6527
6738
  /**
6739
+ * Build one credential prerequisite by reading the SAME source the deploy reads — the
6740
+ * resolved `ctx.env` table — so a ✓ guarantees `ctx.env.require(key)` will succeed. When
6741
+ * the value is present in the raw `process.env` but unresolved by the app's providers
6742
+ * (the silent "deploy can't see it" trap a bare `process.env` check would mark green),
6743
+ * the fix hint points at wiring the providers instead of re-adding the value.
6744
+ *
6745
+ * @param ctx - The cli plugin context (provides the resolved `ctx.env`).
6746
+ * @param key - The credential variable name.
6747
+ * @param label - The diagnostic line label.
6748
+ * @param missingHelp - The fix hint when the credential is genuinely absent everywhere.
6749
+ * @returns The credential prerequisite check.
6750
+ * @example
6751
+ * credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP);
6752
+ */
6753
+ function credentialPrereq(ctx, key, label, missingHelp) {
6754
+ if ((ctx.env.get(key) ?? "") !== "") return {
6755
+ ok: true,
6756
+ label,
6757
+ detail: void 0,
6758
+ scaffoldable: false
6759
+ };
6760
+ return {
6761
+ ok: false,
6762
+ label,
6763
+ detail: (process.env[key] ?? "") !== "" ? PROVIDERS_HELP : missingHelp,
6764
+ scaffoldable: false
6765
+ };
6766
+ }
6767
+ /**
6528
6768
  * Evaluate the three deploy prerequisites against the current project: the Cloudflare
6529
- * wrangler config exists, and both Cloudflare credentials are present in the environment.
6769
+ * wrangler config exists, and both Cloudflare credentials resolve through `ctx.env` (the
6770
+ * deploy's own source of truth — not a bare `process.env` read that can diverge from it).
6530
6771
  *
6772
+ * @param ctx - The cli plugin context (provides the resolved `ctx.env`).
6531
6773
  * @param cwd - The project root (where `wrangler.jsonc` lives).
6532
6774
  * @returns The ordered prerequisite checks.
6533
6775
  * @example
6534
- * const prereqs = diagnose(process.cwd());
6776
+ * const prereqs = diagnose(ctx, process.cwd());
6535
6777
  */
6536
- function diagnose(cwd) {
6778
+ function diagnose(ctx, cwd) {
6537
6779
  const wranglerOk = (0, node_fs.existsSync)(node_path$1.default.join(cwd, "wrangler.jsonc"));
6538
- const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
6539
- const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
6540
6780
  return [
6541
6781
  {
6542
6782
  ok: wranglerOk,
@@ -6544,18 +6784,8 @@ function diagnose(cwd) {
6544
6784
  detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
6545
6785
  scaffoldable: true
6546
6786
  },
6547
- {
6548
- ok: tokenOk,
6549
- label: "CLOUDFLARE_API_TOKEN is set",
6550
- detail: tokenOk ? void 0 : TOKEN_HELP,
6551
- scaffoldable: false
6552
- },
6553
- {
6554
- ok: accountOk,
6555
- label: "CLOUDFLARE_ACCOUNT_ID is set",
6556
- detail: accountOk ? void 0 : ACCOUNT_HELP,
6557
- scaffoldable: false
6558
- }
6787
+ credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP),
6788
+ credentialPrereq(ctx, "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_ACCOUNT_ID is set", ACCOUNT_HELP)
6559
6789
  ];
6560
6790
  }
6561
6791
  /**
@@ -6653,8 +6883,139 @@ async function offerWorkflowSetup(ctx) {
6653
6883
  ctx.state.render.info(SECRETS_HELP);
6654
6884
  }
6655
6885
  /**
6656
- * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
6657
- * report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
6886
+ * Read the taxonomy `code` off a thrown value, when present. Deploy errors carry a
6887
+ * `code` (e.g. `ERR_DEPLOY_PROJECT_NOT_FOUND`) so the wizard can tailor the fix hint.
6888
+ *
6889
+ * @param error - The thrown value.
6890
+ * @returns The `code` string, or `undefined` when absent.
6891
+ * @example
6892
+ * codeOf(deployError("ERR_DEPLOY_AUTH", "…")); // "ERR_DEPLOY_AUTH"
6893
+ */
6894
+ function codeOf(error) {
6895
+ if (typeof error === "object" && error !== null && "code" in error) {
6896
+ const { code } = error;
6897
+ return typeof code === "string" ? code : void 0;
6898
+ }
6899
+ }
6900
+ /**
6901
+ * A copy-pasteable "create the project yourself" hint, shown when the user declines the
6902
+ * offer to auto-create. Spells out that the remote project is what's missing (init only
6903
+ * scaffolds local config).
6904
+ *
6905
+ * @param name - The Cloudflare Pages project name (the deploy slug).
6906
+ * @returns The multi-line hint (newline-separated; rendered indented under a `›`).
6907
+ * @example
6908
+ * ctx.state.render.info(projectNotFoundHint("my-site"));
6909
+ */
6910
+ function projectNotFoundHint(name) {
6911
+ return [
6912
+ "how to fix: the Cloudflare Pages project does not exist yet — create it once, then",
6913
+ "re-run `bun run deploy`. (app.deploy.init() only scaffolds local config; it does not",
6914
+ "create the remote project.)",
6915
+ ` • CLI: bunx wrangler pages project create ${name} --production-branch main`,
6916
+ " • Dashboard: Cloudflare → Workers & Pages → Create → Pages"
6917
+ ].join("\n");
6918
+ }
6919
+ /**
6920
+ * An actionable, error-specific "how to fix" hint for a failed deploy (other than the
6921
+ * project-not-found case, which the wizard handles interactively), so the user never
6922
+ * lands on a raw stack trace.
6923
+ *
6924
+ * @param error - The thrown deploy error.
6925
+ * @returns The fix hint line.
6926
+ * @example
6927
+ * ctx.state.render.info(deployFailureHint(err));
6928
+ */
6929
+ function deployFailureHint$1(error) {
6930
+ const code = codeOf(error);
6931
+ if (code === "ERR_DEPLOY_AUTH" || code === "ERR_DEPLOY_AUTH_EXPIRED") return "how to fix: refresh CLOUDFLARE_API_TOKEN (scope: Account › Cloudflare Pages › Edit), then re-run `bun run deploy`.";
6932
+ if (code === "ERR_DEPLOY_NETWORK") return "how to fix: a network error reached Cloudflare — check connectivity, then re-run `bun run deploy`.";
6933
+ return "how to fix: resolve the error above, then re-run `bun run deploy`.";
6934
+ }
6935
+ /**
6936
+ * Render a styled deploy failure (✗ + fix hint) and return the `"failed"` outcome, so a
6937
+ * caught error surfaces consistently instead of as a raw throw.
6938
+ *
6939
+ * @param ctx - The cli plugin context.
6940
+ * @param error - The thrown deploy error.
6941
+ * @returns The `"failed"` deploy outcome.
6942
+ * @example
6943
+ * return renderFailure(ctx, error);
6944
+ */
6945
+ function renderFailure(ctx, error) {
6946
+ ctx.state.render.error("deploy failed", error);
6947
+ ctx.state.render.info(deployFailureHint$1(error));
6948
+ return {
6949
+ deployed: false,
6950
+ reason: "failed"
6951
+ };
6952
+ }
6953
+ /**
6954
+ * Deploy once via the deploy plugin and wrap the result as a successful outcome. Throws
6955
+ * the classified deploy error on failure (the caller decides how to surface it).
6956
+ *
6957
+ * @param ctx - The cli plugin context.
6958
+ * @param options - The deploy options (branch override).
6959
+ * @returns The successful deploy outcome.
6960
+ * @throws {Error} With a `code` from the deploy error taxonomy on any failure.
6961
+ * @example
6962
+ * const outcome = await deployOnce(ctx, { branch: "main" });
6963
+ */
6964
+ async function deployOnce(ctx, options) {
6965
+ return {
6966
+ deployed: true,
6967
+ ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6968
+ };
6969
+ }
6970
+ /**
6971
+ * Handle a project-not-found deploy failure interactively: ask (a confirmation step)
6972
+ * before creating a real Cloudflare resource, create the Pages project via the deploy
6973
+ * plugin, then retry the deploy once. A declined offer (or a create failure) returns the
6974
+ * `"failed"` outcome with an actionable hint — never a raw stack trace.
6975
+ *
6976
+ * @param ctx - The cli plugin context.
6977
+ * @param options - The deploy options (branch override).
6978
+ * @param originalError - The project-not-found error from the first attempt.
6979
+ * @returns The deploy outcome (deployed after a successful create + retry, else failed).
6980
+ * @example
6981
+ * return createProjectThenRetry(ctx, options, error);
6982
+ */
6983
+ async function createProjectThenRetry(ctx, options, originalError) {
6984
+ const deploy = ctx.require(deployPlugin);
6985
+ const name = deploy.projectName();
6986
+ ctx.state.render.warn(`The Cloudflare Pages project "${name}" does not exist yet.`);
6987
+ if (!await ctx.state.confirm(`Create the Cloudflare Pages project "${name}" now?`)) {
6988
+ ctx.state.render.error("deploy failed", originalError);
6989
+ ctx.state.render.info(projectNotFoundHint(name));
6990
+ return {
6991
+ deployed: false,
6992
+ reason: "failed"
6993
+ };
6994
+ }
6995
+ try {
6996
+ const created = await deploy.createProject();
6997
+ ctx.state.render.check(true, `created Cloudflare Pages project "${created.name}"`);
6998
+ } catch (error) {
6999
+ ctx.state.render.error("could not create the Pages project", error);
7000
+ ctx.state.render.info(deployFailureHint$1(error));
7001
+ return {
7002
+ deployed: false,
7003
+ reason: "failed"
7004
+ };
7005
+ }
7006
+ ctx.state.render.info("project created — retrying the deploy…");
7007
+ try {
7008
+ return await deployOnce(ctx, options);
7009
+ } catch (error) {
7010
+ return renderFailure(ctx, error);
7011
+ }
7012
+ }
7013
+ /**
7014
+ * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin. A
7015
+ * declined confirm returns `{ deployed: false, reason: "declined" }`. A project-not-found
7016
+ * failure offers to create the project (with a confirmation step) and retries; any other
7017
+ * runtime failure is surfaced as a styled error + fix hint, returning
7018
+ * `{ deployed: false, reason: "failed" }` — never a raw stack trace.
6658
7019
  *
6659
7020
  * @param ctx - The cli plugin context.
6660
7021
  * @param options - The deploy options (branch override + `yes`).
@@ -6671,10 +7032,12 @@ async function runDeployStep(ctx, options) {
6671
7032
  reason: "declined"
6672
7033
  };
6673
7034
  }
6674
- return {
6675
- deployed: true,
6676
- ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6677
- };
7035
+ try {
7036
+ return await deployOnce(ctx, options);
7037
+ } catch (error) {
7038
+ if (codeOf(error) === "ERR_DEPLOY_PROJECT_NOT_FOUND") return createProjectThenRetry(ctx, options, error);
7039
+ return renderFailure(ctx, error);
7040
+ }
6678
7041
  }
6679
7042
  /**
6680
7043
  * Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
@@ -6692,10 +7055,10 @@ async function runDeployStep(ctx, options) {
6692
7055
  async function runDeployWizard(ctx, options) {
6693
7056
  const cwd = process.cwd();
6694
7057
  ctx.state.render.heading("Checking prerequisites");
6695
- for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
6696
- await offerScaffold(ctx, diagnose(cwd));
7058
+ for (const item of diagnose(ctx, cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
7059
+ await offerScaffold(ctx, diagnose(ctx, cwd));
6697
7060
  await offerEnvScaffold(ctx, cwd);
6698
- const blockers = diagnose(cwd).filter((item) => !item.ok);
7061
+ const blockers = diagnose(ctx, cwd).filter((item) => !item.ok);
6699
7062
  if (blockers.length > 0) {
6700
7063
  ctx.state.render.heading("Not ready to deploy");
6701
7064
  for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
@@ -6712,7 +7075,7 @@ async function runDeployWizard(ctx, options) {
6712
7075
  ctx.state.render.check(notFoundOk, `${ctx.config.notFoundFile} present`, notFoundOk ? void 0 : "Set build.notFound so the SSG emits it (CF Pages else flips to SPA mode).");
6713
7076
  ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
6714
7077
  const outcome = await runDeployStep(ctx, options);
6715
- await offerWorkflowSetup(ctx);
7078
+ if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx);
6716
7079
  return outcome;
6717
7080
  }
6718
7081
  //#endregion
@@ -6752,25 +7115,26 @@ function injectReloadClient(html) {
6752
7115
  * Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
6753
7116
  * routes success to `onReloaded` and failure to `onError`.
6754
7117
  *
6755
- * @param input - The rebuild dependencies + the changed file.
6756
- * @param input.runBuild - Runs one build and resolves with its summary.
7118
+ * @param input - The rebuild dependencies + the changed file/paths.
7119
+ * @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
6757
7120
  * @param input.onRebuildStart - Called with the changed file just before the build runs.
6758
- * @param input.onReloaded - Called with the changed file + summary after a rebuild.
7121
+ * @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
6759
7122
  * @param input.onError - Called when a rebuild throws.
6760
7123
  * @param input.file - The changed file to report alongside the summary.
7124
+ * @param input.changed - The accumulated changed paths handed to `runBuild` (incremental).
6761
7125
  * @returns Resolves once the rebuild settles (always — errors are routed, not thrown).
6762
7126
  * @example
6763
- * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
7127
+ * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md", changed: ["a.md"] });
6764
7128
  */
6765
7129
  async function runOneRebuild(input) {
6766
7130
  input.onRebuildStart?.(input.file);
6767
7131
  try {
6768
- const summary = await input.runBuild();
7132
+ const summary = await input.runBuild(input.changed);
6769
7133
  input.onReloaded({
6770
7134
  file: input.file,
6771
7135
  pageCount: summary.pageCount,
6772
7136
  durationMs: summary.durationMs
6773
- });
7137
+ }, input.changed);
6774
7138
  } catch (error) {
6775
7139
  input.onError(error);
6776
7140
  }
@@ -6784,9 +7148,9 @@ async function runOneRebuild(input) {
6784
7148
  *
6785
7149
  * @param input - The rebuild dependencies.
6786
7150
  * @param input.debounceMs - Debounce window in milliseconds.
6787
- * @param input.runBuild - Runs one build and resolves with its summary.
7151
+ * @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
6788
7152
  * @param input.onRebuildStart - Called with the changed file just before each build runs.
6789
- * @param input.onReloaded - Called with the changed file + summary after a rebuild.
7153
+ * @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
6790
7154
  * @param input.onError - Called when a rebuild throws.
6791
7155
  * @returns The debounced rebuild driver.
6792
7156
  * @example
@@ -6795,12 +7159,13 @@ async function runOneRebuild(input) {
6795
7159
  function createRebuilder(input) {
6796
7160
  let timer;
6797
7161
  let pendingFile = "";
7162
+ const pendingChanged = /* @__PURE__ */ new Set();
6798
7163
  let building = false;
6799
7164
  let dirty = false;
6800
7165
  /**
6801
- * Rebuild repeatedly until no change arrived mid-flight: each pass clears `dirty`,
6802
- * runs one build, then loops again if a `schedule()` set `dirty` while it ran, so
6803
- * no change is dropped.
7166
+ * Rebuild repeatedly until no change arrived mid-flight: each pass snapshots + clears
7167
+ * the accumulated changed paths, runs one build over them, then loops again if a
7168
+ * `schedule()` set `dirty` (and added more paths) while it ran, so no change is dropped.
6804
7169
  *
6805
7170
  * @returns Resolves once a pass completes with no pending change (errors are routed,
6806
7171
  * never thrown).
@@ -6810,12 +7175,15 @@ function createRebuilder(input) {
6810
7175
  const drainPendingRebuilds = async () => {
6811
7176
  do {
6812
7177
  dirty = false;
7178
+ const changed = [...pendingChanged];
7179
+ pendingChanged.clear();
6813
7180
  await runOneRebuild({
6814
7181
  runBuild: input.runBuild,
6815
7182
  ...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
6816
7183
  onReloaded: input.onReloaded,
6817
7184
  onError: input.onError,
6818
- file: pendingFile
7185
+ file: pendingFile,
7186
+ changed
6819
7187
  });
6820
7188
  } while (dirty);
6821
7189
  };
@@ -6842,14 +7210,15 @@ function createRebuilder(input) {
6842
7210
  };
6843
7211
  return {
6844
7212
  /**
6845
- * Queue a rebuild for the given label (debounced + coalesced).
7213
+ * Queue a rebuild for the given changed path (debounced + coalesced + accumulated).
6846
7214
  *
6847
- * @param file - The label reported as `ReloadInfo.file` the watched directory in `serve()`.
7215
+ * @param file - The changed path reported as `ReloadInfo.file` and added to the changed set.
6848
7216
  * @example
6849
- * rebuilder.schedule("content");
7217
+ * rebuilder.schedule("content/intro/en.md");
6850
7218
  */
6851
7219
  schedule(file) {
6852
7220
  pendingFile = file;
7221
+ pendingChanged.add(file);
6853
7222
  if (timer) clearTimeout(timer);
6854
7223
  timer = setTimeout(fire, input.debounceMs);
6855
7224
  },
@@ -6879,26 +7248,33 @@ function isNoisePath(filename) {
6879
7248
  return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
6880
7249
  }
6881
7250
  /**
6882
- * Create a {@link ChangeGate} that drops three kinds of spurious change events before
6883
- * they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
6884
- * `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
6885
- * echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
6886
- * a change whose file mtime is at or before the last build we started was already
6887
- * captured (or is a late echo), so it is ignored while a genuinely newer edit (even
6888
- * one made mid-build) and a deletion (missing file) always pass. The single timestamp
6889
- * also means no per-path map grows over a long session.
7251
+ * Create a {@link ChangeGate} that drops four kinds of spurious change events before they
7252
+ * reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
7253
+ * `outDir` (the build's own output — a loop guard), the stale duplicate/parent-dir echoes
7254
+ * macOS fires for one save (a build-start high-water mark — a change whose mtime is at or
7255
+ * before the last build we started was already captured), and when a `fileHash` seam is
7256
+ * supplied a NO-OP save whose bytes are identical to the last successfully-built version
7257
+ * (a double Ctrl-S, a `touch`, a format-on-save that reverts). The no-op baseline is
7258
+ * recorded ONLY by {@link ChangeGate.commitBuilt} on build SUCCESS, scoped to that build's
7259
+ * paths — so a failed build commits nothing (a retry save always rebuilds) and a file
7260
+ * edited mid-build is never falsely baselined by another file's success. A genuinely newer
7261
+ * edit (even mid-build) and a deletion (missing file) always pass.
6890
7262
  *
6891
7263
  * @param input - The gate dependencies.
6892
7264
  * @param input.outDir - The build output directory whose writes must never re-trigger a build.
6893
7265
  * @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
6894
7266
  * @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
7267
+ * @param input.fileHash - Resolves a path's content hash (or `null` when missing). Optional;
7268
+ * defaults to `() => null`, which disables the no-op-save short-circuit (every edit passes).
6895
7269
  * @returns The change gate.
6896
7270
  * @example
6897
- * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
7271
+ * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock, fileHash: state.fileHash });
6898
7272
  */
6899
7273
  function createChangeGate(input) {
6900
7274
  const outDirAbs = node_path$1.default.resolve(input.outDir);
7275
+ const fileHash = input.fileHash ?? (() => null);
6901
7276
  let lastBuildStartedAt = input.now();
7277
+ const committedHash = /* @__PURE__ */ new Map();
6902
7278
  return {
6903
7279
  /**
6904
7280
  * Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
@@ -6916,6 +7292,12 @@ function createChangeGate(input) {
6916
7292
  if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${node_path$1.default.sep}`)) return false;
6917
7293
  const mtime = input.fileMtime(changed);
6918
7294
  if (mtime !== null && mtime < lastBuildStartedAt) return false;
7295
+ const hash = fileHash(changed);
7296
+ if (hash === null) {
7297
+ committedHash.delete(changed);
7298
+ return true;
7299
+ }
7300
+ if (committedHash.get(changed) === hash) return false;
6919
7301
  return true;
6920
7302
  },
6921
7303
  /**
@@ -6926,6 +7308,20 @@ function createChangeGate(input) {
6926
7308
  */
6927
7309
  markBuildStart() {
6928
7310
  lastBuildStartedAt = input.now();
7311
+ },
7312
+ /**
7313
+ * Baseline exactly the paths the just-succeeded build consumed (see {@link ChangeGate.commitBuilt}).
7314
+ *
7315
+ * @param changed - The paths the just-succeeded build consumed.
7316
+ * @example
7317
+ * gate.commitBuilt(["content/intro/en.md"]);
7318
+ */
7319
+ commitBuilt(changed) {
7320
+ for (const file of changed) {
7321
+ const key = node_path$1.default.resolve(file);
7322
+ const hash = fileHash(key);
7323
+ if (hash !== null) committedHash.set(key, hash);
7324
+ }
6929
7325
  }
6930
7326
  };
6931
7327
  }
@@ -7118,19 +7514,46 @@ function createDevHandler(ctx, hub) {
7118
7514
  };
7119
7515
  }
7120
7516
  /**
7517
+ * Build the per-run {@link BuildRunOverrides} for a dev build from the session feature
7518
+ * opt-ins: minification is always off in dev (no benefit, slower), and each expensive
7519
+ * output stays off unless its flag re-enables it (`ogImage: false` disables OG generation
7520
+ * regardless of the persisted config). The persisted plugin config is never mutated — the
7521
+ * overrides apply to the dev run only.
7522
+ *
7523
+ * @param features - The resolved per-session dev feature opt-ins.
7524
+ * @returns The config overrides merged into the dev build run.
7525
+ * @example
7526
+ * devBuildOverrides({ og: false, sitemap: false, feeds: false, localeRedirects: false });
7527
+ */
7528
+ function devBuildOverrides(features) {
7529
+ return {
7530
+ minify: false,
7531
+ ...features.feeds ? {} : { feeds: false },
7532
+ ...features.sitemap ? {} : { sitemap: false },
7533
+ ...features.og ? {} : { ogImage: false },
7534
+ ...features.localeRedirects ? {} : { localeRedirects: false }
7535
+ };
7536
+ }
7537
+ /**
7121
7538
  * Run the dev loop: an initial build, an in-process static server that injects the
7122
7539
  * live-reload client, a recursive watcher over `config.watchDirs`, and a debounced
7123
7540
  * rebuild that re-renders and pushes a browser reload. Resolves on SIGINT/SIGTERM,
7124
- * which stops the server, closes the watchers, and cancels any pending rebuild.
7541
+ * which stops the server, closes the watchers, and cancels any pending rebuild. The dev
7542
+ * build disables minification + expensive outputs (per {@link devBuildOverrides}); every
7543
+ * rebuild also skips the clean so caches + unchanged assets survive (no mid-rebuild 404).
7544
+ * Because rebuilds skip the clean, a DELETED or renamed content slug's stale page lingers
7545
+ * (and is served) until you restart `serve` or run a production `build`.
7125
7546
  *
7126
7547
  * @param ctx - The cli plugin context (config, state seams, `require`).
7127
7548
  * @param port - The port to bind the dev server to.
7549
+ * @param features - Per-session dev feature opt-ins (`og`/`sitemap`/`feeds`/`localeRedirects`).
7128
7550
  * @returns Resolves once the server has been torn down by a termination signal.
7129
7551
  * @example
7130
- * await runDevServer(ctx, 4173);
7552
+ * await runDevServer(ctx, 4173, { og: false, sitemap: false, feeds: false, localeRedirects: false });
7131
7553
  */
7132
- async function runDevServer(ctx, port) {
7133
- await ctx.require(buildPlugin).run();
7554
+ async function runDevServer(ctx, port, features) {
7555
+ const overrides = devBuildOverrides(features);
7556
+ await ctx.require(buildPlugin).run({ overrides });
7134
7557
  const hub = createReloadHub();
7135
7558
  const server = ctx.state.serveStatic({
7136
7559
  port,
@@ -7140,19 +7563,27 @@ async function runDevServer(ctx, port) {
7140
7563
  const gate = createChangeGate({
7141
7564
  outDir: ctx.config.outDir,
7142
7565
  fileMtime: ctx.state.fileMtime,
7143
- now: ctx.state.clock
7566
+ now: ctx.state.clock,
7567
+ fileHash: ctx.state.fileHash
7144
7568
  });
7145
7569
  const rebuilder = createRebuilder({
7146
7570
  debounceMs: ctx.config.debounceMs,
7147
7571
  /**
7148
- * Re-run the SSG build for a rebuild.
7572
+ * Re-run the SSG build for a rebuild: skip the clean so the prior assets + on-disk
7573
+ * caches survive (and no in-flight request hits an empty outDir), with the dev
7574
+ * overrides applied.
7149
7575
  *
7576
+ * @param changed - The paths changed since the last build (incremental rebuild hint).
7150
7577
  * @returns The rebuild summary.
7151
7578
  * @example
7152
- * await runBuild();
7579
+ * await runBuild(["content/intro/en.md"]);
7153
7580
  */
7154
- runBuild() {
7155
- return ctx.require(buildPlugin).run();
7581
+ runBuild(changed) {
7582
+ return ctx.require(buildPlugin).run({
7583
+ skipClean: true,
7584
+ overrides,
7585
+ changed
7586
+ });
7156
7587
  },
7157
7588
  /**
7158
7589
  * Show the compact in-place "rebuilding {label}" line before the build runs.
@@ -7169,15 +7600,18 @@ async function runDevServer(ctx, port) {
7169
7600
  * Render the reload line and push a browser reload after a rebuild.
7170
7601
  *
7171
7602
  * @param info - The changed file plus the rebuild's page count and duration.
7603
+ * @param changed - The paths this successful build consumed (baselined for no-op drops).
7172
7604
  * @example
7173
- * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 });
7605
+ * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 }, ["content/a.md"]);
7174
7606
  */
7175
- onReloaded(info) {
7607
+ onReloaded(info, changed) {
7608
+ gate.commitBuilt(changed);
7176
7609
  ctx.state.render.reload(info);
7177
7610
  hub.reloadAll();
7178
7611
  },
7179
7612
  /**
7180
- * Render a rebuild failure (the dev loop keeps running).
7613
+ * Render a rebuild failure (the dev loop keeps running). A failed build baselines
7614
+ * nothing (commitBuilt only runs on success), so an identical retry save still rebuilds.
7181
7615
  *
7182
7616
  * @param error - The thrown rebuild error.
7183
7617
  * @example
@@ -7188,7 +7622,8 @@ async function runDevServer(ctx, port) {
7188
7622
  }
7189
7623
  });
7190
7624
  const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
7191
- if (gate.accept(dir, filename)) rebuilder.schedule(dir);
7625
+ if (!gate.accept(dir, filename)) return;
7626
+ rebuilder.schedule(filename === void 0 ? dir : node_path$1.default.join(dir, filename));
7192
7627
  }));
7193
7628
  ctx.state.render.serverReady({
7194
7629
  local: `http://localhost:${port}`,
@@ -7455,17 +7890,25 @@ function createApi$1(ctx) {
7455
7890
  },
7456
7891
  /**
7457
7892
  * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
7458
- * `watchDirs`, debounced rebuild + reload. Resolves on SIGINT/SIGTERM.
7893
+ * `watchDirs`, debounced + incremental rebuild + reload. For a fast rebuild the dev
7894
+ * build disables minification + expensive, preview-irrelevant outputs (feeds /
7895
+ * sitemap / og-images / locale-redirects); pass `og`/`sitemap`/`feeds`/
7896
+ * `localeRedirects` to re-enable any of them for the session. Resolves on SIGINT/SIGTERM.
7459
7897
  *
7460
- * @param options - Optional port override (defaults to `config.port`).
7898
+ * @param options - Optional port override + per-session dev feature opt-ins.
7461
7899
  * @returns Resolves once the server has been torn down.
7462
7900
  * @example
7463
- * await api.serve({ port: 3000 });
7901
+ * await api.serve({ port: 3000, og: true });
7464
7902
  */
7465
7903
  serve(options = {}) {
7466
7904
  const { port = ctx.config.port } = options;
7467
7905
  ctx.state.render.header("serve");
7468
- return runDevServer(ctx, port);
7906
+ return runDevServer(ctx, port, {
7907
+ og: options.og ?? false,
7908
+ sitemap: options.sitemap ?? false,
7909
+ feeds: options.feeds ?? false,
7910
+ localeRedirects: options.localeRedirects ?? false
7911
+ });
7469
7912
  },
7470
7913
  /**
7471
7914
  * Static preview of the built `dist/` with CF-Pages clean-URL resolution.
@@ -8548,6 +8991,23 @@ function defaultFileMtime(filePath) {
8548
8991
  }
8549
8992
  }
8550
8993
  /**
8994
+ * Default file-content-hash probe — `sha256` of the file bytes, returning `null` for a
8995
+ * missing/unreadable path. serve()'s change gate compares this against the last
8996
+ * successfully-built bytes to drop a no-op save (a byte-identical double Ctrl-S).
8997
+ *
8998
+ * @param filePath - The absolute path to hash.
8999
+ * @returns The hex SHA-256 of the file's bytes, or `null` when it cannot be read.
9000
+ * @example
9001
+ * const hash = defaultFileHash("/abs/content/a.md");
9002
+ */
9003
+ function defaultFileHash(filePath) {
9004
+ try {
9005
+ return (0, node_crypto.createHash)("sha256").update((0, node_fs.readFileSync)(filePath)).digest("hex");
9006
+ } catch {
9007
+ return null;
9008
+ }
9009
+ }
9010
+ /**
8551
9011
  * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
8552
9012
  * reads `node:os` interfaces while tests can inject a deterministic value.
8553
9013
  *
@@ -8680,7 +9140,8 @@ function createState$1(_ctx) {
8680
9140
  serveStatic: defaultServeStatic,
8681
9141
  fileResponse: defaultFileResponse,
8682
9142
  networkUrl: defaultNetworkUrl,
8683
- fileMtime: defaultFileMtime
9143
+ fileMtime: defaultFileMtime,
9144
+ fileHash: defaultFileHash
8684
9145
  };
8685
9146
  }
8686
9147
  //#endregion