nimbus-docs 0.1.8 → 0.1.9

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.js CHANGED
@@ -1,16 +1,18 @@
1
- import { o as validateLintOptions, r as suggest, t as IMPLEMENTED_CODES } from "./rules-B7o0k3TA.js";
1
+ import { o as validateLintOptions, r as suggest, t as IMPLEMENTED_CODES } from "./rules-DDDvKkyJ.js";
2
2
  import { i as toRouteKey, n as isAbsoluteUrl, r as toBrowserHref, t as withStrictKeys } from "./strict-keys-fbKKxxKL.js";
3
+ import { createRequire } from "node:module";
3
4
  import { execFile } from "node:child_process";
4
5
  import { promisify } from "node:util";
5
6
  import fs from "node:fs";
6
- import path from "node:path";
7
+ import path, { dirname, extname, relative, resolve, sep } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import mdx from "@astrojs/mdx";
9
10
  import { satteri } from "@astrojs/markdown-satteri";
10
11
  import sitemap from "@astrojs/sitemap";
11
- import fs$1 from "node:fs/promises";
12
+ import fs$1, { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
12
13
  import { z } from "astro/zod";
13
14
  import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from "@shikijs/transformers";
15
+ import { createHash } from "node:crypto";
14
16
 
15
17
  //#region src/_internal/runtime-config.ts
16
18
  let _cached = null;
@@ -165,6 +167,7 @@ function canonicalEntryUrl(prefix, entryId) {
165
167
  //#endregion
166
168
  //#region src/_internal/sidebar.ts
167
169
  const sortKeyByItem = /* @__PURE__ */ new WeakMap();
170
+ const directoryIndexLinks = /* @__PURE__ */ new WeakSet();
168
171
  function sortSidebarItems(a, b) {
169
172
  const orderDiff = a.order - b.order;
170
173
  if (orderDiff !== 0) return orderDiff;
@@ -251,7 +254,11 @@ function buildFilesystemTree(entries, currentPath, directory, hrefPrefix = "") {
251
254
  const groupsAtLevel = /* @__PURE__ */ new Map();
252
255
  if (directory && parentPath === directory) {
253
256
  const dirIndex = byId.get(directory);
254
- if (dirIndex) result.push(createLink(dirIndex, currentPath, hrefPrefix));
257
+ if (dirIndex) {
258
+ const indexLink = createLink(dirIndex, currentPath, hrefPrefix);
259
+ if (indexLink.type === "link" && !dirIndex.data.sidebar?.label) directoryIndexLinks.add(indexLink);
260
+ result.push(indexLink);
261
+ }
255
262
  }
256
263
  for (const entry of scoped) {
257
264
  if (entry.id === "index") continue;
@@ -562,7 +569,8 @@ function applyDefaultCollapsed(items) {
562
569
  * never holds, so this silently returns the input unchanged.
563
570
  */
564
571
  function applyOverviewLabel(items, label) {
565
- for (const item of items) if (item.type === "group") {
572
+ for (const item of items) if (item.type === "link" && directoryIndexLinks.has(item)) item.label = label;
573
+ else if (item.type === "group") {
566
574
  if (item._indexId) {
567
575
  const firstLink = item.children.find((child) => child.type === "link");
568
576
  if (firstLink && sortKeyByItem.get(firstLink) === item._indexId) firstLink.label = label;
@@ -1826,7 +1834,7 @@ async function validateMdxContent(options) {
1826
1834
  const globalsSet = new Set(options.globals);
1827
1835
  const failures = [];
1828
1836
  for (const dir of options.contentDirs) {
1829
- const files = await walkMdx(dir);
1837
+ const files = await walkMdx$1(dir);
1830
1838
  for (const file of files) {
1831
1839
  if (options.skip?.(file)) continue;
1832
1840
  const fileFailures = scanFile(await fs$1.readFile(file, "utf8"), globalsSet);
@@ -1855,7 +1863,7 @@ function formatFailures(failures, globalsCount) {
1855
1863
  });
1856
1864
  return `[nimbus-docs] Unknown MDX component ${failures.length === 1 ? "tag" : "tags"}:\n` + lines.join("\n") + "\n\nA PascalCase tag in MDX must either be registered in src/components.ts (the global registry) or imported at the top of the file. Without either, MDX renders the tag as literal text on the page — a silent failure this validator turns into a build error.";
1857
1865
  }
1858
- async function walkMdx(dir) {
1866
+ async function walkMdx$1(dir) {
1859
1867
  const out = [];
1860
1868
  async function visit(current) {
1861
1869
  let entries;
@@ -1935,7 +1943,7 @@ function parseImports(body) {
1935
1943
  * inside code samples doesn't trip the validator.
1936
1944
  */
1937
1945
  function stripCodeBlocks(body) {
1938
- return body.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length)).replace(/~~~[\s\S]*?~~~/g, (m) => " ".repeat(m.length)).replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length)).replace(/=\s*"[^"\n]*"/g, (m) => "=" + " ".repeat(m.length - 1)).replace(/=\s*'[^'\n]*'/g, (m) => "=" + " ".repeat(m.length - 1));
1946
+ return body.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length)).replace(/~~~[\s\S]*?~~~/g, (m) => " ".repeat(m.length)).replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length)).replace(/=\s*"[^"\n]*"/g, (m) => "=" + " ".repeat(m.length - 1)).replace(/=\s*'[^'\n]*'/g, (m) => "=" + " ".repeat(m.length - 1)).replace(/"[^"\n]*"/g, (m) => " ".repeat(m.length)).replace(/'[^'\n]*'/g, (m) => " ".repeat(m.length));
1939
1947
  }
1940
1948
  /**
1941
1949
  * Find PascalCase JSX-like tags. Matches `<Capital...` at the start of
@@ -1945,10 +1953,14 @@ function stripCodeBlocks(body) {
1945
1953
  */
1946
1954
  function findPascalCaseTags(body) {
1947
1955
  const out = [];
1948
- for (const match of body.matchAll(/<([A-Z][A-Za-z0-9_]*)\b/g)) out.push({
1949
- name: match[1],
1950
- offset: match.index ?? 0
1951
- });
1956
+ for (const match of body.matchAll(/<([A-Z][A-Za-z0-9_]*)/g)) {
1957
+ const offset = match.index ?? 0;
1958
+ if (body[offset + match[0].length] === "<") continue;
1959
+ out.push({
1960
+ name: match[1],
1961
+ offset
1962
+ });
1963
+ }
1952
1964
  return out;
1953
1965
  }
1954
1966
  /**
@@ -2115,6 +2127,1268 @@ function virtualConfigPlugin(config, extras) {
2115
2127
  };
2116
2128
  }
2117
2129
 
2130
+ //#endregion
2131
+ //#region src/_internal/scan-code-langs.ts
2132
+ /**
2133
+ * Walk `src/content/` and collect every language used in fenced code blocks
2134
+ * inside `.md` / `.mdx` files. Output feeds `shikiConfig.langs` so Shiki
2135
+ * eager-loads every grammar at startup instead of lazy-loading on first use.
2136
+ *
2137
+ * Why this matters: Shiki's lazy load assumes every MDX file gets processed
2138
+ * during a build. Layer 2 (incremental builds' Vite MDX-skip plugin) breaks
2139
+ * that assumption — cached MDX files never enter the markdown pipeline, so
2140
+ * languages that only appear in cached files would never trigger a grammar
2141
+ * load, and any non-cached file using those languages would silently render
2142
+ * without highlighting.
2143
+ *
2144
+ * Eager loading also gives non-incremental users a small predictability win:
2145
+ * the highlighter behaves the same regardless of which file is processed
2146
+ * first.
2147
+ *
2148
+ * Cost: ~1s on a 7k-file bench. Acceptable.
2149
+ */
2150
+ const FENCE_RE = /^[ \t]*```([a-zA-Z][a-zA-Z0-9_+\-]*)/gm;
2151
+ async function* walkMdx(dir) {
2152
+ let entries;
2153
+ try {
2154
+ entries = await readdir(dir, { withFileTypes: true });
2155
+ } catch {
2156
+ return;
2157
+ }
2158
+ for (const entry of entries) {
2159
+ if (entry.name.startsWith(".")) continue;
2160
+ const full = resolve(dir, entry.name);
2161
+ if (entry.isDirectory()) yield* walkMdx(full);
2162
+ else if (entry.isFile() && [".mdx", ".md"].includes(extname(entry.name))) yield full;
2163
+ }
2164
+ }
2165
+ /**
2166
+ * Scan a project's content directories for code-fence languages.
2167
+ *
2168
+ * `langAlias` maps shorthand fence names (e.g. `curl`, `console`) to the
2169
+ * underlying highlighter Shiki actually knows. The mapping is applied
2170
+ * before deduping so the returned set is what Shiki should load.
2171
+ */
2172
+ async function scanCodeBlockLanguages(projectRoot, langAlias = {}) {
2173
+ const langs = /* @__PURE__ */ new Set();
2174
+ const contentRoot = resolve(projectRoot, "src/content");
2175
+ for await (const file of walkMdx(contentRoot)) {
2176
+ let content;
2177
+ try {
2178
+ content = await readFile(file, "utf8");
2179
+ } catch {
2180
+ continue;
2181
+ }
2182
+ FENCE_RE.lastIndex = 0;
2183
+ for (const m of content.matchAll(FENCE_RE)) {
2184
+ const raw = m[1].toLowerCase();
2185
+ const mapped = langAlias[raw] ?? raw;
2186
+ langs.add(mapped);
2187
+ }
2188
+ }
2189
+ return Array.from(langs).sort();
2190
+ }
2191
+
2192
+ //#endregion
2193
+ //#region src/_internal/incremental/cache.ts
2194
+ /**
2195
+ * Filesystem cache layer.
2196
+ *
2197
+ * Layout under `.nimbus/cache/`:
2198
+ *
2199
+ * manifest.json — see Manifest type
2200
+ * pages/<aa>/<full-hash>.html — cached HTML body for a page, sharded
2201
+ * by the first 2 hex chars of the hash
2202
+ *
2203
+ * Phase 2 MVP — atomic per-file writes. v2 adds a manifest-level
2204
+ * `namespace` field for PR-vs-main isolation; resolution lives in
2205
+ * `namespace.ts`. Framework/Node version is folded into `globalHash` via
2206
+ * `computeGlobalHash` already, so it doesn't need a separate field.
2207
+ */
2208
+ const SCHEMA_VERSION = 2;
2209
+ var Cache = class {
2210
+ root;
2211
+ constructor(projectRoot) {
2212
+ this.root = resolve(projectRoot, ".nimbus/cache");
2213
+ }
2214
+ pagePath(hash) {
2215
+ return resolve(this.root, "pages", hash.slice(0, 2), `${hash}.html`);
2216
+ }
2217
+ manifestPath() {
2218
+ return resolve(this.root, "manifest.json");
2219
+ }
2220
+ async readManifest() {
2221
+ try {
2222
+ const raw = await readFile(this.manifestPath(), "utf8");
2223
+ const m = JSON.parse(raw);
2224
+ if (m.schemaVersion !== SCHEMA_VERSION) return null;
2225
+ return m;
2226
+ } catch {
2227
+ return null;
2228
+ }
2229
+ }
2230
+ async writeManifest(manifest) {
2231
+ const full = {
2232
+ schemaVersion: SCHEMA_VERSION,
2233
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
2234
+ ...manifest
2235
+ };
2236
+ await mkdir(this.root, { recursive: true });
2237
+ await writeAtomic(this.manifestPath(), JSON.stringify(full, null, 2) + "\n");
2238
+ }
2239
+ async readPage(hash) {
2240
+ try {
2241
+ return await readFile(this.pagePath(hash), "utf8");
2242
+ } catch {
2243
+ return null;
2244
+ }
2245
+ }
2246
+ async hasPage(hash) {
2247
+ try {
2248
+ await readFile(this.pagePath(hash));
2249
+ return true;
2250
+ } catch {
2251
+ return false;
2252
+ }
2253
+ }
2254
+ async writePage(hash, html) {
2255
+ const path = this.pagePath(hash);
2256
+ await mkdir(dirname(path), { recursive: true });
2257
+ await writeAtomic(path, html);
2258
+ }
2259
+ async clear() {
2260
+ await rm(this.root, {
2261
+ recursive: true,
2262
+ force: true
2263
+ });
2264
+ }
2265
+ /**
2266
+ * Snapshot a *bounded subset* of `dist/_astro/` into the cache.
2267
+ *
2268
+ * Naive snapshot was unbounded: every warm build accumulated new
2269
+ * bundle hashes (vite produces different hashes when the module graph
2270
+ * differs between builds) and we kept everything forever. Caller passes
2271
+ * the set of asset rel-paths that some cached HTML actually references —
2272
+ * anything outside that set gets dropped.
2273
+ *
2274
+ * `referencedRelPaths` should be the union of every `/_astro/...` URL
2275
+ * extracted from cached HTML — see `parseReferencedAssets` in index.ts.
2276
+ */
2277
+ async snapshotAssets(distAstroDir, referencedRelPaths) {
2278
+ const target = resolve(this.root, "assets");
2279
+ await rm(target, {
2280
+ recursive: true,
2281
+ force: true
2282
+ });
2283
+ try {
2284
+ await stat(distAstroDir);
2285
+ } catch {
2286
+ return 0;
2287
+ }
2288
+ if (referencedRelPaths.size === 0) return 0;
2289
+ await mkdir(target, { recursive: true });
2290
+ let count = 0;
2291
+ for (const relPath of referencedRelPaths) {
2292
+ const src = resolve(distAstroDir, relPath);
2293
+ const dst = resolve(target, relPath);
2294
+ try {
2295
+ await stat(src);
2296
+ } catch {
2297
+ continue;
2298
+ }
2299
+ try {
2300
+ await mkdir(dirname(dst), { recursive: true });
2301
+ await cp(src, dst);
2302
+ count++;
2303
+ } catch {}
2304
+ }
2305
+ return count;
2306
+ }
2307
+ /**
2308
+ * Restore cached assets into the build's `_astro/` directory. Only writes
2309
+ * files that don't already exist — fresh assets from the current warm
2310
+ * build win when there's a collision.
2311
+ *
2312
+ * Per-file try/catch: a failed copy logs and continues. Aborting the
2313
+ * whole restore on a single bad file would prevent `astro:build:done`
2314
+ * from reaching the manifest write — that's a worse failure mode than
2315
+ * a few missing assets.
2316
+ */
2317
+ async restoreAssets(distAstroDir, onError) {
2318
+ const source = resolve(this.root, "assets");
2319
+ try {
2320
+ await stat(source);
2321
+ } catch {
2322
+ return 0;
2323
+ }
2324
+ let restored = 0;
2325
+ await mkdir(distAstroDir, { recursive: true });
2326
+ for await (const relPath of walkRelative(source)) {
2327
+ const src = resolve(source, relPath);
2328
+ const dst = resolve(distAstroDir, relPath);
2329
+ try {
2330
+ await stat(dst);
2331
+ continue;
2332
+ } catch {}
2333
+ try {
2334
+ await mkdir(dirname(dst), { recursive: true });
2335
+ await cp(src, dst);
2336
+ restored++;
2337
+ } catch (err) {
2338
+ onError?.(relPath, err);
2339
+ }
2340
+ }
2341
+ return restored;
2342
+ }
2343
+ /**
2344
+ * Snapshot `dist/pagefind/` into the cache. Called after a Pagefind run
2345
+ * completes so a subsequent zero-miss warm build can restore the prior
2346
+ * index without rerunning Pagefind (which sets a ~10s floor at 7k pages
2347
+ * by reindexing the entire site).
2348
+ *
2349
+ * Idempotent: replaces any prior snapshot. Returns the number of files
2350
+ * copied; 0 if `pagefind/` doesn't exist (e.g. user disabled search).
2351
+ */
2352
+ async snapshotPagefind(distPagefindDir) {
2353
+ const target = resolve(this.root, "pagefind");
2354
+ await rm(target, {
2355
+ recursive: true,
2356
+ force: true
2357
+ });
2358
+ try {
2359
+ await stat(distPagefindDir);
2360
+ } catch {
2361
+ return 0;
2362
+ }
2363
+ await mkdir(target, { recursive: true });
2364
+ let count = 0;
2365
+ for await (const relPath of walkRelative(distPagefindDir)) {
2366
+ const src = resolve(distPagefindDir, relPath);
2367
+ const dst = resolve(target, relPath);
2368
+ try {
2369
+ await mkdir(dirname(dst), { recursive: true });
2370
+ await cp(src, dst);
2371
+ count++;
2372
+ } catch {}
2373
+ }
2374
+ return count;
2375
+ }
2376
+ /**
2377
+ * Restore the cached `pagefind/` into `dist/`. Used on zero-miss warm
2378
+ * builds in place of rerunning Pagefind. Per-file try/catch — a single
2379
+ * bad copy doesn't abort the restore.
2380
+ */
2381
+ async restorePagefind(distPagefindDir) {
2382
+ const source = resolve(this.root, "pagefind");
2383
+ try {
2384
+ await stat(source);
2385
+ } catch {
2386
+ return 0;
2387
+ }
2388
+ let restored = 0;
2389
+ await mkdir(distPagefindDir, { recursive: true });
2390
+ for await (const relPath of walkRelative(source)) {
2391
+ const src = resolve(source, relPath);
2392
+ const dst = resolve(distPagefindDir, relPath);
2393
+ try {
2394
+ await mkdir(dirname(dst), { recursive: true });
2395
+ await cp(src, dst);
2396
+ restored++;
2397
+ } catch {}
2398
+ }
2399
+ return restored;
2400
+ }
2401
+ /** Whether a Pagefind snapshot is present on disk. */
2402
+ async hasPagefindSnapshot() {
2403
+ try {
2404
+ await stat(resolve(this.root, "pagefind"));
2405
+ return true;
2406
+ } catch {
2407
+ return false;
2408
+ }
2409
+ }
2410
+ };
2411
+ async function* walkRelative(root) {
2412
+ async function* walk(dir) {
2413
+ const entries = await readdir(dir, { withFileTypes: true });
2414
+ for (const entry of entries) {
2415
+ const full = resolve(dir, entry.name);
2416
+ if (entry.isDirectory()) yield* walk(full);
2417
+ else if (entry.isFile()) yield relative(root, full).split(sep).join("/");
2418
+ }
2419
+ }
2420
+ yield* walk(root);
2421
+ }
2422
+ /**
2423
+ * Write `data` to `path` atomically — write to a sibling temp file, then
2424
+ * rename into place. Prevents half-written files when a build is interrupted.
2425
+ */
2426
+ async function writeAtomic(path, data) {
2427
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
2428
+ await writeFile(tmp, data, "utf8");
2429
+ await rename(tmp, path);
2430
+ }
2431
+
2432
+ //#endregion
2433
+ //#region src/_internal/incremental/hash.ts
2434
+ /**
2435
+ * Hash primitives for the incremental builds cache.
2436
+ *
2437
+ * Two hash kinds:
2438
+ * - globalHash: fingerprint of anything outside src/content/ that could
2439
+ * change rendered output (config, components, layouts,
2440
+ * lockfile). Any change here invalidates every page.
2441
+ * - pageHash: sha256(page bytes + globalHash). Determines whether a
2442
+ * given page's cached HTML is still valid.
2443
+ *
2444
+ * Phase 2 MVP — deliberately no partial tracking, no data-collection tracking,
2445
+ * no component-graph tracking. Phase 3 wires the partial registry into the
2446
+ * page hash; that work depends on `validate-mdx-content.ts` being extended
2447
+ * to capture `<Render file="…">` references.
2448
+ */
2449
+ const TRACKED_DIRS = ["src", "public"];
2450
+ const TRACKED_FILES = [
2451
+ "astro.config.ts",
2452
+ "astro.config.mts",
2453
+ "astro.config.mjs",
2454
+ "astro.config.cts",
2455
+ "astro.config.js",
2456
+ "package.json",
2457
+ "pnpm-lock.yaml",
2458
+ "package-lock.json",
2459
+ "yarn.lock",
2460
+ "bun.lockb",
2461
+ "tsconfig.json"
2462
+ ];
2463
+ const CONTENT_EXCLUDES = ["src/content"];
2464
+ function sha256Hex(input) {
2465
+ return createHash("sha256").update(input).digest("hex");
2466
+ }
2467
+ /**
2468
+ * Walk `dir` recursively, returning relative paths of every file.
2469
+ * Skips node_modules, dist, .astro, .nimbus, and hidden dirs.
2470
+ */
2471
+ async function walk$1(dir, root) {
2472
+ let entries;
2473
+ try {
2474
+ entries = await readdir(dir, { withFileTypes: true });
2475
+ } catch {
2476
+ return [];
2477
+ }
2478
+ const out = [];
2479
+ for (const entry of entries) {
2480
+ if (entry.name.startsWith(".")) continue;
2481
+ if (entry.name === "node_modules") continue;
2482
+ if (entry.name === "dist") continue;
2483
+ const full = resolve(dir, entry.name);
2484
+ const rel = relative(root, full).split(sep).join("/");
2485
+ if (CONTENT_EXCLUDES.some((ex) => rel === ex || rel.startsWith(ex + "/"))) continue;
2486
+ if (entry.isDirectory()) out.push(...await walk$1(full, root));
2487
+ else if (entry.isFile()) out.push(rel);
2488
+ }
2489
+ return out;
2490
+ }
2491
+ /**
2492
+ * Compute the global hash for the project at `projectRoot`.
2493
+ *
2494
+ * The hash is sha256 over a canonical line-per-file listing followed by
2495
+ * provenance lines for framework + runtime versions:
2496
+ *
2497
+ * FILE\t<rel-path>\t<sha256(file-bytes)>\n
2498
+ * PROVENANCE\t<key>=<value>\n
2499
+ *
2500
+ * Sorted by line so the hash is deterministic across filesystems
2501
+ * (readdir order is not guaranteed).
2502
+ *
2503
+ * Provenance covers:
2504
+ * - Cache layout schemaVersion (bumped when the cache format changes)
2505
+ * - Nimbus framework version
2506
+ * - Astro version (resolved from the project's installed copy)
2507
+ * - Node major version (minor diffs occasionally affect bundling)
2508
+ * - Platform + arch (some asset emission is platform-sensitive)
2509
+ *
2510
+ * Including provenance closes BUG-002 / BUG-003: a framework upgrade
2511
+ * (or Node bump, or OS change) silently changed rendered output but the
2512
+ * old global hash matched, so warm builds served stale entries from a
2513
+ * different version of the world.
2514
+ */
2515
+ async function computeGlobalHash(projectRoot) {
2516
+ const files = [];
2517
+ for (const dir of TRACKED_DIRS) {
2518
+ const abs = resolve(projectRoot, dir);
2519
+ files.push(...await walk$1(abs, projectRoot));
2520
+ }
2521
+ for (const file of TRACKED_FILES) {
2522
+ const abs = resolve(projectRoot, file);
2523
+ try {
2524
+ if ((await stat(abs)).isFile()) files.push(file);
2525
+ } catch {}
2526
+ }
2527
+ files.sort();
2528
+ const lines = [];
2529
+ for (const rel of files) {
2530
+ const bytes = await readFile(resolve(projectRoot, rel));
2531
+ lines.push(`FILE\t${rel}\t${sha256Hex(bytes)}`);
2532
+ }
2533
+ const provenance = await readProvenance(projectRoot);
2534
+ for (const [key, value] of Object.entries(provenance).sort(([a], [b]) => a.localeCompare(b))) lines.push(`PROVENANCE\t${key}=${value}`);
2535
+ return sha256Hex(lines.join("\n"));
2536
+ }
2537
+ /** Cache layout version. Bump when the on-disk cache format changes
2538
+ * incompatibly so old entries never get reused under new framework code. */
2539
+ const CACHE_SCHEMA_VERSION = "2";
2540
+ /**
2541
+ * Read versions from the project's installed deps + the runtime. All
2542
+ * lookups are best-effort: a missing package.json just gets recorded as
2543
+ * "unknown" so the hash still composes, and is still stable across
2544
+ * runs on the same machine.
2545
+ */
2546
+ async function readProvenance(projectRoot) {
2547
+ const out = {
2548
+ schemaVersion: CACHE_SCHEMA_VERSION,
2549
+ nodeMajor: process.versions.node.split(".")[0] ?? "unknown",
2550
+ platform: process.platform,
2551
+ arch: process.arch
2552
+ };
2553
+ out.nimbusVersion = await readDepVersion(projectRoot, "nimbus-docs");
2554
+ out.astroVersion = await readDepVersion(projectRoot, "astro");
2555
+ for (const key of TRACKED_ENV_KEYS) out[`env.${key}`] = process.env[key] ?? "";
2556
+ for (const key of Object.keys(process.env).sort()) if (TRACKED_ENV_PREFIXES.some((p) => key.startsWith(p))) out[`env.${key}`] = process.env[key] ?? "";
2557
+ return out;
2558
+ }
2559
+ const TRACKED_ENV_KEYS = [
2560
+ "NODE_ENV",
2561
+ "MODE",
2562
+ "BASE_URL",
2563
+ "SITE"
2564
+ ];
2565
+ const TRACKED_ENV_PREFIXES = [
2566
+ "PUBLIC_",
2567
+ "VITE_PUBLIC_",
2568
+ "ASTRO_"
2569
+ ];
2570
+ async function readDepVersion(projectRoot, dep) {
2571
+ try {
2572
+ const bytes = await readFile(createRequire(resolve(projectRoot, "package.json")).resolve(`${dep}/package.json`), "utf8");
2573
+ return JSON.parse(bytes).version ?? "unknown";
2574
+ } catch {
2575
+ return "unknown";
2576
+ }
2577
+ }
2578
+ /**
2579
+ * Phase 3 — per-page hash with transitive partial dependencies folded in.
2580
+ *
2581
+ * Same shape as `computePageHash` but additionally absorbs the bytes of
2582
+ * every partial the page transitively embeds. Sorted by path so two
2583
+ * builds with the same dependency set produce the same hash regardless
2584
+ * of discovery order.
2585
+ *
2586
+ * Paths are made *relative to projectRoot* before hashing — without this,
2587
+ * absolute paths like `/runner/work/run-N/...` change between CI runs
2588
+ * (ephemeral checkout dirs) and every page hash misses, neutralising the
2589
+ * cache. The path-in-hash detects rename-within-the-project; absolute
2590
+ * prefix differences across machines don't.
2591
+ */
2592
+ function computePageHashWithPartials(pageBytes, globalHash, partialPaths, partialBytesByPath, projectRoot) {
2593
+ const h = createHash("sha256");
2594
+ h.update(globalHash);
2595
+ h.update("\n");
2596
+ h.update(pageBytes);
2597
+ for (const absPath of partialPaths) {
2598
+ const relPath = relative(projectRoot, absPath).split(sep).join("/");
2599
+ const bytes = partialBytesByPath.get(absPath);
2600
+ h.update("\0");
2601
+ h.update(relPath);
2602
+ h.update("\0");
2603
+ if (bytes) h.update(bytes);
2604
+ }
2605
+ return h.digest("hex");
2606
+ }
2607
+
2608
+ //#endregion
2609
+ //#region src/_internal/incremental/namespace.ts
2610
+ /**
2611
+ * Cache namespace resolution.
2612
+ *
2613
+ * Why: PR builds and main builds sharing a cache directory cross-contaminate
2614
+ * — a PR build can reuse stale entries written by main, and vice versa.
2615
+ * Without an explicit namespace, the only mitigation is `nimbus-docs clean`
2616
+ * between branches, which authors forget and CI doesn't enforce.
2617
+ *
2618
+ * Resolution order (first match wins):
2619
+ *
2620
+ * 1. `NIMBUS_CACHE_NAMESPACE` env var — explicit override for users who
2621
+ * need a custom scheme (e.g. preview-vs-prod, or sharing one cache
2622
+ * across multiple branches deliberately).
2623
+ * 2. `GITHUB_REF` — GitHub Actions sets this on every workflow run.
2624
+ * `refs/heads/main`, `refs/pull/123/merge`, etc. Distinguishes PRs
2625
+ * from main without any per-repo setup.
2626
+ * 3. Local git branch via `git rev-parse --abbrev-ref HEAD`.
2627
+ * 4. `"default"` — fallback for detached HEAD, non-git checkouts, or
2628
+ * anything else the prior steps couldn't resolve.
2629
+ *
2630
+ * The resolved namespace lands in the manifest and is compared on warm
2631
+ * build. A mismatch is treated like a global-hash mismatch: full cold
2632
+ * rebuild, no per-page hit attempts.
2633
+ *
2634
+ * On-disk layout stays single-namespace (`.nimbus/cache/`). Switching
2635
+ * branches loses the prior namespace's cache; users running multi-branch
2636
+ * workflows can preserve per-branch cache via standard CI cache-key
2637
+ * conventions (`actions/cache` keyed on branch name).
2638
+ */
2639
+ const execFileP = promisify(execFile);
2640
+ async function resolveCacheNamespace(projectRoot) {
2641
+ const env = process.env.NIMBUS_CACHE_NAMESPACE?.trim();
2642
+ if (env) return env;
2643
+ const ghRef = process.env.GITHUB_REF?.trim();
2644
+ if (ghRef) return ghRef;
2645
+ try {
2646
+ const { stdout } = await execFileP("git", [
2647
+ "rev-parse",
2648
+ "--abbrev-ref",
2649
+ "HEAD"
2650
+ ], {
2651
+ cwd: projectRoot,
2652
+ timeout: 2e3
2653
+ });
2654
+ const branch = stdout.trim();
2655
+ if (branch && branch !== "HEAD") return branch;
2656
+ } catch {}
2657
+ return "default";
2658
+ }
2659
+
2660
+ //#endregion
2661
+ //#region src/_internal/incremental/partial-refs.ts
2662
+ /**
2663
+ * Phase 3 — partial dependency tracking.
2664
+ *
2665
+ * Walks MDX content to find `<Render file="…" />` and `<Render slug="…" />`
2666
+ * references, then builds a per-page transitive closure: "pathname X
2667
+ * embeds partials A, B, C — where A in turn embeds D, and B in turn
2668
+ * embeds E and F." Folding all of those partials' bytes into the page's
2669
+ * hash gives us the property the spec promises: edit one partial, exactly
2670
+ * the pages that transitively embed it re-render.
2671
+ *
2672
+ * Scope (v1):
2673
+ * - Only string-literal `file` / `slug` props get captured. Dynamic
2674
+ * `file={var}` references aren't extractable from regex; partials
2675
+ * reached that way will silently miss invalidation. Documented as a
2676
+ * v1 limitation; the `partialResolver` hook (deferred) gives sites
2677
+ * an escape valve.
2678
+ * - Default resolver: `<Render file="topic/slug" />` resolves to
2679
+ * `src/content/partials/topic/slug.mdx`. Matches the bench, apps/www,
2680
+ * and mvvmm's PR shape for cloudflare-docs (their resolver also
2681
+ * prepends a `product` prop — that needs a custom resolver).
2682
+ * - Cycles in the partial graph are handled (visited set).
2683
+ */
2684
+ /**
2685
+ * Check `candidate` is a normalised path under `rootWithSep`. Cheap
2686
+ * defense against `../` traversal escaping the partials root. We use a
2687
+ * trailing-sep marker on root to avoid false-matching `partialsRoot` with
2688
+ * `partialsRoot-evil/` style siblings.
2689
+ */
2690
+ function isInside(candidate, rootWithSep) {
2691
+ return candidate.startsWith(rootWithSep) || candidate === rootWithSep.slice(0, -1);
2692
+ }
2693
+ const COMPONENT_OPEN_RE = /<([A-Z][A-Za-z0-9_]*)\s+([^>]*?)\/?\s*>/g;
2694
+ const ATTR_RE = /([a-zA-Z][a-zA-Z0-9_]*)\s*=\s*["']([^"']*)["']/g;
2695
+ /**
2696
+ * Default partial resolver: `<Render file="topic/slug" />` (or `slug=`)
2697
+ * → `<projectRoot>/<partialsBase>/topic/slug.{mdx,md}`. Sites using a
2698
+ * different convention (multi-prop, parent product, etc.) pass their own
2699
+ * resolver via `nimbus(config, { partialResolver: ... })`.
2700
+ *
2701
+ * Extension handling:
2702
+ * - The incoming `file`/`slug` value gets its `.mdx` or `.md` extension
2703
+ * stripped so authors can write `<Render file="x.mdx" />` without
2704
+ * producing `x.mdx.mdx`.
2705
+ * - The resolver returns `.mdx` by default. The registry builder calls
2706
+ * `resolvePartialPath` below to try `.mdx` first and fall back to
2707
+ * `.md` if the `.mdx` file doesn't exist — handles sites that mix
2708
+ * extensions or use plain Markdown for partials.
2709
+ *
2710
+ * `partialsBase` lets callers point the resolver at a non-default partials
2711
+ * collection base (closes BUG-040). Default: `src/content/partials`.
2712
+ */
2713
+ function makeDefaultPartialResolver(projectRoot, partialsBase = "src/content/partials") {
2714
+ const partialsRoot = resolve(projectRoot, partialsBase);
2715
+ return (name, props) => {
2716
+ if (name !== "Render") return null;
2717
+ const id = props.file ?? props.slug;
2718
+ if (!id) return null;
2719
+ return resolve(partialsRoot, `${id.replace(/^\/+/, "").replace(/\.(mdx|md)$/, "")}.mdx`);
2720
+ };
2721
+ }
2722
+ /**
2723
+ * Try a resolved partial path as `.mdx`, then fall back to `.md`. Returns
2724
+ * the path that actually exists on disk, or null. Used by the registry
2725
+ * builder so `.md` partials work even though the default resolver returns
2726
+ * `.mdx` for ergonomics.
2727
+ */
2728
+ async function resolvePartialPath(candidatePath) {
2729
+ try {
2730
+ await stat(candidatePath);
2731
+ return candidatePath;
2732
+ } catch {
2733
+ if (candidatePath.endsWith(".mdx")) {
2734
+ const mdPath = candidatePath.slice(0, -4) + ".md";
2735
+ try {
2736
+ await stat(mdPath);
2737
+ return mdPath;
2738
+ } catch {
2739
+ return null;
2740
+ }
2741
+ }
2742
+ return null;
2743
+ }
2744
+ }
2745
+ /**
2746
+ * Extract every PascalCase component opening tag from MDX content along
2747
+ * with its string-literal props. Dynamic-value attributes (`file={var}`)
2748
+ * aren't extracted by design — they can't be statically analysed without
2749
+ * a full MDX AST pass.
2750
+ */
2751
+ function extractComponentRefs(mdxContent) {
2752
+ const refs = [];
2753
+ for (const m of mdxContent.matchAll(COMPONENT_OPEN_RE)) {
2754
+ const name = m[1];
2755
+ const propsBlob = m[2] ?? "";
2756
+ const props = {};
2757
+ for (const am of propsBlob.matchAll(ATTR_RE)) props[am[1]] = am[2];
2758
+ refs.push({
2759
+ name,
2760
+ props
2761
+ });
2762
+ }
2763
+ return refs;
2764
+ }
2765
+ async function* walkPartials(partialsRoot) {
2766
+ let entries;
2767
+ try {
2768
+ entries = await readdir(partialsRoot, { withFileTypes: true });
2769
+ } catch {
2770
+ return;
2771
+ }
2772
+ for (const entry of entries) {
2773
+ if (entry.name.startsWith(".")) continue;
2774
+ const full = resolve(partialsRoot, entry.name);
2775
+ if (entry.isDirectory()) yield* walkPartials(full);
2776
+ else if (entry.isFile() && [".mdx", ".md"].includes(extname(entry.name))) yield full;
2777
+ }
2778
+ }
2779
+ /**
2780
+ * Build the per-page transitive partial registry.
2781
+ *
2782
+ * Algorithm:
2783
+ * 1. Walk `src/content/partials/`, hash each file's bytes, record direct
2784
+ * partial → partial references from its content.
2785
+ * 2. Topologically expand the partial → partial graph into a per-partial
2786
+ * transitive-set map (with cycle protection).
2787
+ * 3. For each page, extract its direct partial refs, then union their
2788
+ * transitive sets into the page's full transitive partial set.
2789
+ */
2790
+ async function buildPartialRegistry(projectRoot, pageBytesByPathname, resolver, partialsBase = "src/content/partials") {
2791
+ const partialsRoot = resolve(projectRoot, partialsBase);
2792
+ const partialsRootWithSep = partialsRoot + sep;
2793
+ const partialBytes = /* @__PURE__ */ new Map();
2794
+ const partialDirectRefs = /* @__PURE__ */ new Map();
2795
+ async function resolveWithFallback(name, props) {
2796
+ const candidate = resolver(name, props);
2797
+ if (!candidate) return null;
2798
+ if (!isInside(candidate, partialsRootWithSep)) return null;
2799
+ return resolvePartialPath(candidate);
2800
+ }
2801
+ for await (const filePath of walkPartials(partialsRoot)) {
2802
+ const bytes = await readFile(filePath);
2803
+ partialBytes.set(filePath, bytes);
2804
+ const refs = extractComponentRefs(bytes.toString("utf8"));
2805
+ const resolved = [];
2806
+ for (const ref of refs) {
2807
+ const r = await resolveWithFallback(ref.name, ref.props);
2808
+ if (r) resolved.push(r);
2809
+ }
2810
+ partialDirectRefs.set(filePath, resolved);
2811
+ }
2812
+ const transitiveForPartial = /* @__PURE__ */ new Map();
2813
+ function computeTransitive(start) {
2814
+ const cached = transitiveForPartial.get(start);
2815
+ if (cached) return cached;
2816
+ const visited = /* @__PURE__ */ new Set();
2817
+ const stack = [start];
2818
+ while (stack.length > 0) {
2819
+ const node = stack.pop();
2820
+ if (visited.has(node)) continue;
2821
+ visited.add(node);
2822
+ const direct = partialDirectRefs.get(node) ?? [];
2823
+ for (const d of direct) if (!visited.has(d)) stack.push(d);
2824
+ }
2825
+ transitiveForPartial.set(start, visited);
2826
+ return visited;
2827
+ }
2828
+ for (const p of partialBytes.keys()) computeTransitive(p);
2829
+ const transitiveByPathname = /* @__PURE__ */ new Map();
2830
+ let pagesWithPartials = 0;
2831
+ let totalTransitiveRefs = 0;
2832
+ for (const [pathname, bytes] of pageBytesByPathname) {
2833
+ const directRefs = extractComponentRefs(bytes.toString("utf8"));
2834
+ if (directRefs.length === 0) {
2835
+ transitiveByPathname.set(pathname, []);
2836
+ continue;
2837
+ }
2838
+ const allTransitive = /* @__PURE__ */ new Set();
2839
+ for (const ref of directRefs) {
2840
+ const candidate = resolver(ref.name, ref.props);
2841
+ if (!candidate) continue;
2842
+ if (!isInside(candidate, partialsRootWithSep)) continue;
2843
+ const resolved = await resolvePartialPath(candidate) ?? candidate;
2844
+ const trans = transitiveForPartial.get(resolved);
2845
+ if (trans) for (const t of trans) allTransitive.add(t);
2846
+ else allTransitive.add(resolved);
2847
+ }
2848
+ const sorted = Array.from(allTransitive).sort();
2849
+ transitiveByPathname.set(pathname, sorted);
2850
+ if (sorted.length > 0) {
2851
+ pagesWithPartials++;
2852
+ totalTransitiveRefs += sorted.length;
2853
+ }
2854
+ }
2855
+ return {
2856
+ transitiveByPathname,
2857
+ partialBytes,
2858
+ stats: {
2859
+ partialCount: partialBytes.size,
2860
+ pagesWithPartials,
2861
+ totalTransitiveRefs
2862
+ }
2863
+ };
2864
+ }
2865
+ /**
2866
+ * Best-effort: just confirms the partials directory is present. Used for
2867
+ * skipping the registry build when a site has no partials at all.
2868
+ */
2869
+ async function partialsDirExists(projectRoot, partialsBase = "src/content/partials") {
2870
+ try {
2871
+ return (await stat(resolve(projectRoot, partialsBase))).isDirectory();
2872
+ } catch {
2873
+ return false;
2874
+ }
2875
+ }
2876
+
2877
+ //#endregion
2878
+ //#region src/_internal/incremental/index.ts
2879
+ /**
2880
+ * Incremental builds — Phase 2 MVP.
2881
+ *
2882
+ * Wires the cache layer into Astro's prerenderer. On warm build, pages whose
2883
+ * source bytes (and the global hash) haven't changed since the last build
2884
+ * return cached HTML directly from `prerenderer.render`; pages that did
2885
+ * change render normally and persist their output to the cache.
2886
+ *
2887
+ * Astro sees every route in `getStaticPaths` either way — cache hits flow
2888
+ * through `astro:build:generated`, adapter writers, route-headers accounting
2889
+ * exactly like fresh renders. This is the spec's design, *not* mvvmm's
2890
+ * `getStaticPaths`-filtering approach, because the latter hides cached
2891
+ * routes from downstream hooks.
2892
+ *
2893
+ * Anti-goals for this MVP (deferred to Phase 3+):
2894
+ * - Partial-dependency tracking. Edit a partial → still full rebuild today.
2895
+ * - Data-collection scoping.
2896
+ * - Component-graph tracking. Any tracked-file change → full rebuild.
2897
+ * - Provenance / namespacing / trust boundary. Hardening track.
2898
+ * - `nimbus build --explain` and structured build reports. Console log only.
2899
+ */
2900
+ /**
2901
+ * Normalise a request URL to its canonical pathname (no trailing slash,
2902
+ * except "/"). Astro builds use trailing-slash format by default; cache
2903
+ * keys are stripped so both shapes match.
2904
+ */
2905
+ function canonicalisePathname(input) {
2906
+ let p = input;
2907
+ const q = p.indexOf("?");
2908
+ if (q >= 0) p = p.slice(0, q);
2909
+ const h = p.indexOf("#");
2910
+ if (h >= 0) p = p.slice(0, h);
2911
+ if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
2912
+ if (p.length === 0) p = "/";
2913
+ return p;
2914
+ }
2915
+ /**
2916
+ * Build a map from pathname → MDX file bytes by walking the docs collection
2917
+ * directory. Phase 2 MVP only handles the primary `docs` collection.
2918
+ *
2919
+ * Pathname derivation: `src/content/docs/<entry.id>.mdx` → `/<entry.id>`,
2920
+ * mirroring `getDocsStaticPaths` which uses `entry.id` verbatim as slug.
2921
+ */
2922
+ /**
2923
+ * Normalise whatever `parseCollectionBases` returned for a collection
2924
+ * into a projectRoot-relative folder spec. Handles three shapes the
2925
+ * user might write:
2926
+ *
2927
+ * base: "shared" → src/content/shared
2928
+ * base: "./src/content/shared" → src/content/shared
2929
+ * base: "/abs/path/to/partials" → preserved (absolute)
2930
+ *
2931
+ * Falls back to `src/content/<defaultFolder>` if the input is empty.
2932
+ */
2933
+ function resolveCollectionBase(projectRoot, raw, defaultFolder) {
2934
+ if (!raw) return `src/content/${defaultFolder}`;
2935
+ if (raw.startsWith("/")) return raw;
2936
+ if (raw.includes("/")) return raw.replace(/^\.\/+/, "");
2937
+ return `src/content/${raw}`;
2938
+ }
2939
+ /**
2940
+ * Pick between the derived collection base and a safe fallback by
2941
+ * checking which actually has matching `.mdx` / `.md` files on disk.
2942
+ * Protects against `parseCollectionBases` mis-attributing across
2943
+ * collections when users hand-roll `defineCollection({ loader:
2944
+ * glob({ base: "…" }) })` — the regex parser can't always tell which
2945
+ * `base:` belongs to which entry.
2946
+ *
2947
+ * Preference order:
2948
+ * 1. If derived === fallback, return either.
2949
+ * 2. If only one of (derived, fallback) has content, return that.
2950
+ * 3. If both have content, prefer fallback — the conventional path is
2951
+ * more trustworthy than a regex-derived guess that could be wrong.
2952
+ * Sites with intentionally non-default bases get matched in (2)
2953
+ * because their default path doesn't exist.
2954
+ * 4. Neither has content: return derived (caller treats as absent).
2955
+ */
2956
+ async function pickCollectionBase(projectRoot, derived, fallback) {
2957
+ if (derived === fallback) return derived;
2958
+ const derivedAbs = resolve(projectRoot, derived);
2959
+ const fallbackAbs = resolve(projectRoot, fallback);
2960
+ const derivedHas = await hasContent(derivedAbs);
2961
+ const fallbackHas = await hasContent(fallbackAbs);
2962
+ if (derivedHas && !fallbackHas) return derived;
2963
+ if (!derivedHas && fallbackHas) return fallback;
2964
+ if (derivedHas && fallbackHas) return fallback;
2965
+ return derived;
2966
+ }
2967
+ async function hasContent(dir) {
2968
+ try {
2969
+ const entries = await readdir(dir, { withFileTypes: true });
2970
+ for (const e of entries) {
2971
+ if (e.isFile() && [".mdx", ".md"].includes(extname(e.name))) return true;
2972
+ if (e.isDirectory()) {
2973
+ if (await hasContent(resolve(dir, e.name))) return true;
2974
+ }
2975
+ }
2976
+ } catch {
2977
+ return false;
2978
+ }
2979
+ return false;
2980
+ }
2981
+ async function collectDocsPages(projectRoot, docsBase = "src/content/docs") {
2982
+ const docsRoot = resolve(projectRoot, docsBase);
2983
+ const bytesByPathname = /* @__PURE__ */ new Map();
2984
+ const filePathByPathname = /* @__PURE__ */ new Map();
2985
+ async function walk(dir) {
2986
+ let entries;
2987
+ try {
2988
+ entries = await readdir(dir, { withFileTypes: true });
2989
+ } catch {
2990
+ return;
2991
+ }
2992
+ for (const entry of entries) {
2993
+ const full = resolve(dir, entry.name);
2994
+ if (entry.isDirectory()) await walk(full);
2995
+ else if (entry.isFile() && [".mdx", ".md"].includes(extname(entry.name))) {
2996
+ const bytes = await readFile(full);
2997
+ const entryId = relative(docsRoot, full).split(sep).join("/").replace(/\.(mdx|md)$/, "");
2998
+ const pathname = entryId === "index" ? "/index" : canonicalisePathname(canonicalEntryUrl("", entryId));
2999
+ bytesByPathname.set(pathname, bytes);
3000
+ filePathByPathname.set(pathname, full);
3001
+ }
3002
+ }
3003
+ }
3004
+ await walk(docsRoot);
3005
+ return {
3006
+ bytesByPathname,
3007
+ filePathByPathname
3008
+ };
3009
+ }
3010
+ /**
3011
+ * Set up the cache context for this build. Called at astro:build:start.
3012
+ * Computes per-page hashes, reads prior manifest, determines which pages
3013
+ * are cache-hits.
3014
+ *
3015
+ * Phase 3 — the page hash includes the bytes of every partial the page
3016
+ * transitively embeds, so editing a partial invalidates exactly the pages
3017
+ * that reference it (directly or transitively) and nothing else.
3018
+ */
3019
+ async function setupIncrementalContext(projectRoot, logger, partialResolver) {
3020
+ const cache = new Cache(projectRoot);
3021
+ const globalHash = await computeGlobalHash(projectRoot);
3022
+ const namespace = await resolveCacheNamespace(projectRoot);
3023
+ const priorManifest = await cache.readManifest();
3024
+ const bases = await parseCollectionBases(resolve(projectRoot, "src/content.config.ts"));
3025
+ const { bytesByPathname, filePathByPathname } = await collectDocsPages(projectRoot, await pickCollectionBase(projectRoot, resolveCollectionBase(projectRoot, bases?.get("docs") ?? "docs", "docs"), "src/content/docs"));
3026
+ const partialsBase = await pickCollectionBase(projectRoot, resolveCollectionBase(projectRoot, bases?.get("partials") ?? "partials", "partials"), "src/content/partials");
3027
+ const resolver = partialResolver ?? makeDefaultPartialResolver(projectRoot, partialsBase);
3028
+ const registry = await partialsDirExists(projectRoot, partialsBase) ? await buildPartialRegistry(projectRoot, bytesByPathname, resolver, partialsBase) : {
3029
+ transitiveByPathname: /* @__PURE__ */ new Map(),
3030
+ partialBytes: /* @__PURE__ */ new Map(),
3031
+ stats: {
3032
+ partialCount: 0,
3033
+ pagesWithPartials: 0,
3034
+ totalTransitiveRefs: 0
3035
+ }
3036
+ };
3037
+ if (registry.stats.partialCount > 0) logger.info(`[incremental] partial registry: ${registry.stats.partialCount} partials, ${registry.stats.pagesWithPartials} pages reference at least one`);
3038
+ const pageHashByPathname = /* @__PURE__ */ new Map();
3039
+ for (const [pathname, bytes] of bytesByPathname) {
3040
+ const transitive = registry.transitiveByPathname.get(pathname) ?? [];
3041
+ pageHashByPathname.set(pathname, computePageHashWithPartials(bytes, globalHash, transitive, registry.partialBytes, projectRoot));
3042
+ }
3043
+ const cacheableHits = /* @__PURE__ */ new Set();
3044
+ const namespaceChanged = priorManifest != null && priorManifest.namespace !== namespace;
3045
+ const globalChanged = !priorManifest || priorManifest.globalHash !== globalHash;
3046
+ if (!globalChanged && !namespaceChanged && priorManifest != null) {
3047
+ for (const [pathname, hash] of pageHashByPathname) if (priorManifest.pages[pathname] === hash && await cache.hasPage(hash)) cacheableHits.add(pathname);
3048
+ }
3049
+ logger.info(`[incremental] cache namespace: ${namespace}`);
3050
+ if (namespaceChanged) logger.info(`[incremental] namespace changed (${priorManifest.namespace} → ${namespace}) — full rebuild`);
3051
+ else if (globalChanged) logger.info(priorManifest ? "[incremental] global hash changed — full rebuild" : "[incremental] no prior cache — full cold build");
3052
+ else logger.info(`[incremental] ${cacheableHits.size} cache hits / ${pageHashByPathname.size} pages`);
3053
+ const persistedHashes = /* @__PURE__ */ new Set();
3054
+ for (const pathname of cacheableHits) {
3055
+ const h = pageHashByPathname.get(pathname);
3056
+ if (h) persistedHashes.add(h);
3057
+ }
3058
+ return {
3059
+ namespace,
3060
+ globalHash,
3061
+ pageHashByPathname,
3062
+ cacheableHits,
3063
+ cache,
3064
+ persistedHashes,
3065
+ stats: {
3066
+ hits: 0,
3067
+ misses: 0,
3068
+ persisted: 0
3069
+ },
3070
+ logger,
3071
+ filePathByPathname
3072
+ };
3073
+ }
3074
+ /**
3075
+ * Wrap an Astro prerenderer with the cache.
3076
+ *
3077
+ * Strategy (mvvmm-style — chosen empirically over the "wrap Response" approach
3078
+ * because Astro's per-route work outside `render` is the actual dominant cost,
3079
+ * not MDX→HTML conversion):
3080
+ *
3081
+ * - `getStaticPaths` is filtered to dirty routes (cache misses) only.
3082
+ * Cached routes never enter Astro's render pipeline — Astro skips their
3083
+ * Vite bundling, their per-route emission overhead, everything.
3084
+ * - `render` only sees dirty routes. It renders normally and persists the
3085
+ * output to cache.
3086
+ * - After the build, `restoreCachedPagesToDist` copies cached HTML into
3087
+ * `dist/<pathname>/index.html` for the filtered cached routes — Astro
3088
+ * never wrote them, so we do.
3089
+ *
3090
+ * Trade-off vs. the spec's "wrap Response in render" design: downstream
3091
+ * Astro hooks (`astro:build:generated`, adapter writers, route accounting)
3092
+ * don't see cached routes. For Cloudflare adapter sites or anything that
3093
+ * depends on every route being visible to those hooks, this matters.
3094
+ * For static SSG sites where the rendered HTML *is* the output, it's fine.
3095
+ * Documented as a limitation in Phase 5 user-facing notes.
3096
+ */
3097
+ function wrapPrerenderer(defaultPrerenderer, ctx) {
3098
+ return {
3099
+ ...defaultPrerenderer,
3100
+ name: `${defaultPrerenderer.name}+nimbus-incremental`,
3101
+ async getStaticPaths() {
3102
+ const all = await defaultPrerenderer.getStaticPaths();
3103
+ const dirty = all.filter((p) => !ctx.cacheableHits.has(canonicalisePathname(p.pathname)));
3104
+ ctx.logger.info(`[incremental] filtered ${all.length - dirty.length} cached routes from render; ${dirty.length} to build`);
3105
+ return dirty;
3106
+ },
3107
+ async render(request, opts) {
3108
+ const pathname = canonicalisePathname(new URL(request.url).pathname);
3109
+ const hash = ctx.pageHashByPathname.get(pathname);
3110
+ ctx.stats.misses++;
3111
+ const response = await defaultPrerenderer.render(request, opts);
3112
+ if (hash && response.ok) try {
3113
+ const text = await response.clone().text();
3114
+ await ctx.cache.writePage(hash, text);
3115
+ ctx.persistedHashes.add(hash);
3116
+ ctx.stats.persisted++;
3117
+ } catch (err) {
3118
+ ctx.logger.warn(`[incremental] failed to persist ${pathname}: ${err.message}`);
3119
+ }
3120
+ return response;
3121
+ }
3122
+ };
3123
+ }
3124
+ /**
3125
+ * Copy cached HTML for filtered routes into `dist/`. Run at astro:build:done.
3126
+ *
3127
+ * Pathname → file mapping assumes `directory` build format (Astro default):
3128
+ * `/foo/bar` → `dist/foo/bar/index.html`
3129
+ * `/` → `dist/index.html`
3130
+ */
3131
+ async function restoreCachedPagesToDist(ctx, outDir) {
3132
+ const astroDir = resolve(outDir, "_astro");
3133
+ const restoredAssets = await ctx.cache.restoreAssets(astroDir, (path, err) => {
3134
+ ctx.logger.warn(`[incremental] failed to restore asset ${path}: ${err.message}`);
3135
+ });
3136
+ if (restoredAssets > 0) ctx.logger.info(`[incremental] restored ${restoredAssets} cached asset files`);
3137
+ const failedRestores = /* @__PURE__ */ new Set();
3138
+ for (const pathname of ctx.cacheableHits) {
3139
+ const hash = ctx.pageHashByPathname.get(pathname);
3140
+ if (!hash) {
3141
+ failedRestores.add(pathname);
3142
+ continue;
3143
+ }
3144
+ const html = await ctx.cache.readPage(hash);
3145
+ if (html === null) {
3146
+ ctx.logger.warn(`[incremental] cached file missing for ${pathname} (hash ${hash.slice(0, 8)}) — dropping from output`);
3147
+ failedRestores.add(pathname);
3148
+ ctx.persistedHashes.delete(hash);
3149
+ continue;
3150
+ }
3151
+ const target = resolve(outDir, pathname === "/" ? "index.html" : `${pathname.slice(1)}/index.html`);
3152
+ try {
3153
+ await mkdir(dirname(target), { recursive: true });
3154
+ await writeFile(target, html, "utf8");
3155
+ ctx.stats.hits++;
3156
+ } catch (err) {
3157
+ ctx.logger.warn(`[incremental] failed to restore ${pathname}: ${err.message}`);
3158
+ failedRestores.add(pathname);
3159
+ ctx.persistedHashes.delete(hash);
3160
+ }
3161
+ }
3162
+ for (const failed of failedRestores) ctx.cacheableHits.delete(failed);
3163
+ }
3164
+ /**
3165
+ * Snapshot the just-built `dist/_astro/` into the cache so future warm
3166
+ * builds can restore asset bundles that this build's HTML references.
3167
+ *
3168
+ * Called at astro:build:done, AFTER any restored bundles have been placed
3169
+ * (so the snapshot is the union of fresh + previously-cached assets the
3170
+ * cached HTML still references).
3171
+ *
3172
+ * BUG-007 fix: bounded to assets actually referenced by cached HTML. We
3173
+ * walk every cached page's bytes, regex-extract `/_astro/...` URLs,
3174
+ * dedupe — and only persist those. Without this the snapshot grew
3175
+ * unboundedly because vite produces new bundle hashes on every warm
3176
+ * build (different module graph → different chunks).
3177
+ */
3178
+ async function snapshotAssetsToCache(ctx, outDir) {
3179
+ const astroDir = resolve(outDir, "_astro");
3180
+ const referencedRelPaths = await collectReferencedAssets(ctx, outDir);
3181
+ const n = await ctx.cache.snapshotAssets(astroDir, referencedRelPaths);
3182
+ if (n > 0) ctx.logger.info(`[incremental] snapshotted ${n} referenced asset files to cache`);
3183
+ }
3184
+ const ASSET_REF_RE = /\/_astro\/([^"')\s>]+)/g;
3185
+ /**
3186
+ * Strip query string and hash from an extracted asset path. Without
3187
+ * this, `/_astro/foo.js?v=1` would record `foo.js?v=1` as the file
3188
+ * name — the snapshot would skip it because no such file exists in
3189
+ * `_astro/`, leaving the warm build with a broken reference (BUG-108).
3190
+ */
3191
+ function normaliseAssetRef(raw) {
3192
+ if (!raw) return null;
3193
+ const q = raw.indexOf("?");
3194
+ const h = raw.indexOf("#");
3195
+ let end = raw.length;
3196
+ if (q >= 0 && q < end) end = q;
3197
+ if (h >= 0 && h < end) end = h;
3198
+ const path = raw.slice(0, end);
3199
+ return path.length > 0 ? path : null;
3200
+ }
3201
+ /**
3202
+ * Scan every cached HTML page on disk for `/_astro/...` references.
3203
+ * Returns the set of rel-paths (e.g. `BaseLayout.C1SNDqdc.css`) every
3204
+ * cache hit will need restored on future warm builds. Used to bound the
3205
+ * asset snapshot.
3206
+ *
3207
+ * The single regex matches `/_astro/...` anywhere in the HTML —
3208
+ * straightforward for `href="..."`, `src="..."`, `url(...)` in inline
3209
+ * styles, and individual `srcset` URLs alike. (BUG-107 was about the
3210
+ * earlier regex anchoring on a quote/paren prefix and missing the
3211
+ * second+nth URL inside a `srcset` value; the unanchored form here
3212
+ * catches them all.)
3213
+ *
3214
+ * We scan the dist output rather than the in-memory cache because dist
3215
+ * is the source of truth for what's currently referenced — after the
3216
+ * cached pages have been restored and fresh pages emitted, dist's HTML
3217
+ * collectively references every asset any warm build will need.
3218
+ */
3219
+ async function collectReferencedAssets(ctx, outDir) {
3220
+ const refs = /* @__PURE__ */ new Set();
3221
+ for (const [pathname, hash] of ctx.pageHashByPathname) {
3222
+ if (!ctx.persistedHashes.has(hash)) continue;
3223
+ const target = resolve(outDir, pathname === "/" ? "index.html" : `${pathname.slice(1)}/index.html`);
3224
+ let content;
3225
+ try {
3226
+ content = await readFile(target, "utf8");
3227
+ } catch {
3228
+ continue;
3229
+ }
3230
+ for (const m of content.matchAll(ASSET_REF_RE)) {
3231
+ const path = normaliseAssetRef(m[1] ?? "");
3232
+ if (path) refs.add(path);
3233
+ }
3234
+ }
3235
+ return refs;
3236
+ }
3237
+ /**
3238
+ * Write the updated manifest. Called at astro:build:done.
3239
+ */
3240
+ async function finaliseIncrementalContext(ctx) {
3241
+ const pages = {};
3242
+ for (const [pathname, hash] of ctx.pageHashByPathname) if (ctx.persistedHashes.has(hash)) pages[pathname] = hash;
3243
+ await ctx.cache.writeManifest({
3244
+ namespace: ctx.namespace,
3245
+ globalHash: ctx.globalHash,
3246
+ pages
3247
+ });
3248
+ ctx.logger.info(`[incremental] ${ctx.stats.hits} hits, ${ctx.stats.misses} misses, ${ctx.stats.persisted} persisted`);
3249
+ }
3250
+
3251
+ //#endregion
3252
+ //#region src/_internal/incremental/mdx-skip-plugin.ts
3253
+ const VIRTUAL_PREFIX = "\0nimbus-stub:";
3254
+ const STUB_MODULE = `// nimbus-incremental: cached entry stub. Layer 3 ensures this is dead code.
3255
+ export const frontmatter = {};
3256
+ export const headings = [];
3257
+ export const file = "";
3258
+ export const url = undefined;
3259
+ export const rawContent = () => "";
3260
+ export const compiledContent = () => "";
3261
+ export function Content() { return null; }
3262
+ export default Content;
3263
+ `;
3264
+ function createMdxSkipContext() {
3265
+ return {
3266
+ cachedAbsolutePaths: /* @__PURE__ */ new Set(),
3267
+ enabled: false
3268
+ };
3269
+ }
3270
+ function mdxSkipPlugin(ctx) {
3271
+ return {
3272
+ name: "nimbus-incremental-mdx-skip",
3273
+ enforce: "pre",
3274
+ async resolveId(source, importer, options) {
3275
+ if (!ctx.enabled) return null;
3276
+ if (!source.endsWith(".mdx")) return null;
3277
+ const resolved = await this.resolve(source, importer, {
3278
+ ...options,
3279
+ skipSelf: true
3280
+ });
3281
+ if (!resolved || resolved.external) return resolved;
3282
+ const absPath = resolved.id.split("?")[0];
3283
+ if (ctx.cachedAbsolutePaths.has(absPath)) return `${VIRTUAL_PREFIX}${absPath.replace(/\.mdx$/, ".cached.js")}`;
3284
+ return resolved;
3285
+ },
3286
+ load(id) {
3287
+ if (id.startsWith(VIRTUAL_PREFIX)) return STUB_MODULE;
3288
+ return null;
3289
+ }
3290
+ };
3291
+ }
3292
+
3293
+ //#endregion
3294
+ //#region src/_internal/incremental/sitemap.ts
3295
+ /**
3296
+ * Layer 4 — sitemap emission.
3297
+ *
3298
+ * When incremental builds are on, the cache layer filters cached routes
3299
+ * from Astro's render pipeline. Downstream integrations that hook
3300
+ * `astro:build:done` (including `@astrojs/sitemap`) only see the dirty
3301
+ * subset in their `pages` argument. The sitemap they emit is missing all
3302
+ * cached routes — broken on every warm build.
3303
+ *
3304
+ * Fix: don't register `@astrojs/sitemap` at all when incremental is on.
3305
+ * Instead, this module emits the sitemap directly from the union of
3306
+ * (Astro's `pages` arg) and (incrementalCtx's cached pathnames).
3307
+ *
3308
+ * Output is **structurally compatible** with `@astrojs/sitemap`'s default
3309
+ * output — same xmlns set, same element shape, same sorted-URL invariant
3310
+ * — but isn't bit-identical to the upstream emitter. Specifically:
3311
+ *
3312
+ * - XML entity escaping uses `&apos;` for single quotes where upstream
3313
+ * uses `&#39;`. Functionally identical; lexically different.
3314
+ * - `@astrojs/sitemap` adds an XML declaration newline upstream's
3315
+ * serializer happens to insert; we don't.
3316
+ *
3317
+ * The shape that DOES hold across cold and warm builds of *this*
3318
+ * emitter is byte-identical (same URL set, same sort, same escape
3319
+ * table). Cold-vs-warm parity is the property the cache layer needs;
3320
+ * upstream-byte-parity is only relevant for sites comparing against
3321
+ * a non-incremental build of `@astrojs/sitemap`.
3322
+ *
3323
+ * Format details:
3324
+ * - One line, no whitespace between elements
3325
+ * - URLs sorted alphabetically by absolute URL
3326
+ * - Directory-format trailing slash (`/foo/` not `/foo`)
3327
+ * - xmlns declarations matching @astrojs/sitemap's set
3328
+ * - sitemap-0.xml carries all entries (we don't split until >45k urls)
3329
+ * - sitemap-index.xml lists sitemap-0.xml only
3330
+ *
3331
+ * v1 scope — no custom `serialize` yet (deferred; cloudflare-docs case),
3332
+ * no `lastmod`, `changefreq`, `priority`, no image/video sitemaps. Matches
3333
+ * `@astrojs/sitemap` *default* output for sites that don't override.
3334
+ */
3335
+ const URLSET_XMLNS = "xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:news=\"http://www.google.com/schemas/sitemap-news/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\" xmlns:image=\"http://www.google.com/schemas/sitemap-image/1.1\" xmlns:video=\"http://www.google.com/schemas/sitemap-video/1.1\"";
3336
+ function trimTrailingSlash(s) {
3337
+ return s.endsWith("/") && s.length > 1 ? s.slice(0, -1) : s;
3338
+ }
3339
+ function ensureTrailingSlash(s) {
3340
+ return s.endsWith("/") ? s : s + "/";
3341
+ }
3342
+ function xmlEscape(s) {
3343
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3344
+ }
3345
+ /**
3346
+ * Build the canonical URL set. Astro's `pages` arg gives us pathnames as
3347
+ * `foo/bar/` style (already trailing-slash for directory format). The
3348
+ * cached pathnames from the incremental context are canonical-form
3349
+ * (no trailing slash). Normalise both, dedupe, sort.
3350
+ */
3351
+ function buildUrlSet(opts) {
3352
+ const siteRoot = trimTrailingSlash(opts.siteUrl);
3353
+ const base = opts.base ? trimTrailingSlash(opts.base) : "";
3354
+ const pathnames = /* @__PURE__ */ new Set();
3355
+ for (const page of opts.builtPages) {
3356
+ const path = ensureTrailingSlash("/" + page.pathname.replace(/^\/+/, ""));
3357
+ pathnames.add(`${siteRoot}${base}${path}`);
3358
+ }
3359
+ for (const cached of opts.cachedPathnames) {
3360
+ const withSlash = cached === "/" ? "/" : ensureTrailingSlash(cached);
3361
+ pathnames.add(`${siteRoot}${base}${withSlash}`);
3362
+ }
3363
+ return Array.from(pathnames).sort();
3364
+ }
3365
+ function renderItem(item) {
3366
+ let inner = `<loc>${xmlEscape(item.url)}</loc>`;
3367
+ if (item.lastmod !== void 0) inner += `<lastmod>${xmlEscape(item.lastmod)}</lastmod>`;
3368
+ if (item.changefreq !== void 0) inner += `<changefreq>${xmlEscape(item.changefreq)}</changefreq>`;
3369
+ if (item.priority !== void 0) inner += `<priority>${item.priority}</priority>`;
3370
+ if (item.links && item.links.length > 0) for (const link of item.links) inner += `<xhtml:link rel="alternate" hreflang="${xmlEscape(link.lang)}" href="${xmlEscape(link.url)}"/>`;
3371
+ return `<url>${inner}</url>`;
3372
+ }
3373
+ async function emitIncrementalSitemap(opts) {
3374
+ const urls = buildUrlSet(opts);
3375
+ if (opts.customPages) for (const extra of opts.customPages) urls.push(extra);
3376
+ if (urls.length === 0) return { urlCount: 0 };
3377
+ urls.sort();
3378
+ const items = [];
3379
+ for (const url of urls) {
3380
+ let item = { url };
3381
+ if (opts.serialize) item = await opts.serialize(item);
3382
+ if (item) items.push(item);
3383
+ }
3384
+ const sitemap0 = `<?xml version="1.0" encoding="UTF-8"?><urlset ${URLSET_XMLNS}>${items.map(renderItem).join("")}</urlset>`;
3385
+ const sitemapIndex = `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>${xmlEscape(`${trimTrailingSlash(opts.siteUrl)}${opts.base ? trimTrailingSlash(opts.base) : ""}/sitemap-0.xml`)}</loc></sitemap></sitemapindex>`;
3386
+ await mkdir(opts.distDir, { recursive: true });
3387
+ await writeFile(resolve(opts.distDir, "sitemap-0.xml"), sitemap0, "utf8");
3388
+ await writeFile(resolve(opts.distDir, "sitemap-index.xml"), sitemapIndex, "utf8");
3389
+ return { urlCount: items.length };
3390
+ }
3391
+
2118
3392
  //#endregion
2119
3393
  //#region src/_internal/scan-version-frontmatter.ts
2120
3394
  /**
@@ -2500,6 +3774,17 @@ function pageUrl(versions, version, slug) {
2500
3774
  * Planned (not shipped):
2501
3775
  * - `/llms.txt` and `/robots.txt` route injection.
2502
3776
  */
3777
+ /**
3778
+ * Common shorthand fences that Shiki doesn't recognise out of the box.
3779
+ * Hoisted to module scope so the code-block-language scanner can apply
3780
+ * the same mapping before passing the result to `shikiConfig.langs`.
3781
+ * Users can extend via Astro's shallow merge of `markdown.shikiConfig`.
3782
+ */
3783
+ const SHIKI_LANG_ALIAS = {
3784
+ curl: "bash",
3785
+ console: "bash",
3786
+ shellsession: "shellscript"
3787
+ };
2503
3788
  function nimbus(rawConfig, options = {}) {
2504
3789
  const config = validateNimbusConfig(rawConfig);
2505
3790
  const lintOptions = validateLintOptions({
@@ -2508,6 +3793,8 @@ function nimbus(rawConfig, options = {}) {
2508
3793
  }, IMPLEMENTED_CODES);
2509
3794
  let projectRootForBuild = "";
2510
3795
  let astroBaseForBuild = "";
3796
+ let incrementalCtx = null;
3797
+ const mdxSkipCtx = createMdxSkipContext();
2511
3798
  return {
2512
3799
  name: "nimbus-docs",
2513
3800
  hooks: {
@@ -2536,6 +3823,7 @@ function nimbus(rawConfig, options = {}) {
2536
3823
  const projectRoot = fileURLToPath(astroConfig.root);
2537
3824
  projectRootForBuild = projectRoot;
2538
3825
  astroBaseForBuild = astroConfig.base ?? "";
3826
+ const codeBlockLangs = await scanCodeBlockLanguages(projectRoot, SHIKI_LANG_ALIAS);
2539
3827
  const contentConfigPath = path.join(projectRoot, "src/content.config.ts");
2540
3828
  const rawCollections = await parseContentCollections(contentConfigPath);
2541
3829
  const collectionBases = await parseCollectionBases(contentConfigPath);
@@ -2582,7 +3870,12 @@ function nimbus(rawConfig, options = {}) {
2582
3870
  versionRedirects = computeMissingPageRedirects(resolved, versionAlternates, versionEntries);
2583
3871
  }
2584
3872
  integrationsToAdd.push(mdx(options.mdx ?? {}));
2585
- if (options.sitemap !== false && Boolean(config.site)) integrationsToAdd.push(sitemap());
3873
+ const wantSitemap = options.sitemap !== false && Boolean(config.site);
3874
+ const sitemapOpts = typeof options.sitemap === "object" ? options.sitemap : void 0;
3875
+ if (wantSitemap && !options.incrementalBuilds) integrationsToAdd.push(sitemap({
3876
+ ...sitemapOpts?.serialize && { serialize: sitemapOpts.serialize },
3877
+ ...sitemapOpts?.customPages && { customPages: sitemapOpts.customPages }
3878
+ }));
2586
3879
  const admonitionVitePlugins = [];
2587
3880
  if (options.admonitions !== false) {
2588
3881
  const admoOpts = typeof options.admonitions === "object" ? options.admonitions : {};
@@ -2606,18 +3899,19 @@ function nimbus(rawConfig, options = {}) {
2606
3899
  },
2607
3900
  defaultColor: false,
2608
3901
  transformers: defaultCodeTransformers(),
2609
- langAlias: {
2610
- curl: "bash",
2611
- console: "bash",
2612
- shellsession: "shellscript"
2613
- }
3902
+ langAlias: SHIKI_LANG_ALIAS,
3903
+ langs: codeBlockLangs
2614
3904
  }
2615
3905
  },
2616
3906
  ...versionRedirects.length > 0 ? { redirects: Object.fromEntries(versionRedirects.map(({ from, to }) => [from, to])) } : {},
2617
- vite: { plugins: [...admonitionVitePlugins, virtualConfigPlugin(config, {
2618
- indexedCollections,
2619
- versionAlternates
2620
- })] }
3907
+ vite: { plugins: [
3908
+ ...admonitionVitePlugins,
3909
+ virtualConfigPlugin(config, {
3910
+ indexedCollections,
3911
+ versionAlternates
3912
+ }),
3913
+ ...options.incrementalBuilds ? [mdxSkipPlugin(mdxSkipCtx)] : []
3914
+ ] }
2621
3915
  });
2622
3916
  },
2623
3917
  "astro:config:done": ({ injectTypes }) => {
@@ -2636,10 +3930,61 @@ function nimbus(rawConfig, options = {}) {
2636
3930
  ].join("\n")
2637
3931
  });
2638
3932
  },
3933
+ "astro:build:start": async ({ setPrerenderer, logger }) => {
3934
+ if (!options.incrementalBuilds) return;
3935
+ if (!projectRootForBuild) {
3936
+ logger.warn("[incremental] project root unknown at build:start; cache disabled this run");
3937
+ return;
3938
+ }
3939
+ incrementalCtx = await setupIncrementalContext(projectRootForBuild, logger, options.partialResolver);
3940
+ mdxSkipCtx.cachedAbsolutePaths.clear();
3941
+ for (const pathname of incrementalCtx.cacheableHits) {
3942
+ const filePath = incrementalCtx.filePathByPathname.get(pathname);
3943
+ if (filePath) mdxSkipCtx.cachedAbsolutePaths.add(filePath);
3944
+ }
3945
+ mdxSkipCtx.enabled = true;
3946
+ logger.info(`[incremental] mdx-skip plugin armed for ${mdxSkipCtx.cachedAbsolutePaths.size} cached MDX files`);
3947
+ setPrerenderer((defaultPrerenderer) => wrapPrerenderer(defaultPrerenderer, incrementalCtx));
3948
+ },
2639
3949
  "astro:build:done": async ({ dir, pages, logger }) => {
2640
- materializeRouteTruthFromPages(projectRootForBuild, astroBaseForBuild, pages, logger);
2641
- if (config.search === false || config.search?.provider === "custom") return;
2642
- await runPagefind(fileURLToPath(dir));
3950
+ if (incrementalCtx) {
3951
+ const distDir = fileURLToPath(dir);
3952
+ await restoreCachedPagesToDist(incrementalCtx, distDir);
3953
+ const fullPagesForTruth = [...pages, ...[...incrementalCtx.cacheableHits].filter((p) => !pages.some((q) => "/" + q.pathname === (p === "/" ? "/" : p + "/"))).map((p) => ({ pathname: p === "/" ? "" : p.slice(1) + "/" }))];
3954
+ materializeRouteTruthFromPages(projectRootForBuild, astroBaseForBuild, fullPagesForTruth, logger);
3955
+ if (options.sitemap !== false && config.site) {
3956
+ const sitemapOptsResolved = typeof options.sitemap === "object" ? options.sitemap : void 0;
3957
+ const result = await emitIncrementalSitemap({
3958
+ siteUrl: config.site,
3959
+ builtPages: pages,
3960
+ cachedPathnames: incrementalCtx.cacheableHits,
3961
+ distDir,
3962
+ base: astroBaseForBuild,
3963
+ serialize: sitemapOptsResolved?.serialize,
3964
+ customPages: sitemapOptsResolved?.customPages
3965
+ });
3966
+ logger.info(`[incremental] sitemap emitted (${result.urlCount} urls)`);
3967
+ }
3968
+ await snapshotAssetsToCache(incrementalCtx, distDir);
3969
+ await finaliseIncrementalContext(incrementalCtx);
3970
+ } else materializeRouteTruthFromPages(projectRootForBuild, astroBaseForBuild, pages, logger);
3971
+ if (config.search === false || config.search?.provider === "custom") {
3972
+ incrementalCtx = null;
3973
+ return;
3974
+ }
3975
+ const distDir = fileURLToPath(dir);
3976
+ const pagefindDistDir = path.join(distDir, "pagefind");
3977
+ if (incrementalCtx !== null && incrementalCtx.stats.misses === 0 && await incrementalCtx.cache.hasPagefindSnapshot()) {
3978
+ const restored = await incrementalCtx.cache.restorePagefind(pagefindDistDir);
3979
+ logger.info(`[incremental] Pagefind skipped — restored ${restored} cached index file(s)`);
3980
+ } else {
3981
+ await runPagefind(distDir);
3982
+ if (incrementalCtx) {
3983
+ const snapped = await incrementalCtx.cache.snapshotPagefind(pagefindDistDir);
3984
+ if (snapped > 0) logger.info(`[incremental] snapshotted ${snapped} Pagefind index file(s) to cache`);
3985
+ }
3986
+ }
3987
+ incrementalCtx = null;
2643
3988
  }
2644
3989
  }
2645
3990
  };