@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.mjs CHANGED
@@ -1282,18 +1282,24 @@ function isPublished(article, isProduction) {
1282
1282
  * locale collection: existing files only, drafts dropped in production, sorted
1283
1283
  * date-descending. The single load+filter+sort step behind {@link createContentApi.loadAll}.
1284
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
+ *
1285
1290
  * @param ctx - Kernel-free domain context (provider + i18n helpers + stage).
1286
1291
  * @param slugs - Every known article slug from the provider.
1287
1292
  * @param locale - The locale to resolve and collect.
1293
+ * @param cached - Optional per-locale article cache to reuse for unchanged slugs.
1288
1294
  * @returns The published (date-descending) articles for this locale.
1289
1295
  * @example
1290
1296
  * ```ts
1291
1297
  * const present = await loadAndFilterArticles(ctx, slugs, "en");
1292
1298
  * ```
1293
1299
  */
1294
- async function loadAndFilterArticles(ctx, slugs, locale) {
1300
+ async function loadAndFilterArticles(ctx, slugs, locale, cached) {
1295
1301
  const isProduction = ctx.global.stage === "production";
1296
- 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);
1297
1303
  }
1298
1304
  /**
1299
1305
  * Derive the article slug from a source file path — the parent directory name
@@ -1330,19 +1336,26 @@ function createContentApi(ctx) {
1330
1336
  * Load every article across every active locale (locale fallback, production
1331
1337
  * draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
1332
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.
1333
1345
  * @returns A locale-keyed map of date-descending articles.
1334
1346
  * @example
1335
1347
  * ```ts
1336
1348
  * const byLocale = await api.loadAll();
1337
1349
  * ```
1338
1350
  */
1339
- async loadAll() {
1351
+ async loadAll(options) {
1352
+ const reuse = options?.reuse === true;
1340
1353
  const slugs = await ctx.provider.slugs();
1341
1354
  const locales = ctx.locales();
1342
1355
  const result = /* @__PURE__ */ new Map();
1343
1356
  let total = 0;
1344
1357
  for (const locale of locales) {
1345
- const present = await loadAndFilterArticles(ctx, slugs, locale);
1358
+ const present = await loadAndFilterArticles(ctx, slugs, locale, reuse ? ctx.state.articles.get(locale) : void 0);
1346
1359
  const cache = /* @__PURE__ */ new Map();
1347
1360
  let index = 0;
1348
1361
  for (const article of present) {
@@ -3343,7 +3356,11 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3343
3356
  /**
3344
3357
  * Bundles CSS and JS into the output directory via two separate runner passes
3345
3358
  * (dodging Bun's mixed-entrypoint segfault), honoring `config.minify`, and caches
3346
- * 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.
3347
3364
  *
3348
3365
  * @param ctx - Plugin context (provides `state`, `config`, `log`).
3349
3366
  * @param options - Optional dependency-injection seam (runner + entrypoints).
@@ -3356,10 +3373,10 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
3356
3373
  async function bundle(ctx, options = {}) {
3357
3374
  const runner = options.runner ?? defaultRunner;
3358
3375
  const { minify, outDir } = ctx.config;
3376
+ const assetsDir = path.join(outDir, "assets");
3359
3377
  const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
3360
3378
  const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
3361
- await runOne(ctx, runner, "css", cssEntrypoints, outDir, path.join(outDir, "assets"), minify);
3362
- 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)]);
3363
3380
  }
3364
3381
  //#endregion
3365
3382
  //#region src/plugins/build/phases/content.ts
@@ -3376,15 +3393,24 @@ const CONTENT_CACHE_KEY = "content";
3376
3393
  * pages/feeds/og-images phases. Performs NO Markdown parsing itself — the
3377
3394
  * content plugin owns rendering (god-plugin invariant).
3378
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
+ *
3379
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.
3380
3404
  * @returns The locale-keyed article map returned by the content plugin.
3381
3405
  * @example
3382
3406
  * ```ts
3383
3407
  * const byLocale = await loadContent(ctx);
3384
3408
  * ```
3385
3409
  */
3386
- async function loadContent(ctx) {
3387
- 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 });
3388
3414
  ctx.state.buildCache.set(CONTENT_CACHE_KEY, byLocale);
3389
3415
  ctx.log.debug("build:content", { locales: byLocale.size });
3390
3416
  return byLocale;
@@ -4738,6 +4764,71 @@ function renderBody(definition, routeContext) {
4738
4764
  return renderToString(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
4739
4765
  }
4740
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
+ /**
4741
4832
  * Write a rendered page document to its on-disk path. The path comes from the
4742
4833
  * compiled `TypedRoute.toFile(params)` (honoring any route-level `.toFile()`
4743
4834
  * override), resolved under the build `outDir`; parent directories are created first.
@@ -4766,13 +4857,14 @@ async function writeDocument(outDir, entry, params, html) {
4766
4857
  * @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
4767
4858
  * @param instance - The concrete page instance to render.
4768
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).
4769
4861
  * @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
4770
4862
  * @example
4771
4863
  * ```ts
4772
- * await renderInstance(ctx, instance, { assets: "", template: null });
4864
+ * await renderInstance(ctx, instance, { assets: "", template: null }, false);
4773
4865
  * ```
4774
4866
  */
4775
- async function renderInstance(ctx, instance, shell) {
4867
+ async function renderInstance(ctx, instance, shell, reuse) {
4776
4868
  const { definition, entry, params, locale } = instance;
4777
4869
  const router = ctx.require(routerPlugin);
4778
4870
  const data = await loadRouteData(definition, params, locale, ctx);
@@ -4785,7 +4877,7 @@ async function renderInstance(ctx, instance, shell) {
4785
4877
  };
4786
4878
  const parts = {
4787
4879
  head: composeHeadHtml(ctx, instance, url, routeContext, data),
4788
- body: renderBody(definition, routeContext),
4880
+ body: renderBodyCached(ctx, instance, routeContext, data, reuse),
4789
4881
  assets: shell.assets,
4790
4882
  locale
4791
4883
  };
@@ -4886,6 +4978,12 @@ function findRootHtml(rendered) {
4886
4978
  */
4887
4979
  const RENDER_BATCH_SIZE = 2;
4888
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
+ /**
4889
4987
  * Render `items` through `worker` in bounded-size batches, yielding a macrotask
4890
4988
  * (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
4891
4989
  * sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
@@ -4921,21 +5019,29 @@ async function renderInBatches(items, batchSize, worker) {
4921
5019
  * bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
4922
5020
  * capture the root page's HTML for the root-index phase.
4923
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.
5025
+ *
4924
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).
4925
5029
  * @returns The number of pages rendered and the captured default-page HTML.
4926
5030
  * @example
4927
5031
  * ```ts
4928
5032
  * const { pageCount, rootHtml } = await renderPages(ctx);
4929
5033
  * ```
4930
5034
  */
4931
- async function renderPages(ctx) {
5035
+ async function renderPages(ctx, options) {
5036
+ const reuse = options?.reuse === true;
4932
5037
  const router = ctx.require(routerPlugin);
4933
5038
  const manifest = router.manifest();
4934
5039
  ctx.state.manifest = [...manifest];
4935
5040
  const locales = ctx.require(i18nPlugin).locales();
4936
5041
  const byPattern = makeEntryMap(router);
5042
+ if (!reuse) ctx.state.renderCache.clear();
4937
5043
  const shell = await prepareShell(ctx);
4938
- const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), RENDER_BATCH_SIZE, (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));
4939
5045
  await writeDataSidecars(ctx, rendered, router.mode());
4940
5046
  ctx.log.debug("build:pages", { count: rendered.length });
4941
5047
  return {
@@ -5143,6 +5249,40 @@ async function generateSitemap(ctx) {
5143
5249
  * @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
5144
5250
  * emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
5145
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
+ }
5146
5286
  /**
5147
5287
  * The static ordered list of pipeline phase names.
5148
5288
  *
@@ -5256,8 +5396,7 @@ async function runOutputs(ctx) {
5256
5396
  * `build:phase` boundary per phase and `build:complete` once at the end.
5257
5397
  *
5258
5398
  * @param ctx - Plugin context (provides `require`, `emit`, `state`, `config`, `log`).
5259
- * @param options - Optional run overrides.
5260
- * @param options.outDir - Override the configured output directory for this run.
5399
+ * @param options - Optional per-run overrides ({@link RunOptions}).
5261
5400
  * @returns The build result (outDir, pageCount, durationMs).
5262
5401
  * @example
5263
5402
  * ```ts
@@ -5272,17 +5411,22 @@ async function runPipeline(ctx, options) {
5272
5411
  ...ctx,
5273
5412
  config: {
5274
5413
  ...ctx.config,
5275
- outDir
5414
+ outDir,
5415
+ ...options?.overrides
5276
5416
  }
5277
5417
  };
5278
- await rm(outDir, {
5418
+ const plan = planIncrementalRebuild(options?.changed);
5419
+ if (!options?.skipClean) await rm(outDir, {
5279
5420
  recursive: true,
5280
5421
  force: true
5281
5422
  });
5282
5423
  await mkdir(outDir, { recursive: true });
5283
5424
  await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
5284
- await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext)), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
5285
- 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 }));
5286
5430
  await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
5287
5431
  await runOutputs(phaseContext);
5288
5432
  await withPhase(phaseContext, "root-index", async () => {
@@ -5335,10 +5479,12 @@ const defaultConfig$2 = {
5335
5479
  function createApi$3(ctx) {
5336
5480
  return {
5337
5481
  /**
5338
- * 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).
5339
5486
  *
5340
- * @param options - Optional run overrides.
5341
- * @param options.outDir - Override the configured output directory for this run.
5487
+ * @param options - Optional per-run overrides (outDir / skipClean / overrides / changed).
5342
5488
  * @returns The build result (outDir, pageCount, durationMs).
5343
5489
  * @example
5344
5490
  * ```ts
@@ -5431,8 +5577,8 @@ function createEvents(register) {
5431
5577
  /**
5432
5578
  * Creates initial `build` plugin state: a frozen config snapshot plus empty
5433
5579
  * per-run caches (`manifest`, `buildCache`, `runId`) and the cross-run OG
5434
- * content-hash cache. Holds caches and config only — no domain data is
5435
- * 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).
5436
5582
  *
5437
5583
  * @param ctx - Minimal context with global and config.
5438
5584
  * @param ctx.global - Global plugin registry (unused; caches are config-driven).
@@ -5449,7 +5595,8 @@ function createState$3(ctx) {
5449
5595
  manifest: null,
5450
5596
  buildCache: /* @__PURE__ */ new Map(),
5451
5597
  runId: null,
5452
- ogImageHashCache: /* @__PURE__ */ new Map()
5598
+ ogImageHashCache: /* @__PURE__ */ new Map(),
5599
+ renderCache: /* @__PURE__ */ new Map()
5453
5600
  };
5454
5601
  }
5455
5602
  //#endregion
@@ -5673,12 +5820,37 @@ function buildWranglerArgs(input) {
5673
5820
  branch
5674
5821
  ];
5675
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
+ }
5676
5848
  /** Lowercased substring matchers for the wrangler error taxonomy. */
5677
5849
  const ERROR_SIGNATURES = [
5678
5850
  {
5679
5851
  match: ["could not find project", "project not found"],
5680
5852
  kind: "ERR_DEPLOY_PROJECT_NOT_FOUND",
5681
- 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.)"
5682
5854
  },
5683
5855
  {
5684
5856
  match: [
@@ -6257,15 +6429,16 @@ function validateConfig$1(ctx) {
6257
6429
  * Run wrangler for the prepared argv and surface its scrubbed result, translating
6258
6430
  * a non-zero exit into the classified deploy error. The API token is read from env
6259
6431
  * here so it never crosses a logging boundary; only scrubbed output is returned.
6432
+ * Shared by `run()` (deploy) and `createProject()` (project create).
6260
6433
  *
6261
6434
  * @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
6262
6435
  * @param args - The fully-built, pre-validated wrangler argv.
6263
6436
  * @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
6264
6437
  * @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
6265
6438
  * @example
6266
- * const { stdout, scrubbedStderr } = await executeDeploy(ctx, args);
6439
+ * const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6267
6440
  */
6268
- async function executeDeploy(ctx, args) {
6441
+ async function executeWrangler(ctx, args) {
6269
6442
  const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
6270
6443
  const { stdout, scrubbedStderr, exitCode } = await runWrangler({
6271
6444
  spawn: ctx.state.spawn,
@@ -6337,7 +6510,7 @@ function createApi$2(ctx) {
6337
6510
  root
6338
6511
  });
6339
6512
  const start = Date.now();
6340
- const { stdout, scrubbedStderr } = await executeDeploy(ctx, args);
6513
+ const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
6341
6514
  ctx.log.info(scrubbedStderr);
6342
6515
  const result = buildDeployResult(stdout, branch, start);
6343
6516
  ctx.state.lastDeployment = result;
@@ -6376,6 +6549,38 @@ function createApi$2(ctx) {
6376
6549
  cwd: process.cwd(),
6377
6550
  options
6378
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
+ };
6379
6584
  }
6380
6585
  };
6381
6586
  }
@@ -6509,21 +6714,56 @@ const ACCOUNT_HELP = [
6509
6714
  "right-hand sidebar (also in the dashboard URL). Then make it available:",
6510
6715
  " export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
6511
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");
6512
6723
  /** The GitHub repo secrets the generated workflow consumes. */
6513
6724
  const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
6514
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
+ /**
6515
6755
  * Evaluate the three deploy prerequisites against the current project: the Cloudflare
6516
- * 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).
6517
6758
  *
6759
+ * @param ctx - The cli plugin context (provides the resolved `ctx.env`).
6518
6760
  * @param cwd - The project root (where `wrangler.jsonc` lives).
6519
6761
  * @returns The ordered prerequisite checks.
6520
6762
  * @example
6521
- * const prereqs = diagnose(process.cwd());
6763
+ * const prereqs = diagnose(ctx, process.cwd());
6522
6764
  */
6523
- function diagnose(cwd) {
6765
+ function diagnose(ctx, cwd) {
6524
6766
  const wranglerOk = existsSync(path.join(cwd, "wrangler.jsonc"));
6525
- const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
6526
- const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
6527
6767
  return [
6528
6768
  {
6529
6769
  ok: wranglerOk,
@@ -6531,18 +6771,8 @@ function diagnose(cwd) {
6531
6771
  detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
6532
6772
  scaffoldable: true
6533
6773
  },
6534
- {
6535
- ok: tokenOk,
6536
- label: "CLOUDFLARE_API_TOKEN is set",
6537
- detail: tokenOk ? void 0 : TOKEN_HELP,
6538
- scaffoldable: false
6539
- },
6540
- {
6541
- ok: accountOk,
6542
- label: "CLOUDFLARE_ACCOUNT_ID is set",
6543
- detail: accountOk ? void 0 : ACCOUNT_HELP,
6544
- scaffoldable: false
6545
- }
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)
6546
6776
  ];
6547
6777
  }
6548
6778
  /**
@@ -6640,8 +6870,139 @@ async function offerWorkflowSetup(ctx) {
6640
6870
  ctx.state.render.info(SECRETS_HELP);
6641
6871
  }
6642
6872
  /**
6643
- * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
6644
- * 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.
6645
7006
  *
6646
7007
  * @param ctx - The cli plugin context.
6647
7008
  * @param options - The deploy options (branch override + `yes`).
@@ -6658,10 +7019,12 @@ async function runDeployStep(ctx, options) {
6658
7019
  reason: "declined"
6659
7020
  };
6660
7021
  }
6661
- return {
6662
- deployed: true,
6663
- ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6664
- };
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
+ }
6665
7028
  }
6666
7029
  /**
6667
7030
  * Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
@@ -6679,10 +7042,10 @@ async function runDeployStep(ctx, options) {
6679
7042
  async function runDeployWizard(ctx, options) {
6680
7043
  const cwd = process.cwd();
6681
7044
  ctx.state.render.heading("Checking prerequisites");
6682
- for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
6683
- await offerScaffold(ctx, diagnose(cwd));
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));
6684
7047
  await offerEnvScaffold(ctx, cwd);
6685
- const blockers = diagnose(cwd).filter((item) => !item.ok);
7048
+ const blockers = diagnose(ctx, cwd).filter((item) => !item.ok);
6686
7049
  if (blockers.length > 0) {
6687
7050
  ctx.state.render.heading("Not ready to deploy");
6688
7051
  for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
@@ -6699,7 +7062,7 @@ async function runDeployWizard(ctx, options) {
6699
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).");
6700
7063
  ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
6701
7064
  const outcome = await runDeployStep(ctx, options);
6702
- await offerWorkflowSetup(ctx);
7065
+ if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx);
6703
7066
  return outcome;
6704
7067
  }
6705
7068
  //#endregion
@@ -6739,25 +7102,26 @@ function injectReloadClient(html) {
6739
7102
  * Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
6740
7103
  * routes success to `onReloaded` and failure to `onError`.
6741
7104
  *
6742
- * @param input - The rebuild dependencies + the changed file.
6743
- * @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.
6744
7107
  * @param input.onRebuildStart - Called with the changed file just before the build runs.
6745
- * @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.
6746
7109
  * @param input.onError - Called when a rebuild throws.
6747
7110
  * @param input.file - The changed file to report alongside the summary.
7111
+ * @param input.changed - The accumulated changed paths handed to `runBuild` (incremental).
6748
7112
  * @returns Resolves once the rebuild settles (always — errors are routed, not thrown).
6749
7113
  * @example
6750
- * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
7114
+ * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md", changed: ["a.md"] });
6751
7115
  */
6752
7116
  async function runOneRebuild(input) {
6753
7117
  input.onRebuildStart?.(input.file);
6754
7118
  try {
6755
- const summary = await input.runBuild();
7119
+ const summary = await input.runBuild(input.changed);
6756
7120
  input.onReloaded({
6757
7121
  file: input.file,
6758
7122
  pageCount: summary.pageCount,
6759
7123
  durationMs: summary.durationMs
6760
- });
7124
+ }, input.changed);
6761
7125
  } catch (error) {
6762
7126
  input.onError(error);
6763
7127
  }
@@ -6771,9 +7135,9 @@ async function runOneRebuild(input) {
6771
7135
  *
6772
7136
  * @param input - The rebuild dependencies.
6773
7137
  * @param input.debounceMs - Debounce window in milliseconds.
6774
- * @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.
6775
7139
  * @param input.onRebuildStart - Called with the changed file just before each build runs.
6776
- * @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.
6777
7141
  * @param input.onError - Called when a rebuild throws.
6778
7142
  * @returns The debounced rebuild driver.
6779
7143
  * @example
@@ -6782,12 +7146,13 @@ async function runOneRebuild(input) {
6782
7146
  function createRebuilder(input) {
6783
7147
  let timer;
6784
7148
  let pendingFile = "";
7149
+ const pendingChanged = /* @__PURE__ */ new Set();
6785
7150
  let building = false;
6786
7151
  let dirty = false;
6787
7152
  /**
6788
- * Rebuild repeatedly until no change arrived mid-flight: each pass clears `dirty`,
6789
- * runs one build, then loops again if a `schedule()` set `dirty` while it ran, so
6790
- * 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.
6791
7156
  *
6792
7157
  * @returns Resolves once a pass completes with no pending change (errors are routed,
6793
7158
  * never thrown).
@@ -6797,12 +7162,15 @@ function createRebuilder(input) {
6797
7162
  const drainPendingRebuilds = async () => {
6798
7163
  do {
6799
7164
  dirty = false;
7165
+ const changed = [...pendingChanged];
7166
+ pendingChanged.clear();
6800
7167
  await runOneRebuild({
6801
7168
  runBuild: input.runBuild,
6802
7169
  ...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
6803
7170
  onReloaded: input.onReloaded,
6804
7171
  onError: input.onError,
6805
- file: pendingFile
7172
+ file: pendingFile,
7173
+ changed
6806
7174
  });
6807
7175
  } while (dirty);
6808
7176
  };
@@ -6829,14 +7197,15 @@ function createRebuilder(input) {
6829
7197
  };
6830
7198
  return {
6831
7199
  /**
6832
- * Queue a rebuild for the given label (debounced + coalesced).
7200
+ * Queue a rebuild for the given changed path (debounced + coalesced + accumulated).
6833
7201
  *
6834
- * @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.
6835
7203
  * @example
6836
- * rebuilder.schedule("content");
7204
+ * rebuilder.schedule("content/intro/en.md");
6837
7205
  */
6838
7206
  schedule(file) {
6839
7207
  pendingFile = file;
7208
+ pendingChanged.add(file);
6840
7209
  if (timer) clearTimeout(timer);
6841
7210
  timer = setTimeout(fire, input.debounceMs);
6842
7211
  },
@@ -6866,26 +7235,33 @@ function isNoisePath(filename) {
6866
7235
  return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
6867
7236
  }
6868
7237
  /**
6869
- * Create a {@link ChangeGate} that drops three kinds of spurious change events before
6870
- * they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
6871
- * `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
6872
- * echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
6873
- * a change whose file mtime is at or before the last build we started was already
6874
- * captured (or is a late echo), so it is ignored while a genuinely newer edit (even
6875
- * one made mid-build) and a deletion (missing file) always pass. The single timestamp
6876
- * 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.
6877
7249
  *
6878
7250
  * @param input - The gate dependencies.
6879
7251
  * @param input.outDir - The build output directory whose writes must never re-trigger a build.
6880
7252
  * @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
6881
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).
6882
7256
  * @returns The change gate.
6883
7257
  * @example
6884
- * 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 });
6885
7259
  */
6886
7260
  function createChangeGate(input) {
6887
7261
  const outDirAbs = path.resolve(input.outDir);
7262
+ const fileHash = input.fileHash ?? (() => null);
6888
7263
  let lastBuildStartedAt = input.now();
7264
+ const committedHash = /* @__PURE__ */ new Map();
6889
7265
  return {
6890
7266
  /**
6891
7267
  * Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
@@ -6903,6 +7279,12 @@ function createChangeGate(input) {
6903
7279
  if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${path.sep}`)) return false;
6904
7280
  const mtime = input.fileMtime(changed);
6905
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;
6906
7288
  return true;
6907
7289
  },
6908
7290
  /**
@@ -6913,6 +7295,20 @@ function createChangeGate(input) {
6913
7295
  */
6914
7296
  markBuildStart() {
6915
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
+ }
6916
7312
  }
6917
7313
  };
6918
7314
  }
@@ -7105,19 +7501,46 @@ function createDevHandler(ctx, hub) {
7105
7501
  };
7106
7502
  }
7107
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
+ /**
7108
7525
  * Run the dev loop: an initial build, an in-process static server that injects the
7109
7526
  * live-reload client, a recursive watcher over `config.watchDirs`, and a debounced
7110
7527
  * rebuild that re-renders and pushes a browser reload. Resolves on SIGINT/SIGTERM,
7111
- * 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`.
7112
7533
  *
7113
7534
  * @param ctx - The cli plugin context (config, state seams, `require`).
7114
7535
  * @param port - The port to bind the dev server to.
7536
+ * @param features - Per-session dev feature opt-ins (`og`/`sitemap`/`feeds`/`localeRedirects`).
7115
7537
  * @returns Resolves once the server has been torn down by a termination signal.
7116
7538
  * @example
7117
- * await runDevServer(ctx, 4173);
7539
+ * await runDevServer(ctx, 4173, { og: false, sitemap: false, feeds: false, localeRedirects: false });
7118
7540
  */
7119
- async function runDevServer(ctx, port) {
7120
- await ctx.require(buildPlugin).run();
7541
+ async function runDevServer(ctx, port, features) {
7542
+ const overrides = devBuildOverrides(features);
7543
+ await ctx.require(buildPlugin).run({ overrides });
7121
7544
  const hub = createReloadHub();
7122
7545
  const server = ctx.state.serveStatic({
7123
7546
  port,
@@ -7127,19 +7550,27 @@ async function runDevServer(ctx, port) {
7127
7550
  const gate = createChangeGate({
7128
7551
  outDir: ctx.config.outDir,
7129
7552
  fileMtime: ctx.state.fileMtime,
7130
- now: ctx.state.clock
7553
+ now: ctx.state.clock,
7554
+ fileHash: ctx.state.fileHash
7131
7555
  });
7132
7556
  const rebuilder = createRebuilder({
7133
7557
  debounceMs: ctx.config.debounceMs,
7134
7558
  /**
7135
- * 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.
7136
7562
  *
7563
+ * @param changed - The paths changed since the last build (incremental rebuild hint).
7137
7564
  * @returns The rebuild summary.
7138
7565
  * @example
7139
- * await runBuild();
7566
+ * await runBuild(["content/intro/en.md"]);
7140
7567
  */
7141
- runBuild() {
7142
- return ctx.require(buildPlugin).run();
7568
+ runBuild(changed) {
7569
+ return ctx.require(buildPlugin).run({
7570
+ skipClean: true,
7571
+ overrides,
7572
+ changed
7573
+ });
7143
7574
  },
7144
7575
  /**
7145
7576
  * Show the compact in-place "rebuilding {label}" line before the build runs.
@@ -7156,15 +7587,18 @@ async function runDevServer(ctx, port) {
7156
7587
  * Render the reload line and push a browser reload after a rebuild.
7157
7588
  *
7158
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).
7159
7591
  * @example
7160
- * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 });
7592
+ * onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 }, ["content/a.md"]);
7161
7593
  */
7162
- onReloaded(info) {
7594
+ onReloaded(info, changed) {
7595
+ gate.commitBuilt(changed);
7163
7596
  ctx.state.render.reload(info);
7164
7597
  hub.reloadAll();
7165
7598
  },
7166
7599
  /**
7167
- * 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.
7168
7602
  *
7169
7603
  * @param error - The thrown rebuild error.
7170
7604
  * @example
@@ -7175,7 +7609,8 @@ async function runDevServer(ctx, port) {
7175
7609
  }
7176
7610
  });
7177
7611
  const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
7178
- 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));
7179
7614
  }));
7180
7615
  ctx.state.render.serverReady({
7181
7616
  local: `http://localhost:${port}`,
@@ -7442,17 +7877,25 @@ function createApi$1(ctx) {
7442
7877
  },
7443
7878
  /**
7444
7879
  * Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
7445
- * `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.
7446
7884
  *
7447
- * @param options - Optional port override (defaults to `config.port`).
7885
+ * @param options - Optional port override + per-session dev feature opt-ins.
7448
7886
  * @returns Resolves once the server has been torn down.
7449
7887
  * @example
7450
- * await api.serve({ port: 3000 });
7888
+ * await api.serve({ port: 3000, og: true });
7451
7889
  */
7452
7890
  serve(options = {}) {
7453
7891
  const { port = ctx.config.port } = options;
7454
7892
  ctx.state.render.header("serve");
7455
- 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
+ });
7456
7899
  },
7457
7900
  /**
7458
7901
  * Static preview of the built `dist/` with CF-Pages clean-URL resolution.
@@ -8535,6 +8978,23 @@ function defaultFileMtime(filePath) {
8535
8978
  }
8536
8979
  }
8537
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
+ /**
8538
8998
  * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
8539
8999
  * reads `node:os` interfaces while tests can inject a deterministic value.
8540
9000
  *
@@ -8667,7 +9127,8 @@ function createState$1(_ctx) {
8667
9127
  serveStatic: defaultServeStatic,
8668
9128
  fileResponse: defaultFileResponse,
8669
9129
  networkUrl: defaultNetworkUrl,
8670
- fileMtime: defaultFileMtime
9130
+ fileMtime: defaultFileMtime,
9131
+ fileHash: defaultFileHash
8671
9132
  };
8672
9133
  }
8673
9134
  //#endregion