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/dist/index.js CHANGED
@@ -1,16 +1,18 @@
1
- import { o as validateLintOptions, r as suggest, t as IMPLEMENTED_CODES } from "./rules-DnAP-j89.js";
2
- import { t as withStrictKeys } from "./strict-keys-BiXiT3pq.js";
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 href = joinHref(hrefPrefix, entry.id);
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: entry.data.sidebar?.label ?? entry.data.title,
318
- href,
319
- isCurrent: toRouteKey(currentPath) === toRouteKey(href),
240
+ label,
241
+ href: internalHref,
242
+ isCurrent: toRouteKey(currentPath) === toRouteKey(internalHref),
320
243
  badge,
321
- order: entry.data.sidebar?.order ?? Number.MAX_VALUE
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 groupLabel = indexEntry?.data.sidebar?.label ?? formatLabel(dirSegment);
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 children = [];
390
- if (indexEntry) children.push(createLink(indexEntry, currentPath, hrefPrefix));
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: indexEntry?.data.sidebar?.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)) return item.children;
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. Used by
512
- * `Header.astro` to render the section tab strip. Caller must pass the
513
- * *un-scoped* tree (the result of `buildSidebarTree`, not `getSidebar`);
514
- * otherwise only the current section's children are visible and the
515
- * derivation collapses to a single item.
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
- const links = flattenLinks(item.children);
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 ?? links[0].href),
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
- * Process hideChildren: replace groups whose index has hideChildren=true
564
- * with a single link to the index page.
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 indexKey = toRouteKey(canonicalEntryUrl("", item._indexId));
579
- const indexLink = item.children.find((c) => c.type === "link" && toRouteKey(c.href) === indexKey);
580
- if (indexLink) {
581
- const link = {
582
- ...indexLink,
583
- label: item.label
584
- };
585
- result.push(link);
586
- continue;
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
- /** Flatten sidebar tree into a list of links (for pagination) */
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") flat.push(...flattenSidebar(item.children));
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_]*)\b/g)) out.push({
1778
- name: match[1],
1779
- offset: match.index ?? 0
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 `&apos;` for single quotes where upstream
3313
+ * uses `&#39;`. Functionally identical; lexically different.
3314
+ * - `@astrojs/sitemap` adds an XML declaration newline upstream's
3315
+ * serializer happens to insert; we don't.
3316
+ *
3317
+ * The shape that DOES hold across cold and warm builds of *this*
3318
+ * emitter is byte-identical (same URL set, same sort, same escape
3319
+ * table). Cold-vs-warm parity is the property the cache layer needs;
3320
+ * upstream-byte-parity is only relevant for sites comparing against
3321
+ * a non-incremental build of `@astrojs/sitemap`.
3322
+ *
3323
+ * Format details:
3324
+ * - One line, no whitespace between elements
3325
+ * - URLs sorted alphabetically by absolute URL
3326
+ * - Directory-format trailing slash (`/foo/` not `/foo`)
3327
+ * - xmlns declarations matching @astrojs/sitemap's set
3328
+ * - sitemap-0.xml carries all entries (we don't split until >45k urls)
3329
+ * - sitemap-index.xml lists sitemap-0.xml only
3330
+ *
3331
+ * v1 scope — no custom `serialize` yet (deferred; cloudflare-docs case),
3332
+ * no `lastmod`, `changefreq`, `priority`, no image/video sitemaps. Matches
3333
+ * `@astrojs/sitemap` *default* output for sites that don't override.
3334
+ */
3335
+ const URLSET_XMLNS = "xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:news=\"http://www.google.com/schemas/sitemap-news/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\" xmlns:image=\"http://www.google.com/schemas/sitemap-image/1.1\" xmlns:video=\"http://www.google.com/schemas/sitemap-video/1.1\"";
3336
+ function trimTrailingSlash(s) {
3337
+ return s.endsWith("/") && s.length > 1 ? s.slice(0, -1) : s;
3338
+ }
3339
+ function ensureTrailingSlash(s) {
3340
+ return s.endsWith("/") ? s : s + "/";
3341
+ }
3342
+ function xmlEscape(s) {
3343
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
3344
+ }
3345
+ /**
3346
+ * Build the canonical URL set. Astro's `pages` arg gives us pathnames as
3347
+ * `foo/bar/` style (already trailing-slash for directory format). The
3348
+ * cached pathnames from the incremental context are canonical-form
3349
+ * (no trailing slash). Normalise both, dedupe, sort.
3350
+ */
3351
+ function buildUrlSet(opts) {
3352
+ const siteRoot = trimTrailingSlash(opts.siteUrl);
3353
+ const base = opts.base ? trimTrailingSlash(opts.base) : "";
3354
+ const pathnames = /* @__PURE__ */ new Set();
3355
+ for (const page of opts.builtPages) {
3356
+ const path = ensureTrailingSlash("/" + page.pathname.replace(/^\/+/, ""));
3357
+ pathnames.add(`${siteRoot}${base}${path}`);
3358
+ }
3359
+ for (const cached of opts.cachedPathnames) {
3360
+ const withSlash = cached === "/" ? "/" : ensureTrailingSlash(cached);
3361
+ pathnames.add(`${siteRoot}${base}${withSlash}`);
3362
+ }
3363
+ return Array.from(pathnames).sort();
3364
+ }
3365
+ function renderItem(item) {
3366
+ let inner = `<loc>${xmlEscape(item.url)}</loc>`;
3367
+ if (item.lastmod !== void 0) inner += `<lastmod>${xmlEscape(item.lastmod)}</lastmod>`;
3368
+ if (item.changefreq !== void 0) inner += `<changefreq>${xmlEscape(item.changefreq)}</changefreq>`;
3369
+ if (item.priority !== void 0) inner += `<priority>${item.priority}</priority>`;
3370
+ if (item.links && item.links.length > 0) for (const link of item.links) inner += `<xhtml:link rel="alternate" hreflang="${xmlEscape(link.lang)}" href="${xmlEscape(link.url)}"/>`;
3371
+ return `<url>${inner}</url>`;
3372
+ }
3373
+ async function emitIncrementalSitemap(opts) {
3374
+ const urls = buildUrlSet(opts);
3375
+ if (opts.customPages) for (const extra of opts.customPages) urls.push(extra);
3376
+ if (urls.length === 0) return { urlCount: 0 };
3377
+ urls.sort();
3378
+ const items = [];
3379
+ for (const url of urls) {
3380
+ let item = { url };
3381
+ if (opts.serialize) item = await opts.serialize(item);
3382
+ if (item) items.push(item);
3383
+ }
3384
+ const sitemap0 = `<?xml version="1.0" encoding="UTF-8"?><urlset ${URLSET_XMLNS}>${items.map(renderItem).join("")}</urlset>`;
3385
+ const sitemapIndex = `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>${xmlEscape(`${trimTrailingSlash(opts.siteUrl)}${opts.base ? trimTrailingSlash(opts.base) : ""}/sitemap-0.xml`)}</loc></sitemap></sitemapindex>`;
3386
+ await mkdir(opts.distDir, { recursive: true });
3387
+ await writeFile(resolve(opts.distDir, "sitemap-0.xml"), sitemap0, "utf8");
3388
+ await writeFile(resolve(opts.distDir, "sitemap-index.xml"), sitemapIndex, "utf8");
3389
+ return { urlCount: items.length };
3390
+ }
3391
+
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
- if (options.sitemap !== false && Boolean(config.site)) integrationsToAdd.push(sitemap());
3873
+ const wantSitemap = options.sitemap !== false && Boolean(config.site);
3874
+ const sitemapOpts = typeof options.sitemap === "object" ? options.sitemap : void 0;
3875
+ if (wantSitemap && !options.incrementalBuilds) integrationsToAdd.push(sitemap({
3876
+ ...sitemapOpts?.serialize && { serialize: sitemapOpts.serialize },
3877
+ ...sitemapOpts?.customPages && { customPages: sitemapOpts.customPages }
3878
+ }));
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: [virtualConfigPlugin(config, {
2428
- indexedCollections,
2429
- versionAlternates
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
- materializeRouteTruthFromPages(projectRootForBuild, astroBaseForBuild, pages, logger);
2451
- if (config.search === false || config.search?.provider === "custom") return;
2452
- await runPagefind(fileURLToPath(dir));
3950
+ if (incrementalCtx) {
3951
+ const distDir = fileURLToPath(dir);
3952
+ await restoreCachedPagesToDist(incrementalCtx, distDir);
3953
+ const fullPagesForTruth = [...pages, ...[...incrementalCtx.cacheableHits].filter((p) => !pages.some((q) => "/" + q.pathname === (p === "/" ? "/" : p + "/"))).map((p) => ({ pathname: p === "/" ? "" : p.slice(1) + "/" }))];
3954
+ materializeRouteTruthFromPages(projectRootForBuild, astroBaseForBuild, fullPagesForTruth, logger);
3955
+ if (options.sitemap !== false && config.site) {
3956
+ const sitemapOptsResolved = typeof options.sitemap === "object" ? options.sitemap : void 0;
3957
+ const result = await emitIncrementalSitemap({
3958
+ siteUrl: config.site,
3959
+ builtPages: pages,
3960
+ cachedPathnames: incrementalCtx.cacheableHits,
3961
+ distDir,
3962
+ base: astroBaseForBuild,
3963
+ serialize: sitemapOptsResolved?.serialize,
3964
+ customPages: sitemapOptsResolved?.customPages
3965
+ });
3966
+ logger.info(`[incremental] sitemap emitted (${result.urlCount} urls)`);
3967
+ }
3968
+ await snapshotAssetsToCache(incrementalCtx, distDir);
3969
+ await finaliseIncrementalContext(incrementalCtx);
3970
+ } else materializeRouteTruthFromPages(projectRootForBuild, astroBaseForBuild, pages, logger);
3971
+ if (config.search === false || config.search?.provider === "custom") {
3972
+ incrementalCtx = null;
3973
+ return;
3974
+ }
3975
+ const distDir = fileURLToPath(dir);
3976
+ const pagefindDistDir = path.join(distDir, "pagefind");
3977
+ if (incrementalCtx !== null && incrementalCtx.stats.misses === 0 && await incrementalCtx.cache.hasPagefindSnapshot()) {
3978
+ const restored = await incrementalCtx.cache.restorePagefind(pagefindDistDir);
3979
+ logger.info(`[incremental] Pagefind skipped — restored ${restored} cached index file(s)`);
3980
+ } else {
3981
+ await runPagefind(distDir);
3982
+ if (incrementalCtx) {
3983
+ const snapped = await incrementalCtx.cache.snapshotPagefind(pagefindDistDir);
3984
+ if (snapped > 0) logger.info(`[incremental] snapshotted ${snapped} Pagefind index file(s) to cache`);
3985
+ }
3986
+ }
3987
+ incrementalCtx = null;
2453
3988
  }
2454
3989
  }
2455
3990
  };