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/cli/index.js +34 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/client.d.ts +12 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +38 -6
- package/dist/client.js.map +1 -1
- package/dist/content.d.ts +4 -4
- package/dist/index.d.ts +86 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1370 -25
- package/dist/index.js.map +1 -1
- package/dist/{rules-B7o0k3TA.js → rules-DDDvKkyJ.js} +2 -1
- package/dist/{rules-B7o0k3TA.js.map → rules-DDDvKkyJ.js.map} +1 -1
- package/dist/schemas.d.ts +2 -2
- package/dist/schemas.d.ts.map +1 -1
- package/package.json +9 -8
package/dist/index.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import { o as validateLintOptions, r as suggest, t as IMPLEMENTED_CODES } from "./rules-
|
|
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)
|
|
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 === "
|
|
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_]*)
|
|
1949
|
-
|
|
1950
|
-
offset
|
|
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 `'` for single quotes where upstream
|
|
3313
|
+
* uses `'`. 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
2618
|
-
|
|
2619
|
-
|
|
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
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
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
|
};
|