nimbus-docs 0.1.7 → 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/README.md +1 -1
- package/dist/cli/index.js +49 -5
- 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 +62 -6
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +11 -5
- package/dist/content.js.map +1 -1
- package/dist/{diagnostic-C6OaBe_o.d.ts → diagnostic-ewiZxpSO.d.ts} +4 -1
- package/dist/diagnostic-ewiZxpSO.d.ts.map +1 -0
- package/dist/index.d.ts +138 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1709 -174
- package/dist/index.js.map +1 -1
- package/dist/react.d.ts +164 -19
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +290 -50
- package/dist/react.js.map +1 -1
- package/dist/{rules-DnAP-j89.js → rules-DDDvKkyJ.js} +250 -2
- package/dist/rules-DDDvKkyJ.js.map +1 -0
- package/dist/schemas.d.ts +87 -4
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +32 -6
- package/dist/schemas.js.map +1 -1
- package/dist/strict-keys-fbKKxxKL.js +141 -0
- package/dist/strict-keys-fbKKxxKL.js.map +1 -0
- package/dist/types.d.ts +49 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -4
- package/CHANGELOG.md +0 -19
- package/dist/diagnostic-C6OaBe_o.d.ts.map +0 -1
- package/dist/rules-DnAP-j89.js.map +0 -1
- package/dist/strict-keys-BiXiT3pq.js +0 -35
- package/dist/strict-keys-BiXiT3pq.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import { o as validateLintOptions, r as suggest, t as IMPLEMENTED_CODES } from "./rules-
|
|
2
|
-
import { t as withStrictKeys } from "./strict-keys-
|
|
1
|
+
import { o as validateLintOptions, r as suggest, t as IMPLEMENTED_CODES } from "./rules-DDDvKkyJ.js";
|
|
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;
|
|
@@ -162,115 +164,10 @@ function canonicalEntryUrl(prefix, entryId) {
|
|
|
162
164
|
return `${prefix}/${slug}`;
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
//#endregion
|
|
166
|
-
//#region src/_internal/url.ts
|
|
167
|
-
/**
|
|
168
|
-
* Internal URL helpers — one shape for matching, one shape for rendering.
|
|
169
|
-
*
|
|
170
|
-
* Static hosts that serve `page/index.html` (Astro's default `build.format:
|
|
171
|
-
* "directory"`) canonicalize to a trailing-slash URL. If framework helpers
|
|
172
|
-
* emit slashless hrefs, every sidebar click costs a 307 redirect before
|
|
173
|
-
* Astro's client router can pick up the page. The fix splits href shape
|
|
174
|
-
* into two forms:
|
|
175
|
-
*
|
|
176
|
-
* - `toRouteKey(href)` — slashless canonical form. Used wherever the
|
|
177
|
-
* framework compares paths for identity (active sidebar state,
|
|
178
|
-
* prev/next lookup, validation against the indexed route set).
|
|
179
|
-
*
|
|
180
|
-
* - `toBrowserHref(href)` — what we emit into `<a href>` / `<link>` for
|
|
181
|
-
* HTML document routes. Adds a trailing slash so the URL matches the
|
|
182
|
-
* directory-index page the host serves directly.
|
|
183
|
-
*
|
|
184
|
-
* Asset URLs (`.md`, `.png`, `.txt`, …), external URLs, and anchor-only
|
|
185
|
-
* hrefs are returned unchanged by `toBrowserHref` — they aren't HTML
|
|
186
|
-
* document routes and adding a slash would break them.
|
|
187
|
-
*
|
|
188
|
-
* Keep these out of the public API: starter components consume hrefs the
|
|
189
|
-
* framework already shaped. Authors don't (and shouldn't) call these
|
|
190
|
-
* directly.
|
|
191
|
-
*/
|
|
192
|
-
/**
|
|
193
|
-
* True for hrefs that point off-site — anything with a URI scheme
|
|
194
|
-
* (`https:`, `mailto:`, `data:`, …) or a protocol-relative `//cdn.…`
|
|
195
|
-
* prefix. Bare relative paths like `"cli"` and `"./foo"` are NOT external
|
|
196
|
-
* — they resolve against the current page and the framework shouldn't
|
|
197
|
-
* second-guess them.
|
|
198
|
-
*/
|
|
199
|
-
function isAbsoluteUrl(href) {
|
|
200
|
-
return /^([a-z][a-z0-9+\-.]*:|\/\/)/i.test(href);
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Detect whether the final path segment looks like a file (has an
|
|
204
|
-
* extension). HTML document routes don't carry an extension under
|
|
205
|
-
* `build.format: "directory"`; assets like `/og/card.png`,
|
|
206
|
-
* `/llms.txt`, and `/cli/index.md` do.
|
|
207
|
-
*
|
|
208
|
-
* Conservative: only treats short, ASCII-letter-only extensions as files,
|
|
209
|
-
* so paths with dots inside a segment (`/v1.2/foo`, version slugs) still
|
|
210
|
-
* count as document routes.
|
|
211
|
-
*/
|
|
212
|
-
function hasFileExtension(pathname) {
|
|
213
|
-
const lastSegment = pathname.slice(pathname.lastIndexOf("/") + 1);
|
|
214
|
-
const dot = lastSegment.lastIndexOf(".");
|
|
215
|
-
if (dot <= 0) return false;
|
|
216
|
-
const ext = lastSegment.slice(dot + 1);
|
|
217
|
-
return ext.length > 0 && ext.length <= 6 && /^[a-zA-Z0-9]+$/.test(ext);
|
|
218
|
-
}
|
|
219
|
-
/** Split an href into `[pathname, suffix]` where `suffix` is the `?…#…` tail. */
|
|
220
|
-
function splitSuffix(href) {
|
|
221
|
-
const queryAt = href.indexOf("?");
|
|
222
|
-
const hashAt = href.indexOf("#");
|
|
223
|
-
const cutAt = queryAt === -1 ? hashAt : hashAt === -1 ? queryAt : Math.min(queryAt, hashAt);
|
|
224
|
-
if (cutAt === -1) return [href, ""];
|
|
225
|
-
return [href.slice(0, cutAt), href.slice(cutAt)];
|
|
226
|
-
}
|
|
227
|
-
/**
|
|
228
|
-
* Slashless canonical form for path comparisons.
|
|
229
|
-
*
|
|
230
|
-
* /cli → /cli
|
|
231
|
-
* /cli/ → /cli
|
|
232
|
-
* /cli/?x=1#y → /cli
|
|
233
|
-
* / → /
|
|
234
|
-
* /guides/setup/ → /guides/setup
|
|
235
|
-
*
|
|
236
|
-
* Strips query and hash so callers can compare two hrefs that differ only
|
|
237
|
-
* in their tail. Root stays `"/"` — that's identity, not a trailing-slash
|
|
238
|
-
* artifact.
|
|
239
|
-
*/
|
|
240
|
-
function toRouteKey(href) {
|
|
241
|
-
const [pathname] = splitSuffix(href);
|
|
242
|
-
if (pathname.length <= 1) return pathname || "/";
|
|
243
|
-
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Trailing-slash form for browser-facing hrefs to HTML document routes.
|
|
247
|
-
* Preserves query and hash; root, external URLs, anchor-only hrefs, and
|
|
248
|
-
* asset URLs (paths with a file extension) are returned unchanged.
|
|
249
|
-
*
|
|
250
|
-
* /cli → /cli/
|
|
251
|
-
* /cli/ → /cli/
|
|
252
|
-
* /cli#install → /cli/#install
|
|
253
|
-
* /cli?v=1 → /cli/?v=1
|
|
254
|
-
* / → /
|
|
255
|
-
* /og/card.png → /og/card.png (asset, unchanged)
|
|
256
|
-
* /cli/index.md → /cli/index.md (asset, unchanged)
|
|
257
|
-
* https://x.com/a → https://x.com/a (external, unchanged)
|
|
258
|
-
* #anchor → #anchor (anchor-only, unchanged)
|
|
259
|
-
*/
|
|
260
|
-
function toBrowserHref(href) {
|
|
261
|
-
if (isAbsoluteUrl(href)) return href;
|
|
262
|
-
if (href.startsWith("#") || href.startsWith("?")) return href;
|
|
263
|
-
if (!href.startsWith("/")) return href;
|
|
264
|
-
const [pathname, suffix] = splitSuffix(href);
|
|
265
|
-
if (pathname === "/") return href;
|
|
266
|
-
if (hasFileExtension(pathname)) return href;
|
|
267
|
-
if (pathname.endsWith("/")) return href;
|
|
268
|
-
return `${pathname}/${suffix}`;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
167
|
//#endregion
|
|
272
168
|
//#region src/_internal/sidebar.ts
|
|
273
169
|
const sortKeyByItem = /* @__PURE__ */ new WeakMap();
|
|
170
|
+
const directoryIndexLinks = /* @__PURE__ */ new WeakSet();
|
|
274
171
|
function sortSidebarItems(a, b) {
|
|
275
172
|
const orderDiff = a.order - b.order;
|
|
276
173
|
if (orderDiff !== 0) return orderDiff;
|
|
@@ -307,18 +204,44 @@ function joinHref(hrefPrefix, entryId) {
|
|
|
307
204
|
return toBrowserHref(canonicalEntryUrl(hrefPrefix.replace(/\/$/, ""), entryId));
|
|
308
205
|
}
|
|
309
206
|
function createLink(entry, currentPath, hrefPrefix = "") {
|
|
310
|
-
const
|
|
207
|
+
const internalHref = joinHref(hrefPrefix, entry.id);
|
|
311
208
|
const badge = entry.data.draft ? entry.data.sidebar?.badge ?? {
|
|
312
209
|
text: "Draft",
|
|
313
210
|
variant: "warning"
|
|
314
211
|
} : entry.data.sidebar?.badge;
|
|
212
|
+
const label = entry.data.sidebar?.label ?? entry.data.title;
|
|
213
|
+
const order = entry.data.sidebar?.order ?? Number.MAX_VALUE;
|
|
214
|
+
const externalLink = entry.data.external_link;
|
|
215
|
+
if (externalLink) {
|
|
216
|
+
if (isAbsoluteUrl(externalLink)) {
|
|
217
|
+
const ext = {
|
|
218
|
+
type: "external",
|
|
219
|
+
label,
|
|
220
|
+
href: externalLink,
|
|
221
|
+
badge,
|
|
222
|
+
order
|
|
223
|
+
};
|
|
224
|
+
sortKeyByItem.set(ext, entry.id);
|
|
225
|
+
return ext;
|
|
226
|
+
}
|
|
227
|
+
const link = {
|
|
228
|
+
type: "link",
|
|
229
|
+
label,
|
|
230
|
+
href: toBrowserHref(externalLink),
|
|
231
|
+
isCurrent: false,
|
|
232
|
+
badge,
|
|
233
|
+
order
|
|
234
|
+
};
|
|
235
|
+
sortKeyByItem.set(link, entry.id);
|
|
236
|
+
return link;
|
|
237
|
+
}
|
|
315
238
|
const link = {
|
|
316
239
|
type: "link",
|
|
317
|
-
label
|
|
318
|
-
href,
|
|
319
|
-
isCurrent: toRouteKey(currentPath) === toRouteKey(
|
|
240
|
+
label,
|
|
241
|
+
href: internalHref,
|
|
242
|
+
isCurrent: toRouteKey(currentPath) === toRouteKey(internalHref),
|
|
320
243
|
badge,
|
|
321
|
-
order
|
|
244
|
+
order
|
|
322
245
|
};
|
|
323
246
|
sortKeyByItem.set(link, entry.id);
|
|
324
247
|
return link;
|
|
@@ -329,8 +252,17 @@ function buildFilesystemTree(entries, currentPath, directory, hrefPrefix = "") {
|
|
|
329
252
|
function buildLevel(parentPath) {
|
|
330
253
|
const result = [];
|
|
331
254
|
const groupsAtLevel = /* @__PURE__ */ new Map();
|
|
255
|
+
if (directory && parentPath === directory) {
|
|
256
|
+
const dirIndex = byId.get(directory);
|
|
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
|
+
}
|
|
262
|
+
}
|
|
332
263
|
for (const entry of scoped) {
|
|
333
264
|
if (entry.id === "index") continue;
|
|
265
|
+
if (entry.id === directory) continue;
|
|
334
266
|
const id = entry.id;
|
|
335
267
|
const relativeTo = directory ?? "";
|
|
336
268
|
const relativeId = relativeTo ? id === relativeTo ? "" : id.slice(relativeTo.length + 1) : id;
|
|
@@ -375,26 +307,40 @@ function buildFilesystemTree(entries, currentPath, directory, hrefPrefix = "") {
|
|
|
375
307
|
for (const [groupPath, group] of groupsAtLevel) {
|
|
376
308
|
const nestedChildren = buildLevel(groupPath);
|
|
377
309
|
group.children = [...group.children, ...nestedChildren].sort(sortSidebarItems);
|
|
378
|
-
if (group.children.length > 0)
|
|
379
|
-
const minChildOrder = Math.min(...group.children.map((item) => item.order));
|
|
380
|
-
group.order = Math.min(group.order, minChildOrder);
|
|
381
|
-
}
|
|
310
|
+
if (group.order === Number.MAX_VALUE && group.children.length > 0) group.order = Math.min(...group.children.map((item) => item.order));
|
|
382
311
|
}
|
|
383
312
|
return result.sort(sortSidebarItems);
|
|
384
313
|
}
|
|
385
314
|
function createGroupFromEntry(dirPath, indexEntry, currentPath, _byId) {
|
|
386
315
|
const dirSegment = dirPath.split("/").pop();
|
|
387
|
-
const
|
|
316
|
+
const groupConfig = indexEntry?.data.sidebar?.group;
|
|
317
|
+
const groupLabel = groupConfig?.label ?? indexEntry?.data.sidebar?.label ?? indexEntry?.data.title ?? formatLabel(dirSegment);
|
|
388
318
|
const groupOrder = indexEntry?.data.sidebar?.order ?? Number.MAX_VALUE;
|
|
389
|
-
const
|
|
390
|
-
|
|
319
|
+
const groupBadge = groupConfig?.badge ?? indexEntry?.data.sidebar?.badge;
|
|
320
|
+
let indexHref;
|
|
321
|
+
let indexIsCurrent = false;
|
|
322
|
+
let indexIsExternal = false;
|
|
323
|
+
if (indexEntry) {
|
|
324
|
+
const externalLink = indexEntry.data.external_link;
|
|
325
|
+
if (externalLink !== void 0) if (isAbsoluteUrl(externalLink)) {
|
|
326
|
+
indexHref = externalLink;
|
|
327
|
+
indexIsExternal = true;
|
|
328
|
+
} else indexHref = toBrowserHref(externalLink);
|
|
329
|
+
else {
|
|
330
|
+
indexHref = joinHref(hrefPrefix, indexEntry.id);
|
|
331
|
+
indexIsCurrent = toRouteKey(currentPath) === toRouteKey(indexHref);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
391
334
|
const group = {
|
|
392
335
|
type: "group",
|
|
393
336
|
label: groupLabel,
|
|
394
337
|
order: groupOrder,
|
|
395
|
-
badge:
|
|
396
|
-
children,
|
|
397
|
-
_indexId: indexEntry?.id
|
|
338
|
+
badge: groupBadge,
|
|
339
|
+
children: [],
|
|
340
|
+
_indexId: indexEntry?.id,
|
|
341
|
+
indexHref,
|
|
342
|
+
indexIsCurrent: indexIsCurrent || void 0,
|
|
343
|
+
indexIsExternal: indexIsExternal || void 0
|
|
398
344
|
};
|
|
399
345
|
sortKeyByItem.set(group, dirPath);
|
|
400
346
|
return group;
|
|
@@ -494,45 +440,80 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
|
|
|
494
440
|
* Return only the children of the top-level group containing the current
|
|
495
441
|
* page. Falls back to the full tree if the current page isn't inside any
|
|
496
442
|
* group (e.g. a top-level link, or a path that doesn't resolve).
|
|
443
|
+
*
|
|
444
|
+
* Under structural separation the group's landing page lives on
|
|
445
|
+
* `indexHref` rather than in `children`. When the active group has a
|
|
446
|
+
* landing, we prepend a synthetic link (or external item) for it so
|
|
447
|
+
* the section landing remains reachable from the scoped rail — without
|
|
448
|
+
* this, on `/api/` the rail shows `/api/users`, `/api/orders`, … but
|
|
449
|
+
* the section's own overview page would be missing from the rail.
|
|
497
450
|
*/
|
|
498
451
|
function scopeToCurrentSection(items, currentPath) {
|
|
499
452
|
if (!currentPath.split("/").filter(Boolean)[0]) return items;
|
|
500
453
|
for (const item of items) if (item.type === "group") {
|
|
501
|
-
if (hasActivePage(item, currentPath))
|
|
454
|
+
if (hasActivePage(item, currentPath)) {
|
|
455
|
+
if (!item.indexHref) return item.children;
|
|
456
|
+
return [item.indexIsExternal ? {
|
|
457
|
+
type: "external",
|
|
458
|
+
label: item.label,
|
|
459
|
+
href: item.indexHref,
|
|
460
|
+
badge: item.badge,
|
|
461
|
+
order: Number.NEGATIVE_INFINITY
|
|
462
|
+
} : {
|
|
463
|
+
type: "link",
|
|
464
|
+
label: item.label,
|
|
465
|
+
href: item.indexHref,
|
|
466
|
+
isCurrent: item.indexIsCurrent === true,
|
|
467
|
+
badge: item.badge,
|
|
468
|
+
order: Number.NEGATIVE_INFINITY
|
|
469
|
+
}, ...item.children];
|
|
470
|
+
}
|
|
502
471
|
}
|
|
503
472
|
return items;
|
|
504
473
|
}
|
|
505
474
|
function hasActivePage(item, currentPath) {
|
|
506
475
|
if (item.type === "link") return item.isCurrent === true;
|
|
507
476
|
if (item.type === "external") return false;
|
|
477
|
+
if (item.indexIsCurrent === true) return true;
|
|
508
478
|
return item.children.some((child) => hasActivePage(child, currentPath));
|
|
509
479
|
}
|
|
510
480
|
/**
|
|
511
|
-
* Derive one section per top-level group in the sidebar tree
|
|
512
|
-
* `Header.astro` to render
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
481
|
+
* Derive one section per top-level group in the sidebar tree, scoped
|
|
482
|
+
* to *cross-collection* navigation. Used by `Header.astro` to render
|
|
483
|
+
* the section tab strip.
|
|
484
|
+
*
|
|
485
|
+
* Filter rule: only groups whose `_prefix` is set become sections. The
|
|
486
|
+
* `_prefix` field is populated exclusively by `autogenerate: { collection: <non-primary> }`
|
|
487
|
+
* config items (see `resolveConfigItems`). This is the structural
|
|
488
|
+
* signal that the group represents a *separate collection mounted at a
|
|
489
|
+
* URL prefix* — e.g. `Components` mounted at `/components/` —
|
|
490
|
+
* rather than a sub-directory of the primary docs collection.
|
|
491
|
+
*
|
|
492
|
+
* Why this matters: under the previous unconditional behavior, every
|
|
493
|
+
* top-level group in the sidebar (including `wip/`, `lab/`, and other
|
|
494
|
+
* docs-collection subdirectories) was promoted to a header tab. The
|
|
495
|
+
* header rail is meant for "other collections" navigation, not for
|
|
496
|
+
* sub-sections of the default collection — those belong in the
|
|
497
|
+
* sidebar's own collapsible tree.
|
|
498
|
+
*
|
|
499
|
+
* Caller must pass the *un-scoped* tree (the result of
|
|
500
|
+
* `buildSidebarTree`, not `getSidebar`); otherwise only the current
|
|
501
|
+
* section's children are visible and the derivation collapses to a
|
|
502
|
+
* single item.
|
|
516
503
|
*/
|
|
517
504
|
function deriveSidebarSections(items) {
|
|
518
505
|
return items.flatMap((item) => {
|
|
519
506
|
if (item.type !== "group") return [];
|
|
520
|
-
|
|
507
|
+
if (!item._prefix) return [];
|
|
508
|
+
const links = flattenSidebar(item.children);
|
|
521
509
|
if (links.length === 0) return [];
|
|
522
510
|
return [{
|
|
523
511
|
label: item.label,
|
|
524
|
-
href: toBrowserHref(item._prefix
|
|
512
|
+
href: toBrowserHref(item._prefix),
|
|
525
513
|
isActive: links.some((link) => link.isCurrent === true)
|
|
526
514
|
}];
|
|
527
515
|
});
|
|
528
516
|
}
|
|
529
|
-
/** Depth-first walk; collect every internal link descendant. */
|
|
530
|
-
function flattenLinks(items) {
|
|
531
|
-
const out = [];
|
|
532
|
-
for (const item of items) if (item.type === "link") out.push(item);
|
|
533
|
-
else if (item.type === "group") out.push(...flattenLinks(item.children));
|
|
534
|
-
return out;
|
|
535
|
-
}
|
|
536
517
|
/**
|
|
537
518
|
* Build the un-scoped sidebar tree from config + content entries.
|
|
538
519
|
*
|
|
@@ -557,11 +538,55 @@ function buildSidebarTree(entriesByCollection, primaryCollection, currentPath, c
|
|
|
557
538
|
else items = buildFilesystemTree(primaryEntries, currentPath, void 0, primaryPrefix);
|
|
558
539
|
const pooledEntries = Object.values(entriesByCollection).flat();
|
|
559
540
|
items = processHideChildren(items, pooledEntries);
|
|
541
|
+
if (config?.overviewLabel) {
|
|
542
|
+
const label = typeof config.overviewLabel === "string" ? config.overviewLabel : "Overview";
|
|
543
|
+
items = applyOverviewLabel(items, label);
|
|
544
|
+
}
|
|
545
|
+
if (config?.defaultCollapsed) applyDefaultCollapsed(items);
|
|
560
546
|
return items;
|
|
561
547
|
}
|
|
562
548
|
/**
|
|
563
|
-
*
|
|
564
|
-
*
|
|
549
|
+
* Walk every group and stamp `collapsed: true` where no explicit value
|
|
550
|
+
* was set. Used by the `sidebar.defaultCollapsed` opt-in. Recurses into
|
|
551
|
+
* nested children so a deeply-structured tree collapses at every level.
|
|
552
|
+
*/
|
|
553
|
+
function applyDefaultCollapsed(items) {
|
|
554
|
+
for (const item of items) if (item.type === "group") {
|
|
555
|
+
if (item.collapsed === void 0) item.collapsed = true;
|
|
556
|
+
applyDefaultCollapsed(item.children);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* @deprecated Effective no-op under structural separation. Pre-2026
|
|
561
|
+
* Nimbus rendered the group's index as the first child link and used
|
|
562
|
+
* this to rename that link to "Overview". The index is now exposed via
|
|
563
|
+
* `SidebarGroupItem.indexHref` (the group label IS the link), so there's
|
|
564
|
+
* no first-child index to rename. The function is kept so older configs
|
|
565
|
+
* that set `sidebar.overviewLabel` don't blow up; future major can drop it.
|
|
566
|
+
*
|
|
567
|
+
* Renamed only when the first link IS the group's index (matched via the
|
|
568
|
+
* `sortKeyByItem` WeakMap) — under structural separation that condition
|
|
569
|
+
* never holds, so this silently returns the input unchanged.
|
|
570
|
+
*/
|
|
571
|
+
function applyOverviewLabel(items, label) {
|
|
572
|
+
for (const item of items) if (item.type === "link" && directoryIndexLinks.has(item)) item.label = label;
|
|
573
|
+
else if (item.type === "group") {
|
|
574
|
+
if (item._indexId) {
|
|
575
|
+
const firstLink = item.children.find((child) => child.type === "link");
|
|
576
|
+
if (firstLink && sortKeyByItem.get(firstLink) === item._indexId) firstLink.label = label;
|
|
577
|
+
}
|
|
578
|
+
applyOverviewLabel(item.children, label);
|
|
579
|
+
}
|
|
580
|
+
return items;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Process `sidebar.hideChildren: true` on a group's index entry:
|
|
584
|
+
* replace the entire group with a single flat link to the index page.
|
|
585
|
+
*
|
|
586
|
+
* Under structural separation the group already exposes its landing
|
|
587
|
+
* page via `indexHref` and never adds the index as a child, so this
|
|
588
|
+
* function reads `indexHref` directly when collapsing — no need to
|
|
589
|
+
* search through `children` for an index link that isn't there.
|
|
565
590
|
*/
|
|
566
591
|
function processHideChildren(items, entries) {
|
|
567
592
|
const entryById = /* @__PURE__ */ new Map();
|
|
@@ -573,18 +598,25 @@ function processHideChildren(items, entries) {
|
|
|
573
598
|
result.push(item);
|
|
574
599
|
continue;
|
|
575
600
|
}
|
|
576
|
-
if (item._indexId) {
|
|
601
|
+
if (item._indexId && item.indexHref) {
|
|
577
602
|
if (entryById.get(item._indexId)?.data.sidebar?.hideChildren) {
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
603
|
+
const replacement = item.indexIsExternal ? {
|
|
604
|
+
type: "external",
|
|
605
|
+
label: item.label,
|
|
606
|
+
href: item.indexHref,
|
|
607
|
+
badge: item.badge,
|
|
608
|
+
order: item.order
|
|
609
|
+
} : {
|
|
610
|
+
type: "link",
|
|
611
|
+
label: item.label,
|
|
612
|
+
href: item.indexHref,
|
|
613
|
+
isCurrent: item.indexIsCurrent === true,
|
|
614
|
+
badge: item.badge,
|
|
615
|
+
order: item.order
|
|
616
|
+
};
|
|
617
|
+
sortKeyByItem.set(replacement, item._indexId);
|
|
618
|
+
result.push(replacement);
|
|
619
|
+
continue;
|
|
588
620
|
}
|
|
589
621
|
}
|
|
590
622
|
item.children = process(item.children);
|
|
@@ -618,11 +650,33 @@ function collectSidebarCollectionRefs(items) {
|
|
|
618
650
|
walk(items);
|
|
619
651
|
return [...found];
|
|
620
652
|
}
|
|
621
|
-
/**
|
|
653
|
+
/**
|
|
654
|
+
* Flatten sidebar tree into a list of links (for pagination).
|
|
655
|
+
*
|
|
656
|
+
* Groups with a landing page (`indexHref` set; structural-separation
|
|
657
|
+
* model) contribute a synthetic link at the group's position so that
|
|
658
|
+
* `getPrevNext` includes the directory-index page in the prev/next
|
|
659
|
+
* walk. Without this, navigating *to* or *from* a group's index page
|
|
660
|
+
* skips it entirely (e.g. on `/api/`, "prev" jumps over the section
|
|
661
|
+
* landing to the previous group's last child).
|
|
662
|
+
*
|
|
663
|
+
* External landing pages (`indexIsExternal`) are excluded — they're
|
|
664
|
+
* off-site destinations, not part of the in-site pagination ring.
|
|
665
|
+
*/
|
|
622
666
|
function flattenSidebar(items) {
|
|
623
667
|
const flat = [];
|
|
624
668
|
for (const item of items) if (item.type === "link") flat.push(item);
|
|
625
|
-
else if (item.type === "group")
|
|
669
|
+
else if (item.type === "group") {
|
|
670
|
+
if (item.indexHref && !item.indexIsExternal) flat.push({
|
|
671
|
+
type: "link",
|
|
672
|
+
label: item.label,
|
|
673
|
+
href: item.indexHref,
|
|
674
|
+
isCurrent: item.indexIsCurrent === true,
|
|
675
|
+
badge: item.badge,
|
|
676
|
+
order: item.order
|
|
677
|
+
});
|
|
678
|
+
flat.push(...flattenSidebar(item.children));
|
|
679
|
+
}
|
|
626
680
|
return flat;
|
|
627
681
|
}
|
|
628
682
|
function formatLabel(segment) {
|
|
@@ -969,6 +1023,131 @@ async function getLastUpdatedFromGit(filePath) {
|
|
|
969
1023
|
return result;
|
|
970
1024
|
}
|
|
971
1025
|
|
|
1026
|
+
//#endregion
|
|
1027
|
+
//#region src/_internal/admonition-transform.ts
|
|
1028
|
+
/** Built-in MyST / Docusaurus / CF admonition types and their Aside mapping. */
|
|
1029
|
+
const BUILTIN_TYPES = {
|
|
1030
|
+
note: "note",
|
|
1031
|
+
info: "note",
|
|
1032
|
+
tip: "tip",
|
|
1033
|
+
caution: "caution",
|
|
1034
|
+
warning: "caution",
|
|
1035
|
+
important: "caution",
|
|
1036
|
+
danger: "danger"
|
|
1037
|
+
};
|
|
1038
|
+
/**
|
|
1039
|
+
* Transform a single MDX source string. Idempotent — running the
|
|
1040
|
+
* transform twice produces the same output as running it once.
|
|
1041
|
+
*/
|
|
1042
|
+
function transformAdmonitions(source, options = {}) {
|
|
1043
|
+
const typeMap = {
|
|
1044
|
+
...BUILTIN_TYPES,
|
|
1045
|
+
...options.typeAliases ?? {}
|
|
1046
|
+
};
|
|
1047
|
+
const { frontmatter, body, bodyOffset: _ } = splitFrontmatter(source);
|
|
1048
|
+
const { stashed, restore } = stashCodeBlocks(body);
|
|
1049
|
+
return frontmatter + restore(stashed.replace(ADMONITION_PATTERN, (match, rawType, rawTitle, rawContent) => {
|
|
1050
|
+
const aside = typeMap[String(rawType).toLowerCase()];
|
|
1051
|
+
if (!aside) return match;
|
|
1052
|
+
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
|
|
1053
|
+
const content = String(rawContent).trim();
|
|
1054
|
+
return `\n\n<Aside type="${aside}"${title ? ` title=${JSON.stringify(title)}` : ""}>\n\n${content}\n\n</Aside>\n\n`;
|
|
1055
|
+
}));
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Match `:::type[optional title] body :::` with non-greedy body.
|
|
1059
|
+
*
|
|
1060
|
+
* Components:
|
|
1061
|
+
* - `:::` literal opener
|
|
1062
|
+
* - `([a-zA-Z]+)` type token (captured, case-insensitive lookup at use site)
|
|
1063
|
+
* - `(?:\[(...)\])?` optional bracketed title; brackets stripped from capture
|
|
1064
|
+
* - `\s+|\n` at least one whitespace before content (avoids matching
|
|
1065
|
+
* `:::foo:::` directly)
|
|
1066
|
+
* - `([\s\S]*?)` non-greedy body, may span newlines
|
|
1067
|
+
* - `\n?\s*:::` closer, possibly with leading whitespace
|
|
1068
|
+
*
|
|
1069
|
+
* Non-greedy body + global flag means adjacent admonitions don't merge
|
|
1070
|
+
* (the engine finds the *nearest* `:::` closer for each opener).
|
|
1071
|
+
*/
|
|
1072
|
+
const ADMONITION_PATTERN = /:::([a-zA-Z]+)(?:\[([^\]]*)\])?[ \t]*(?:\n|[ \t]+)([\s\S]*?)\n?[ \t]*:::/g;
|
|
1073
|
+
function splitFrontmatter(source) {
|
|
1074
|
+
const match = source.match(/^---\n[\s\S]*?\n---\n?/);
|
|
1075
|
+
if (!match) return {
|
|
1076
|
+
frontmatter: "",
|
|
1077
|
+
body: source,
|
|
1078
|
+
bodyOffset: 0
|
|
1079
|
+
};
|
|
1080
|
+
return {
|
|
1081
|
+
frontmatter: match[0],
|
|
1082
|
+
body: source.slice(match[0].length),
|
|
1083
|
+
bodyOffset: match[0].length
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Replace fenced code blocks with opaque placeholders so the admonition
|
|
1088
|
+
* regex doesn't reach `:::` tokens inside code samples. The `restore()`
|
|
1089
|
+
* function reinstates the originals after the transform.
|
|
1090
|
+
*
|
|
1091
|
+
* Order matters: stash the longest fence flavors first (``` and ~~~)
|
|
1092
|
+
* so the placeholders themselves don't get re-stashed. Inline backtick
|
|
1093
|
+
* code spans are NOT stashed — a `:::` inside a single-line `code` span
|
|
1094
|
+
* is rare and would have to be on the same line as both fences anyway.
|
|
1095
|
+
*/
|
|
1096
|
+
function stashCodeBlocks(body) {
|
|
1097
|
+
const blocks = [];
|
|
1098
|
+
const PLACEHOLDER = "\0NIMBUS_CODEBLOCK_";
|
|
1099
|
+
const PLACEHOLDER_END = "\0";
|
|
1100
|
+
const stashed = body.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/g, (match) => {
|
|
1101
|
+
const index = blocks.length;
|
|
1102
|
+
blocks.push(match);
|
|
1103
|
+
return `${PLACEHOLDER}${index}${PLACEHOLDER_END}`;
|
|
1104
|
+
});
|
|
1105
|
+
function restore(src) {
|
|
1106
|
+
return src.replace(new RegExp(`${PLACEHOLDER}(\\d+)${PLACEHOLDER_END}`, "g"), (_match, index) => blocks[Number(index)] ?? "");
|
|
1107
|
+
}
|
|
1108
|
+
return {
|
|
1109
|
+
stashed,
|
|
1110
|
+
restore
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
//#endregion
|
|
1115
|
+
//#region src/_internal/admonition-vite-plugin.ts
|
|
1116
|
+
/**
|
|
1117
|
+
* Vite plugin: intercept `.mdx` (and `.md`) loads under the project's
|
|
1118
|
+
* content directories, rewrite `:::admonition` directives to `<Aside>`
|
|
1119
|
+
* components, hand the transformed source to the next loader in the
|
|
1120
|
+
* chain. Sits in front of @astrojs/mdx and Sätteri.
|
|
1121
|
+
*
|
|
1122
|
+
* `enforce: "pre"` is load-bearing — the MDX integration's own transform
|
|
1123
|
+
* registers without an `enforce` and runs in the default mid-pipeline
|
|
1124
|
+
* slot. Pre-stage runs before that, so by the time MDX parses the file,
|
|
1125
|
+
* the directive syntax has already been rewritten to JSX.
|
|
1126
|
+
*
|
|
1127
|
+
* Scope is restricted to the project's content directories so we don't
|
|
1128
|
+
* touch unrelated `.md` files in `node_modules/` or vendored MDX.
|
|
1129
|
+
*/
|
|
1130
|
+
function admonitionPlugin(options) {
|
|
1131
|
+
const normalizedDirs = options.contentDirs.map((d) => path.resolve(d));
|
|
1132
|
+
return {
|
|
1133
|
+
name: "nimbus-docs:admonitions",
|
|
1134
|
+
enforce: "pre",
|
|
1135
|
+
transform(code, id) {
|
|
1136
|
+
const [pathOnly] = id.split("?", 1);
|
|
1137
|
+
if (!pathOnly) return null;
|
|
1138
|
+
if (!pathOnly.endsWith(".mdx") && !pathOnly.endsWith(".md")) return null;
|
|
1139
|
+
const absolute = path.resolve(pathOnly);
|
|
1140
|
+
if (!normalizedDirs.some((dir) => absolute === dir || absolute.startsWith(dir + path.sep))) return null;
|
|
1141
|
+
if (options.skip?.(absolute)) return null;
|
|
1142
|
+
if (!code.includes(":::")) return null;
|
|
1143
|
+
return {
|
|
1144
|
+
code: transformAdmonitions(code, { typeAliases: options.typeAliases }),
|
|
1145
|
+
map: null
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
972
1151
|
//#endregion
|
|
973
1152
|
//#region src/_internal/parse-components-registry.ts
|
|
974
1153
|
/**
|
|
@@ -1655,7 +1834,7 @@ async function validateMdxContent(options) {
|
|
|
1655
1834
|
const globalsSet = new Set(options.globals);
|
|
1656
1835
|
const failures = [];
|
|
1657
1836
|
for (const dir of options.contentDirs) {
|
|
1658
|
-
const files = await walkMdx(dir);
|
|
1837
|
+
const files = await walkMdx$1(dir);
|
|
1659
1838
|
for (const file of files) {
|
|
1660
1839
|
if (options.skip?.(file)) continue;
|
|
1661
1840
|
const fileFailures = scanFile(await fs$1.readFile(file, "utf8"), globalsSet);
|
|
@@ -1684,7 +1863,7 @@ function formatFailures(failures, globalsCount) {
|
|
|
1684
1863
|
});
|
|
1685
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.";
|
|
1686
1865
|
}
|
|
1687
|
-
async function walkMdx(dir) {
|
|
1866
|
+
async function walkMdx$1(dir) {
|
|
1688
1867
|
const out = [];
|
|
1689
1868
|
async function visit(current) {
|
|
1690
1869
|
let entries;
|
|
@@ -1764,7 +1943,7 @@ function parseImports(body) {
|
|
|
1764
1943
|
* inside code samples doesn't trip the validator.
|
|
1765
1944
|
*/
|
|
1766
1945
|
function stripCodeBlocks(body) {
|
|
1767
|
-
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));
|
|
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));
|
|
1768
1947
|
}
|
|
1769
1948
|
/**
|
|
1770
1949
|
* Find PascalCase JSX-like tags. Matches `<Capital...` at the start of
|
|
@@ -1774,10 +1953,14 @@ function stripCodeBlocks(body) {
|
|
|
1774
1953
|
*/
|
|
1775
1954
|
function findPascalCaseTags(body) {
|
|
1776
1955
|
const out = [];
|
|
1777
|
-
for (const match of body.matchAll(/<([A-Z][A-Za-z0-9_]*)
|
|
1778
|
-
|
|
1779
|
-
offset
|
|
1780
|
-
|
|
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
|
+
}
|
|
1781
1964
|
return out;
|
|
1782
1965
|
}
|
|
1783
1966
|
/**
|
|
@@ -1811,8 +1994,11 @@ const headElementSchema = z.object({
|
|
|
1811
1994
|
"meta",
|
|
1812
1995
|
"link",
|
|
1813
1996
|
"script",
|
|
1814
|
-
"style"
|
|
1815
|
-
|
|
1997
|
+
"style",
|
|
1998
|
+
"title",
|
|
1999
|
+
"noscript",
|
|
2000
|
+
"base"
|
|
2001
|
+
], { error: "head element \"tag\" must be one of: meta, link, script, style, title, noscript, base" }),
|
|
1816
2002
|
attrs: z.record(z.string(), z.string()).default({}),
|
|
1817
2003
|
content: z.string().optional()
|
|
1818
2004
|
});
|
|
@@ -1941,6 +2127,1268 @@ function virtualConfigPlugin(config, extras) {
|
|
|
1941
2127
|
};
|
|
1942
2128
|
}
|
|
1943
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
|
+
|
|
1944
3392
|
//#endregion
|
|
1945
3393
|
//#region src/_internal/scan-version-frontmatter.ts
|
|
1946
3394
|
/**
|
|
@@ -2326,6 +3774,17 @@ function pageUrl(versions, version, slug) {
|
|
|
2326
3774
|
* Planned (not shipped):
|
|
2327
3775
|
* - `/llms.txt` and `/robots.txt` route injection.
|
|
2328
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
|
+
};
|
|
2329
3788
|
function nimbus(rawConfig, options = {}) {
|
|
2330
3789
|
const config = validateNimbusConfig(rawConfig);
|
|
2331
3790
|
const lintOptions = validateLintOptions({
|
|
@@ -2334,6 +3793,8 @@ function nimbus(rawConfig, options = {}) {
|
|
|
2334
3793
|
}, IMPLEMENTED_CODES);
|
|
2335
3794
|
let projectRootForBuild = "";
|
|
2336
3795
|
let astroBaseForBuild = "";
|
|
3796
|
+
let incrementalCtx = null;
|
|
3797
|
+
const mdxSkipCtx = createMdxSkipContext();
|
|
2337
3798
|
return {
|
|
2338
3799
|
name: "nimbus-docs",
|
|
2339
3800
|
hooks: {
|
|
@@ -2362,6 +3823,7 @@ function nimbus(rawConfig, options = {}) {
|
|
|
2362
3823
|
const projectRoot = fileURLToPath(astroConfig.root);
|
|
2363
3824
|
projectRootForBuild = projectRoot;
|
|
2364
3825
|
astroBaseForBuild = astroConfig.base ?? "";
|
|
3826
|
+
const codeBlockLangs = await scanCodeBlockLanguages(projectRoot, SHIKI_LANG_ALIAS);
|
|
2365
3827
|
const contentConfigPath = path.join(projectRoot, "src/content.config.ts");
|
|
2366
3828
|
const rawCollections = await parseContentCollections(contentConfigPath);
|
|
2367
3829
|
const collectionBases = await parseCollectionBases(contentConfigPath);
|
|
@@ -2408,26 +3870,48 @@ function nimbus(rawConfig, options = {}) {
|
|
|
2408
3870
|
versionRedirects = computeMissingPageRedirects(resolved, versionAlternates, versionEntries);
|
|
2409
3871
|
}
|
|
2410
3872
|
integrationsToAdd.push(mdx(options.mdx ?? {}));
|
|
2411
|
-
|
|
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
|
+
}));
|
|
3879
|
+
const admonitionVitePlugins = [];
|
|
3880
|
+
if (options.admonitions !== false) {
|
|
3881
|
+
const admoOpts = typeof options.admonitions === "object" ? options.admonitions : {};
|
|
3882
|
+
const projectRoot = fileURLToPath(astroConfig.root);
|
|
3883
|
+
const contentDirs = (admoOpts.contentDirs ?? ["src/content"]).map((d) => path.isAbsolute(d) ? d : path.join(projectRoot, d));
|
|
3884
|
+
admonitionVitePlugins.push(admonitionPlugin({
|
|
3885
|
+
contentDirs,
|
|
3886
|
+
typeAliases: admoOpts.typeAliases,
|
|
3887
|
+
skip: admoOpts.skip
|
|
3888
|
+
}));
|
|
3889
|
+
}
|
|
2412
3890
|
updateConfig({
|
|
2413
3891
|
...config.site ? { site: config.site } : {},
|
|
2414
3892
|
integrations: integrationsToAdd,
|
|
2415
3893
|
markdown: {
|
|
2416
|
-
processor: satteri(),
|
|
3894
|
+
processor: options.markdown?.processor ?? satteri(),
|
|
2417
3895
|
shikiConfig: {
|
|
2418
3896
|
themes: {
|
|
2419
3897
|
light: "github-light",
|
|
2420
3898
|
dark: "github-dark"
|
|
2421
3899
|
},
|
|
2422
3900
|
defaultColor: false,
|
|
2423
|
-
transformers: defaultCodeTransformers()
|
|
3901
|
+
transformers: defaultCodeTransformers(),
|
|
3902
|
+
langAlias: SHIKI_LANG_ALIAS,
|
|
3903
|
+
langs: codeBlockLangs
|
|
2424
3904
|
}
|
|
2425
3905
|
},
|
|
2426
3906
|
...versionRedirects.length > 0 ? { redirects: Object.fromEntries(versionRedirects.map(({ from, to }) => [from, to])) } : {},
|
|
2427
|
-
vite: { plugins: [
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
3907
|
+
vite: { plugins: [
|
|
3908
|
+
...admonitionVitePlugins,
|
|
3909
|
+
virtualConfigPlugin(config, {
|
|
3910
|
+
indexedCollections,
|
|
3911
|
+
versionAlternates
|
|
3912
|
+
}),
|
|
3913
|
+
...options.incrementalBuilds ? [mdxSkipPlugin(mdxSkipCtx)] : []
|
|
3914
|
+
] }
|
|
2431
3915
|
});
|
|
2432
3916
|
},
|
|
2433
3917
|
"astro:config:done": ({ injectTypes }) => {
|
|
@@ -2446,10 +3930,61 @@ function nimbus(rawConfig, options = {}) {
|
|
|
2446
3930
|
].join("\n")
|
|
2447
3931
|
});
|
|
2448
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
|
+
},
|
|
2449
3949
|
"astro:build:done": async ({ dir, pages, logger }) => {
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
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;
|
|
2453
3988
|
}
|
|
2454
3989
|
}
|
|
2455
3990
|
};
|