nimbus-docs 0.1.8 → 0.1.10

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