nimbus-docs 0.1.3 → 0.1.5

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,13 +1,16 @@
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
3
  import { execFile } from "node:child_process";
2
4
  import { promisify } from "node:util";
5
+ import fs from "node:fs";
3
6
  import path from "node:path";
4
7
  import { fileURLToPath } from "node:url";
5
8
  import mdx from "@astrojs/mdx";
6
9
  import { satteri } from "@astrojs/markdown-satteri";
7
10
  import sitemap from "@astrojs/sitemap";
8
- import fs from "node:fs/promises";
9
- import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from "@shikijs/transformers";
11
+ import fs$1 from "node:fs/promises";
10
12
  import { z } from "astro/zod";
13
+ import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from "@shikijs/transformers";
11
14
 
12
15
  //#region src/_internal/runtime-config.ts
13
16
  let _cached = null;
@@ -15,8 +18,9 @@ let _cachedCollections = null;
15
18
  let _cachedAlternates = null;
16
19
  async function loadNimbusConfig() {
17
20
  if (_cached) return _cached;
18
- _cached = (await import("virtual:nimbus/config")).config;
19
- return _cached;
21
+ const value = (await import("virtual:nimbus/config")).config;
22
+ _cached = value;
23
+ return value;
20
24
  }
21
25
  /**
22
26
  * Build-time-resolved list of collections the agent-facing routes
@@ -25,8 +29,9 @@ async function loadNimbusConfig() {
25
29
  */
26
30
  async function loadIndexedCollections() {
27
31
  if (_cachedCollections) return _cachedCollections;
28
- _cachedCollections = (await import("virtual:nimbus/config")).indexedCollections;
29
- return _cachedCollections;
32
+ const value = (await import("virtual:nimbus/config")).indexedCollections;
33
+ _cachedCollections = value;
34
+ return value;
30
35
  }
31
36
  /**
32
37
  * Build-time-resolved alternates table for cross-version SEO links.
@@ -35,14 +40,15 @@ async function loadIndexedCollections() {
35
40
  */
36
41
  async function loadVersionAlternates() {
37
42
  if (_cachedAlternates) return _cachedAlternates;
38
- _cachedAlternates = (await import("virtual:nimbus/config")).versionAlternates ?? {};
39
- return _cachedAlternates;
43
+ const value = (await import("virtual:nimbus/config")).versionAlternates ?? {};
44
+ _cachedAlternates = value;
45
+ return value;
40
46
  }
41
47
 
42
48
  //#endregion
43
49
  //#region src/_internal/content.ts
44
50
  /** Primary collection name. Hard-coded — see also `getDocsStaticPaths`. */
45
- const PRIMARY_COLLECTION$3 = "docs";
51
+ const PRIMARY_COLLECTION$2 = "docs";
46
52
  /**
47
53
  * Return visible entries from one or more collections. Drafts are
48
54
  * filtered out in production builds (matching the existing
@@ -57,7 +63,7 @@ const PRIMARY_COLLECTION$3 = "docs";
57
63
  * time. Callers that need per-collection type safety should call
58
64
  * `getCollection("api")` directly.
59
65
  */
60
- async function getVisibleEntries(collections = [PRIMARY_COLLECTION$3]) {
66
+ async function getVisibleEntries(collections = [PRIMARY_COLLECTION$2]) {
61
67
  const { getCollection } = await import("astro:content");
62
68
  const all = (await Promise.all(collections.map((name) => getCollection(name).catch(() => [])))).flat();
63
69
  return import.meta.env.PROD ? all.filter((entry) => !entry.data.draft) : all;
@@ -77,6 +83,191 @@ async function getVisibleEntriesByCollection(collections) {
77
83
  return out;
78
84
  }
79
85
 
86
+ //#endregion
87
+ //#region ../../node_modules/.pnpm/github-slugger@2.0.0/node_modules/github-slugger/regex.js
88
+ const regex = /[\0-\x1F!-,\.\/:-@\[-\^`\{-\xA9\xAB-\xB4\xB6-\xB9\xBB-\xBF\xD7\xF7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u02FF\u0375\u0378\u0379\u037E\u0380-\u0385\u0387\u038B\u038D\u03A2\u03F6\u0482\u0530\u0557\u0558\u055A-\u055F\u0589-\u0590\u05BE\u05C0\u05C3\u05C6\u05C8-\u05CF\u05EB-\u05EE\u05F3-\u060F\u061B-\u061F\u066A-\u066D\u06D4\u06DD\u06DE\u06E9\u06FD\u06FE\u0700-\u070F\u074B\u074C\u07B2-\u07BF\u07F6-\u07F9\u07FB\u07FC\u07FE\u07FF\u082E-\u083F\u085C-\u085F\u086B-\u089F\u08B5\u08C8-\u08D2\u08E2\u0964\u0965\u0970\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09F2-\u09FB\u09FD\u09FF\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF0-\u0AF8\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B54\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B70\u0B72-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BF0-\u0BFF\u0C0D\u0C11\u0C29\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5B-\u0C5F\u0C64\u0C65\u0C70-\u0C7F\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0CFF\u0D0D\u0D11\u0D45\u0D49\u0D4F-\u0D53\u0D58-\u0D5E\u0D64\u0D65\u0D70-\u0D79\u0D80\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DE5\u0DF0\u0DF1\u0DF4-\u0E00\u0E3B-\u0E3F\u0E4F\u0E5A-\u0E80\u0E83\u0E85\u0E8B\u0EA4\u0EA6\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F01-\u0F17\u0F1A-\u0F1F\u0F2A-\u0F34\u0F36\u0F38\u0F3A-\u0F3D\u0F48\u0F6D-\u0F70\u0F85\u0F98\u0FBD-\u0FC5\u0FC7-\u0FFF\u104A-\u104F\u109E\u109F\u10C6\u10C8-\u10CC\u10CE\u10CF\u10FB\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u1360-\u137F\u1390-\u139F\u13F6\u13F7\u13FE-\u1400\u166D\u166E\u1680\u169B-\u169F\u16EB-\u16ED\u16F9-\u16FF\u170D\u1715-\u171F\u1735-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17D4-\u17D6\u17D8-\u17DB\u17DE\u17DF\u17EA-\u180A\u180E\u180F\u181A-\u181F\u1879-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191F\u192C-\u192F\u193C-\u1945\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DA-\u19FF\u1A1C-\u1A1F\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1AA6\u1AA8-\u1AAF\u1AC1-\u1AFF\u1B4C-\u1B4F\u1B5A-\u1B6A\u1B74-\u1B7F\u1BF4-\u1BFF\u1C38-\u1C3F\u1C4A-\u1C4C\u1C7E\u1C7F\u1C89-\u1C8F\u1CBB\u1CBC\u1CC0-\u1CCF\u1CD3\u1CFB-\u1CFF\u1DFA\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FBD\u1FBF-\u1FC1\u1FC5\u1FCD-\u1FCF\u1FD4\u1FD5\u1FDC-\u1FDF\u1FED-\u1FF1\u1FF5\u1FFD-\u203E\u2041-\u2053\u2055-\u2070\u2072-\u207E\u2080-\u208F\u209D-\u20CF\u20F1-\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F-\u215F\u2189-\u24B5\u24EA-\u2BFF\u2C2F\u2C5F\u2CE5-\u2CEA\u2CF4-\u2CFF\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D70-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E00-\u2E2E\u2E30-\u3004\u3008-\u3020\u3030\u3036\u3037\u303D-\u3040\u3097\u3098\u309B\u309C\u30A0\u30FB\u3100-\u3104\u3130\u318F-\u319F\u31C0-\u31EF\u3200-\u33FF\u4DC0-\u4DFF\u9FFD-\u9FFF\uA48D-\uA4CF\uA4FE\uA4FF\uA60D-\uA60F\uA62C-\uA63F\uA673\uA67E\uA6F2-\uA716\uA720\uA721\uA789\uA78A\uA7C0\uA7C1\uA7CB-\uA7F4\uA828-\uA82B\uA82D-\uA83F\uA874-\uA87F\uA8C6-\uA8CF\uA8DA-\uA8DF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA954-\uA95F\uA97D-\uA97F\uA9C1-\uA9CE\uA9DA-\uA9DF\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A-\uAA5F\uAA77-\uAA79\uAAC3-\uAADA\uAADE\uAADF\uAAF0\uAAF1\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F\uAB5B\uAB6A-\uAB6F\uABEB\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uD7FF\uE000-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB29\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBB2-\uFBD2\uFD3E-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFC-\uFDFF\uFE10-\uFE1F\uFE30-\uFE32\uFE35-\uFE4C\uFE50-\uFE6F\uFE75\uFEFD-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF3E\uFF40\uFF5B-\uFF65\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFFF]|\uD800[\uDC0C\uDC27\uDC3B\uDC3E\uDC4E\uDC4F\uDC5E-\uDC7F\uDCFB-\uDD3F\uDD75-\uDDFC\uDDFE-\uDE7F\uDE9D-\uDE9F\uDED1-\uDEDF\uDEE1-\uDEFF\uDF20-\uDF2C\uDF4B-\uDF4F\uDF7B-\uDF7F\uDF9E\uDF9F\uDFC4-\uDFC7\uDFD0\uDFD6-\uDFFF]|\uD801[\uDC9E\uDC9F\uDCAA-\uDCAF\uDCD4-\uDCD7\uDCFC-\uDCFF\uDD28-\uDD2F\uDD64-\uDDFF\uDF37-\uDF3F\uDF56-\uDF5F\uDF68-\uDFFF]|\uD802[\uDC06\uDC07\uDC09\uDC36\uDC39-\uDC3B\uDC3D\uDC3E\uDC56-\uDC5F\uDC77-\uDC7F\uDC9F-\uDCDF\uDCF3\uDCF6-\uDCFF\uDD16-\uDD1F\uDD3A-\uDD7F\uDDB8-\uDDBD\uDDC0-\uDDFF\uDE04\uDE07-\uDE0B\uDE14\uDE18\uDE36\uDE37\uDE3B-\uDE3E\uDE40-\uDE5F\uDE7D-\uDE7F\uDE9D-\uDEBF\uDEC8\uDEE7-\uDEFF\uDF36-\uDF3F\uDF56-\uDF5F\uDF73-\uDF7F\uDF92-\uDFFF]|\uD803[\uDC49-\uDC7F\uDCB3-\uDCBF\uDCF3-\uDCFF\uDD28-\uDD2F\uDD3A-\uDE7F\uDEAA\uDEAD-\uDEAF\uDEB2-\uDEFF\uDF1D-\uDF26\uDF28-\uDF2F\uDF51-\uDFAF\uDFC5-\uDFDF\uDFF7-\uDFFF]|\uD804[\uDC47-\uDC65\uDC70-\uDC7E\uDCBB-\uDCCF\uDCE9-\uDCEF\uDCFA-\uDCFF\uDD35\uDD40-\uDD43\uDD48-\uDD4F\uDD74\uDD75\uDD77-\uDD7F\uDDC5-\uDDC8\uDDCD\uDDDB\uDDDD-\uDDFF\uDE12\uDE38-\uDE3D\uDE3F-\uDE7F\uDE87\uDE89\uDE8E\uDE9E\uDEA9-\uDEAF\uDEEB-\uDEEF\uDEFA-\uDEFF\uDF04\uDF0D\uDF0E\uDF11\uDF12\uDF29\uDF31\uDF34\uDF3A\uDF45\uDF46\uDF49\uDF4A\uDF4E\uDF4F\uDF51-\uDF56\uDF58-\uDF5C\uDF64\uDF65\uDF6D-\uDF6F\uDF75-\uDFFF]|\uD805[\uDC4B-\uDC4F\uDC5A-\uDC5D\uDC62-\uDC7F\uDCC6\uDCC8-\uDCCF\uDCDA-\uDD7F\uDDB6\uDDB7\uDDC1-\uDDD7\uDDDE-\uDDFF\uDE41-\uDE43\uDE45-\uDE4F\uDE5A-\uDE7F\uDEB9-\uDEBF\uDECA-\uDEFF\uDF1B\uDF1C\uDF2C-\uDF2F\uDF3A-\uDFFF]|\uD806[\uDC3B-\uDC9F\uDCEA-\uDCFE\uDD07\uDD08\uDD0A\uDD0B\uDD14\uDD17\uDD36\uDD39\uDD3A\uDD44-\uDD4F\uDD5A-\uDD9F\uDDA8\uDDA9\uDDD8\uDDD9\uDDE2\uDDE5-\uDDFF\uDE3F-\uDE46\uDE48-\uDE4F\uDE9A-\uDE9C\uDE9E-\uDEBF\uDEF9-\uDFFF]|\uD807[\uDC09\uDC37\uDC41-\uDC4F\uDC5A-\uDC71\uDC90\uDC91\uDCA8\uDCB7-\uDCFF\uDD07\uDD0A\uDD37-\uDD39\uDD3B\uDD3E\uDD48-\uDD4F\uDD5A-\uDD5F\uDD66\uDD69\uDD8F\uDD92\uDD99-\uDD9F\uDDAA-\uDEDF\uDEF7-\uDFAF\uDFB1-\uDFFF]|\uD808[\uDF9A-\uDFFF]|\uD809[\uDC6F-\uDC7F\uDD44-\uDFFF]|[\uD80A\uD80B\uD80E-\uD810\uD812-\uD819\uD824-\uD82B\uD82D\uD82E\uD830-\uD833\uD837\uD839\uD83D\uD83F\uD87B-\uD87D\uD87F\uD885-\uDB3F\uDB41-\uDBFF][\uDC00-\uDFFF]|\uD80D[\uDC2F-\uDFFF]|\uD811[\uDE47-\uDFFF]|\uD81A[\uDE39-\uDE3F\uDE5F\uDE6A-\uDECF\uDEEE\uDEEF\uDEF5-\uDEFF\uDF37-\uDF3F\uDF44-\uDF4F\uDF5A-\uDF62\uDF78-\uDF7C\uDF90-\uDFFF]|\uD81B[\uDC00-\uDE3F\uDE80-\uDEFF\uDF4B-\uDF4E\uDF88-\uDF8E\uDFA0-\uDFDF\uDFE2\uDFE5-\uDFEF\uDFF2-\uDFFF]|\uD821[\uDFF8-\uDFFF]|\uD823[\uDCD6-\uDCFF\uDD09-\uDFFF]|\uD82C[\uDD1F-\uDD4F\uDD53-\uDD63\uDD68-\uDD6F\uDEFC-\uDFFF]|\uD82F[\uDC6B-\uDC6F\uDC7D-\uDC7F\uDC89-\uDC8F\uDC9A-\uDC9C\uDC9F-\uDFFF]|\uD834[\uDC00-\uDD64\uDD6A-\uDD6C\uDD73-\uDD7A\uDD83\uDD84\uDD8C-\uDDA9\uDDAE-\uDE41\uDE45-\uDFFF]|\uD835[\uDC55\uDC9D\uDCA0\uDCA1\uDCA3\uDCA4\uDCA7\uDCA8\uDCAD\uDCBA\uDCBC\uDCC4\uDD06\uDD0B\uDD0C\uDD15\uDD1D\uDD3A\uDD3F\uDD45\uDD47-\uDD49\uDD51\uDEA6\uDEA7\uDEC1\uDEDB\uDEFB\uDF15\uDF35\uDF4F\uDF6F\uDF89\uDFA9\uDFC3\uDFCC\uDFCD]|\uD836[\uDC00-\uDDFF\uDE37-\uDE3A\uDE6D-\uDE74\uDE76-\uDE83\uDE85-\uDE9A\uDEA0\uDEB0-\uDFFF]|\uD838[\uDC07\uDC19\uDC1A\uDC22\uDC25\uDC2B-\uDCFF\uDD2D-\uDD2F\uDD3E\uDD3F\uDD4A-\uDD4D\uDD4F-\uDEBF\uDEFA-\uDFFF]|\uD83A[\uDCC5-\uDCCF\uDCD7-\uDCFF\uDD4C-\uDD4F\uDD5A-\uDFFF]|\uD83B[\uDC00-\uDDFF\uDE04\uDE20\uDE23\uDE25\uDE26\uDE28\uDE33\uDE38\uDE3A\uDE3C-\uDE41\uDE43-\uDE46\uDE48\uDE4A\uDE4C\uDE50\uDE53\uDE55\uDE56\uDE58\uDE5A\uDE5C\uDE5E\uDE60\uDE63\uDE65\uDE66\uDE6B\uDE73\uDE78\uDE7D\uDE7F\uDE8A\uDE9C-\uDEA0\uDEA4\uDEAA\uDEBC-\uDFFF]|\uD83C[\uDC00-\uDD2F\uDD4A-\uDD4F\uDD6A-\uDD6F\uDD8A-\uDFFF]|\uD83E[\uDC00-\uDFEF\uDFFA-\uDFFF]|\uD869[\uDEDE-\uDEFF]|\uD86D[\uDF35-\uDF3F]|\uD86E[\uDC1E\uDC1F]|\uD873[\uDEA2-\uDEAF]|\uD87A[\uDFE1-\uDFFF]|\uD87E[\uDE1E-\uDFFF]|\uD884[\uDF4B-\uDFFF]|\uDB40[\uDC00-\uDCFF\uDDF0-\uDFFF]/g;
89
+
90
+ //#endregion
91
+ //#region ../../node_modules/.pnpm/github-slugger@2.0.0/node_modules/github-slugger/index.js
92
+ const own = Object.hasOwnProperty;
93
+ /**
94
+ * Generate a slug.
95
+ *
96
+ * Does not track previously generated slugs: repeated calls with the same value
97
+ * will result in the exact same slug.
98
+ * Use the `GithubSlugger` class to get unique slugs.
99
+ *
100
+ * @param {string} value
101
+ * String of text to slugify
102
+ * @param {boolean} [maintainCase=false]
103
+ * Keep the current case, otherwise make all lowercase
104
+ * @return {string}
105
+ * A unique slug string
106
+ */
107
+ function slug(value, maintainCase) {
108
+ if (typeof value !== "string") return "";
109
+ if (!maintainCase) value = value.toLowerCase();
110
+ return value.replace(regex, "").replace(/ /g, "-");
111
+ }
112
+
113
+ //#endregion
114
+ //#region src/_internal/astro-slug.ts
115
+ /**
116
+ * Mirror of Astro's content-layer slug normalization, used by every
117
+ * framework URL builder that derives a URL from an `entry.id`.
118
+ *
119
+ * Astro's `glob` content loader (used by Nimbus's `docsCollection` /
120
+ * `partialsCollection` factories) runs each path segment through
121
+ * `github-slugger.slug()` and strips a trailing `/index`. That output is
122
+ * what `entry.id` becomes inside `getCollection`, and it's what `params.slug`
123
+ * substitutes into `[...slug].astro` routes. Anything that constructs a
124
+ * URL from the raw filesystem path *must* apply the same normalization or
125
+ * the URL won't match what Astro actually serves.
126
+ *
127
+ * Why mirror instead of asking Astro: framework URL builders run at page
128
+ * render time (sidebar hrefs) and during the integration's
129
+ * `astro:config:setup` (sitemap, llms.txt). At neither point do we have a
130
+ * clean way to read Astro's resolved routes. The honest architectural fix
131
+ * is to refactor those builders to consume the route manifest at
132
+ * `astro:routes:resolved` (sitemap/llms) or via a build-emitted lookup
133
+ * table (sidebar). Until that work lands, this helper keeps the URLs
134
+ * correct.
135
+ *
136
+ * Mirror caveat: this matches `github-slugger.slug()` and the trailing-
137
+ * /index strip — the documented public-library behaviors Astro inherits
138
+ * — not Astro's private routing internals. If a user supplies a custom
139
+ * `generateId` to the content loader, or a `data.slug` override in
140
+ * frontmatter, this helper doesn't see it. Both are uncommon.
141
+ */
142
+ /** Canonicalize one entry id the way Astro's content layer does. */
143
+ function canonicalSlug(entryId) {
144
+ const slugged = entryId.split("/").map((segment) => slug(segment)).join("/");
145
+ if (slugged === "index") return "";
146
+ return slugged.endsWith("/index") ? slugged.slice(0, -6) : slugged;
147
+ }
148
+ /**
149
+ * Compose the URL Astro serves for an entry at a given collection prefix.
150
+ *
151
+ * canonicalEntryUrl("", "WIP/index") → "/wip"
152
+ * canonicalEntryUrl("/blog", "first") → "/blog/first"
153
+ * canonicalEntryUrl("/v1", "index") → "/v1"
154
+ *
155
+ * `prefix` is the collection mount path (`""` for the primary `docs`
156
+ * collection, `/blog` for a `blog` collection, `/v1` for a version
157
+ * collection). Pass exactly what your framework prefix resolver returns.
158
+ */
159
+ function canonicalEntryUrl(prefix, entryId) {
160
+ const slug = canonicalSlug(entryId);
161
+ if (slug === "") return prefix === "" ? "/" : prefix;
162
+ return `${prefix}/${slug}`;
163
+ }
164
+
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
+
80
271
  //#endregion
81
272
  //#region src/_internal/sidebar.ts
82
273
  const sortKeyByItem = /* @__PURE__ */ new WeakMap();
@@ -89,17 +280,6 @@ function sortSidebarItems(a, b) {
89
280
  if (keyDiff !== 0) return keyDiff;
90
281
  return a.type.localeCompare(b.type);
91
282
  }
92
- /** Ensure internal href has leading /, no trailing slash (except root) */
93
- function normalizeInternalHref(href) {
94
- let h = href.split("?")[0].split("#")[0];
95
- if (!h.startsWith("/")) h = `/${h}`;
96
- if (h.length > 1 && h.endsWith("/")) h = h.slice(0, -1);
97
- return h;
98
- }
99
- /** Strip query and hash for active-state matching */
100
- function stripQueryHash(href) {
101
- return href.split("?")[0].split("#")[0];
102
- }
103
283
  function buildEntryIndex(entries) {
104
284
  const visible = entries.filter((e) => !e.data.sidebar?.hidden);
105
285
  const byId = /* @__PURE__ */ new Map();
@@ -115,9 +295,16 @@ function buildEntryIndex(entries) {
115
295
  hasChildren
116
296
  };
117
297
  }
118
- /** Compose a final href for an entry. `hrefPrefix` is the collection mount path (e.g. `/api`). */
298
+ /**
299
+ * Compose a final href for an entry. `hrefPrefix` is the collection mount
300
+ * path (e.g. `/api`). Runs through `canonicalEntryUrl` so the href
301
+ * matches what Astro serves (lowercase + folder-index strip — see
302
+ * `_internal/astro-slug.ts` for the why and known caveats), then through
303
+ * `toBrowserHref` so the rendered link is the trailing-slash form static
304
+ * hosts serve directly (no 307 round-trip on click).
305
+ */
119
306
  function joinHref(hrefPrefix, entryId) {
120
- return `${hrefPrefix.replace(/\/$/, "")}/${entryId}`;
307
+ return toBrowserHref(canonicalEntryUrl(hrefPrefix.replace(/\/$/, ""), entryId));
121
308
  }
122
309
  function createLink(entry, currentPath, hrefPrefix = "") {
123
310
  const href = joinHref(hrefPrefix, entry.id);
@@ -129,7 +316,7 @@ function createLink(entry, currentPath, hrefPrefix = "") {
129
316
  type: "link",
130
317
  label: entry.data.sidebar?.label ?? entry.data.title,
131
318
  href,
132
- isCurrent: currentPath === href,
319
+ isCurrent: toRouteKey(currentPath) === toRouteKey(href),
133
320
  badge,
134
321
  order: entry.data.sidebar?.order ?? Number.MAX_VALUE
135
322
  };
@@ -221,6 +408,7 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
221
408
  const result = [];
222
409
  for (let i = 0; i < configItems.length; i++) {
223
410
  const item = configItems[i];
411
+ if (!item) continue;
224
412
  const order = orderStart + i;
225
413
  if (typeof item === "string") {
226
414
  const entry = byId.get(item);
@@ -229,7 +417,7 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
229
417
  link.order = order;
230
418
  result.push(link);
231
419
  } else console.warn(`[sidebar] Page "${item}" referenced in config but not found in primary collection "${primaryCollection}"`);
232
- } else if ("link" in item) if (!item.link.startsWith("/")) {
420
+ } else if ("link" in item) if (isAbsoluteUrl(item.link) || !item.link.startsWith("/")) {
233
421
  const extLink = {
234
422
  type: "external",
235
423
  label: item.label,
@@ -239,15 +427,15 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
239
427
  };
240
428
  result.push(extLink);
241
429
  } else {
242
- const href = normalizeInternalHref(item.link);
243
- const matchPath = stripQueryHash(href);
244
- const lookup = href.slice(1);
245
- if (!lookup.includes("/") && href !== "/" && !byId.has(lookup)) console.warn(`[sidebar] Internal link "${item.link}" (label: "${item.label}") does not match any entry in primary collection "${primaryCollection}"`);
430
+ const href = toBrowserHref(item.link);
431
+ const routeKey = toRouteKey(item.link);
432
+ const lookup = routeKey.slice(1);
433
+ if (lookup !== "" && !lookup.includes("/") && !byId.has(lookup)) console.warn(`[sidebar] Internal link "${item.link}" (label: "${item.label}") does not match any entry in primary collection "${primaryCollection}"`);
246
434
  const link = {
247
435
  type: "link",
248
436
  label: item.label,
249
437
  href,
250
- isCurrent: currentPath === matchPath,
438
+ isCurrent: toRouteKey(currentPath) === routeKey,
251
439
  badge: item.badge,
252
440
  order
253
441
  };
@@ -333,7 +521,7 @@ function deriveSidebarSections(items) {
333
521
  if (links.length === 0) return [];
334
522
  return [{
335
523
  label: item.label,
336
- href: item._prefix ?? links[0].href,
524
+ href: toBrowserHref(item._prefix ?? links[0].href),
337
525
  isActive: links.some((link) => link.isCurrent === true)
338
526
  }];
339
527
  });
@@ -387,8 +575,8 @@ function processHideChildren(items, entries) {
387
575
  }
388
576
  if (item._indexId) {
389
577
  if (entryById.get(item._indexId)?.data.sidebar?.hideChildren) {
390
- const indexHref = `/${item._indexId}`;
391
- const indexLink = item.children.find((c) => c.type === "link" && c.href === indexHref);
578
+ const indexKey = toRouteKey(canonicalEntryUrl("", item._indexId));
579
+ const indexLink = item.children.find((c) => c.type === "link" && toRouteKey(c.href) === indexKey);
392
580
  if (indexLink) {
393
581
  const link = {
394
582
  ...indexLink,
@@ -451,6 +639,54 @@ function sidebarHash(items) {
451
639
  return (hash >>> 0).toString(36).padStart(7, "0");
452
640
  }
453
641
 
642
+ //#endregion
643
+ //#region src/_internal/collection-mount.ts
644
+ /**
645
+ * Collection-mount conventions — one source of truth for "what URL prefix
646
+ * does collection X serve at?".
647
+ *
648
+ * Shared between `index.ts` (which uses it for `getIndexedEntries`,
649
+ * `getDocsPageProps`, etc.) and `lint/site-model.ts` (where
650
+ * `findDuplicateRoutes` needs it to detect cross-collection URL
651
+ * collisions). Keeping the function here prevents the duplicate-slug
652
+ * validator from drifting out of sync with the actual routing.
653
+ */
654
+ /** Primary collection name — mounted at the site root with no prefix. */
655
+ const PRIMARY_COLLECTION$1 = "docs";
656
+ /**
657
+ * Resolve the URL-prefix segment for a given collection name.
658
+ *
659
+ * 1. Primary `docs` collection mounts at root → returns `""`.
660
+ * 2. With `versions` configured, a `docs-<slug>` collection whose slug
661
+ * appears in `versions.others` mounts under `/<slug>` (the version
662
+ * label, not the collection id).
663
+ * 3. Any other collection (`api`, `blog`, …) mounts at `/<collection>`.
664
+ *
665
+ * Returned shape: empty string OR `/<segment>` with leading slash, no
666
+ * trailing slash. Callers append `/<entryId>` or `/index.md`.
667
+ */
668
+ function collectionMountPrefix(collection, versions) {
669
+ if (collection === PRIMARY_COLLECTION$1) return "";
670
+ if (versions && collection.startsWith("docs-")) {
671
+ const slug = collection.slice(5);
672
+ if (versions.others.includes(slug)) return `/${slug}`;
673
+ }
674
+ return `/${collection}`;
675
+ }
676
+ /**
677
+ * Resolve the URL-safe label a collection is referenced by — the segment
678
+ * that appears in URLs and section headers. For version collections this
679
+ * is the manifest's short slug; for everything else it's the collection id.
680
+ */
681
+ function collectionLabel(collection, versions) {
682
+ if (collection === PRIMARY_COLLECTION$1) return collection;
683
+ if (versions && collection.startsWith("docs-")) {
684
+ const slug = collection.slice(5);
685
+ if (versions.others.includes(slug)) return slug;
686
+ }
687
+ return collection;
688
+ }
689
+
454
690
  //#endregion
455
691
  //#region src/_internal/transform.ts
456
692
  function protectCode(markdown) {
@@ -610,16 +846,11 @@ function getBreadcrumbs$1(slug, homeLabel = "Home") {
610
846
  path += `/${part}`;
611
847
  crumbs.push({
612
848
  label: part.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
613
- href: path
849
+ href: toBrowserHref(path)
614
850
  });
615
851
  }
616
852
  return crumbs;
617
853
  }
618
- function normalizeInternalPath(path) {
619
- const [withoutHash] = path.split("#", 1);
620
- const [pathname] = withoutHash.split("?", 1);
621
- return pathname || "/";
622
- }
623
854
  function resolveOverride(override, fallback, validInternalLinks) {
624
855
  if (override === false) return void 0;
625
856
  if (override === void 0) return fallback;
@@ -632,11 +863,11 @@ function resolveOverride(override, fallback, validInternalLinks) {
632
863
  }
633
864
  if (override.link && !override.link.startsWith("/") && !override.link.startsWith("http")) throw new Error(`prev/next override link "${override.link}" must be an absolute path (starting with /) or a full URL`);
634
865
  if (override.link?.startsWith("/") && validInternalLinks) {
635
- const targetPath = normalizeInternalPath(override.link);
866
+ const targetPath = toRouteKey(override.link);
636
867
  if (!validInternalLinks.has(targetPath)) throw new Error(`prev/next override link "${override.link}" does not match any existing internal docs route`);
637
868
  }
638
869
  const label = override.label ?? fallback?.label;
639
- const href = override.link ?? fallback?.href;
870
+ const href = override.link ? toBrowserHref(override.link) : fallback?.href;
640
871
  if (!fallback && (label === void 0 || href === void 0)) throw new Error("prev/next object override requires both `label` and `link` when no sidebar neighbor exists");
641
872
  if (!href) return void 0;
642
873
  return {
@@ -646,7 +877,8 @@ function resolveOverride(override, fallback, validInternalLinks) {
646
877
  }
647
878
  function getPrevNext$1(currentPath, sidebarTree, overrides, validInternalLinks) {
648
879
  const flat = flattenSidebar(sidebarTree);
649
- const index = flat.findIndex((item) => item.href === currentPath);
880
+ const currentKey = toRouteKey(currentPath);
881
+ const index = flat.findIndex((item) => toRouteKey(item.href) === currentKey);
650
882
  const sidebarPrev = index > 0 ? {
651
883
  label: flat[index - 1].label,
652
884
  href: flat[index - 1].href
@@ -768,7 +1000,7 @@ const EXPORT_PATTERN = /export\s+const\s+components\s*(?::\s*[^=]+)?=\s*\{([\s\S
768
1000
  async function parseComponentsRegistry(filePath) {
769
1001
  let source;
770
1002
  try {
771
- source = await fs.readFile(filePath, "utf8");
1003
+ source = await fs$1.readFile(filePath, "utf8");
772
1004
  } catch (err) {
773
1005
  if (err.code === "ENOENT") return null;
774
1006
  throw err;
@@ -820,6 +1052,178 @@ function splitTopLevelCommas$1(input) {
820
1052
  return result;
821
1053
  }
822
1054
 
1055
+ //#endregion
1056
+ //#region src/lint/site-model.ts
1057
+ /**
1058
+ * Lint-side data shapes + the `nimbus/duplicate-slug` build validator.
1059
+ *
1060
+ * Route truth for `nimbus/internal-link` comes from Astro itself
1061
+ * (`astro:build:done` hands us the emitted `pages` array — the single
1062
+ * source of truth for served URLs). The integration writes that to
1063
+ * `.nimbus/routes.json`; the type lives here only so the rule and the
1064
+ * writer agree on the shape.
1065
+ *
1066
+ * The duplicate-slug validator runs *before* the build because Astro
1067
+ * silently dedupes colliding routes — by the time `astro:build:done`
1068
+ * fires, one entry has already shadowed the other. We catch collisions
1069
+ * pre-build by computing each entry's canonical slug with the same
1070
+ * library Astro's content layer uses (`github-slugger`), then grouping
1071
+ * entries by collection + slug. This is mirror-behavior, but mirroring
1072
+ * a documented public library rather than Astro's private internals —
1073
+ * a much smaller maintenance surface.
1074
+ */
1075
+ /**
1076
+ * Walk `src/content/` using a `(collection key → folder name)` map so
1077
+ * entries are tagged with the registered key, not the on-disk folder.
1078
+ * Skips folders that aren't in the map (they're loose content, not a
1079
+ * routed collection).
1080
+ */
1081
+ function enumerateEntriesByBase(contentRoot, bases) {
1082
+ const folderToKey = /* @__PURE__ */ new Map();
1083
+ for (const [key, base] of bases) folderToKey.set(base, key);
1084
+ const out = [];
1085
+ for (const [folder, key] of folderToKey) {
1086
+ const baseDir = path.join(contentRoot, folder);
1087
+ walkFolder(baseDir, baseDir, (relInBase) => {
1088
+ out.push({
1089
+ collection: key,
1090
+ id: relInBase.replace(/\.mdx$/, ""),
1091
+ relPath: `${folder}/${relInBase}`
1092
+ });
1093
+ });
1094
+ }
1095
+ return out;
1096
+ }
1097
+ function walkFolder(dir, root, visit) {
1098
+ let dirents;
1099
+ try {
1100
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
1101
+ } catch (err) {
1102
+ if (err.code === "ENOENT") return;
1103
+ throw err;
1104
+ }
1105
+ for (const entry of dirents) {
1106
+ const full = path.join(dir, entry.name);
1107
+ if (entry.isDirectory()) {
1108
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
1109
+ walkFolder(full, root, visit);
1110
+ } else if (entry.isFile() && entry.name.endsWith(".mdx")) visit(path.relative(root, full).replace(/\\/g, "/"));
1111
+ }
1112
+ }
1113
+ /**
1114
+ * Group route owners by URL. A group with more than one member is a real
1115
+ * collision Astro will silently shadow at build time — that's what this
1116
+ * helper exists to surface before the build wastes a cycle.
1117
+ *
1118
+ * Owners come from two sources, both fed in by the integration:
1119
+ *
1120
+ * - **Content entries**, with URLs computed via
1121
+ * `collectionMountPrefix(entry.collection, versions) +
1122
+ * canonicalEntryUrl(prefix, entry.id)`. Catches cross-collection
1123
+ * (`docs/blog/post.mdx` vs `blog/post.mdx`), version-collection
1124
+ * (`docs/v1/x.mdx` vs `docs-v1/x.mdx`), case-only, and
1125
+ * leaf-vs-folder-index collisions Astro's content layer dedupes.
1126
+ * - **Static `src/pages/**` files** (via `enumerateStaticPageRoutes`).
1127
+ * Catches the page-vs-content collision (`pages/search.astro`
1128
+ * shadowing `content/docs/search.mdx` at `/search`).
1129
+ *
1130
+ * Doesn't honor `data.slug` frontmatter overrides — entries that use those
1131
+ * may produce false negatives. Reading frontmatter from every entry
1132
+ * pre-build would add noticeable I/O for a v1 feature; tracked as a
1133
+ * follow-up.
1134
+ */
1135
+ function findDuplicateRoutes(owners) {
1136
+ const byUrl = /* @__PURE__ */ new Map();
1137
+ for (const { url, source } of owners) {
1138
+ const bucket = byUrl.get(url);
1139
+ if (bucket) bucket.push(source);
1140
+ else byUrl.set(url, [source]);
1141
+ }
1142
+ const dups = [];
1143
+ for (const [url, sources] of byUrl) if (sources.length > 1) dups.push({
1144
+ url,
1145
+ sources
1146
+ });
1147
+ return dups;
1148
+ }
1149
+ /** Format duplicate groups into a build-error message. */
1150
+ function formatDuplicateRoutes(dups) {
1151
+ const lines = dups.map((d) => ` ${d.url} ← ${d.sources.join(", ")}`);
1152
+ return `[nimbus-docs] Duplicate ${dups.length === 1 ? "route is" : "routes are"} claimed by more than one source (nimbus/duplicate-slug):\n` + lines.join("\n") + "\n\nTwo or more sources resolve to the same URL — one would shadow the other on the deployed site (Astro silently dedupes colliding routes). Rename or move one source in each pair.";
1153
+ }
1154
+ const PAGE_EXTS = new Set([
1155
+ ".astro",
1156
+ ".ts",
1157
+ ".js",
1158
+ ".md",
1159
+ ".mdx"
1160
+ ]);
1161
+ function enumerateStaticPageRoutes(pagesRoot, projectRoot) {
1162
+ const out = [];
1163
+ walkPages(pagesRoot, pagesRoot, (relPath) => {
1164
+ const ext = path.extname(relPath);
1165
+ if (!PAGE_EXTS.has(ext)) return;
1166
+ if (path.basename(relPath).startsWith("_")) return;
1167
+ const parts = relPath.replace(/\\/g, "/").split("/");
1168
+ const bareLeaf = parts[parts.length - 1].replace(/\.[^.]+$/, "");
1169
+ if (parts.slice(0, -1).some(isDynamicSegment) || isDynamicSegment(bareLeaf)) return;
1170
+ const url = fileToRoute(parts, ext);
1171
+ const sourceAbs = path.join(pagesRoot, relPath);
1172
+ const source = path.relative(projectRoot, sourceAbs).replace(/\\/g, "/");
1173
+ out.push({
1174
+ url,
1175
+ source
1176
+ });
1177
+ });
1178
+ return out;
1179
+ }
1180
+ function walkPages(dir, root, visit) {
1181
+ let dirents;
1182
+ try {
1183
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
1184
+ } catch (err) {
1185
+ if (err.code === "ENOENT") return;
1186
+ throw err;
1187
+ }
1188
+ for (const entry of dirents) {
1189
+ const full = path.join(dir, entry.name);
1190
+ if (entry.isDirectory()) {
1191
+ if (entry.name.startsWith("_") || entry.name === "node_modules") continue;
1192
+ walkPages(full, root, visit);
1193
+ } else if (entry.isFile()) visit(path.relative(root, full));
1194
+ }
1195
+ }
1196
+ function isDynamicSegment(seg) {
1197
+ return seg.startsWith("[") && seg.endsWith("]") && seg.length >= 3;
1198
+ }
1199
+ /**
1200
+ * Translate a static page file path (no dynamic segments) to its URL.
1201
+ *
1202
+ * pages/index.astro → /
1203
+ * pages/search.astro → /search
1204
+ * pages/Search.astro → /search (Astro lowercases via joinSegments)
1205
+ * pages/llms.txt.ts → /llms.txt (endpoint: strip .ts, keep .txt)
1206
+ * pages/blog/index.astro → /blog
1207
+ * pages/blog/post.md → /blog/post
1208
+ */
1209
+ function fileToRoute(parts, ext) {
1210
+ const cloned = [...parts];
1211
+ const last = cloned[cloned.length - 1];
1212
+ if ((ext === ".ts" || ext === ".js") && /\.[^.]+\.[tj]s$/.test(last)) cloned[cloned.length - 1] = last.replace(/\.[tj]s$/, "");
1213
+ else cloned[cloned.length - 1] = last.replace(/\.[^.]+$/, "");
1214
+ if (cloned[cloned.length - 1] === "index") cloned.pop();
1215
+ const joined = cloned.map((s) => s.toLowerCase()).join("/");
1216
+ return joined === "" ? "/" : `/${joined}`;
1217
+ }
1218
+ /**
1219
+ * Compute the mounted URL a content entry resolves to. Exposed so callers
1220
+ * (the integration's pre-build dup-check) can build `RouteOwner` records
1221
+ * with the same logic the framework uses everywhere else.
1222
+ */
1223
+ function contentEntryUrl(entry, versions) {
1224
+ return canonicalEntryUrl(collectionMountPrefix(entry.collection, versions), entry.id);
1225
+ }
1226
+
823
1227
  //#endregion
824
1228
  //#region src/_internal/parse-content-collections.ts
825
1229
  /**
@@ -859,7 +1263,7 @@ const EXPORT_PREFIX_PATTERN = /export\s+const\s+collections\s*(?::\s*[^=]+)?=\s*
859
1263
  async function parseContentCollections(filePath) {
860
1264
  let source;
861
1265
  try {
862
- source = await fs.readFile(filePath, "utf8");
1266
+ source = await fs$1.readFile(filePath, "utf8");
863
1267
  } catch (err) {
864
1268
  if (err.code === "ENOENT") return null;
865
1269
  throw err;
@@ -884,6 +1288,181 @@ async function parseContentCollections(filePath) {
884
1288
  return names;
885
1289
  }
886
1290
  /**
1291
+ * Sibling of `parseContentCollections` that also extracts each entry's
1292
+ * `base` option — the folder name under `src/content/` the collection
1293
+ * loads from. Returns a `Map<key, folderName>` where the folder defaults
1294
+ * to the collection key when no `base:` override is present.
1295
+ *
1296
+ * Used by the `nimbus/duplicate-slug` validator and any other framework
1297
+ * code that needs to walk a collection's actual on-disk location rather
1298
+ * than assuming the folder name matches the collection key. Astro's
1299
+ * content layer respects the `base` option — `docsCollection({ base:
1300
+ * "documentation" })` loads from `src/content/documentation/` while still
1301
+ * registering as collection `docs`. Without this map, filesystem-walking
1302
+ * code would mis-tag those entries (`collection: "documentation"`) and
1303
+ * either flag bogus collisions or silently skip them via the indexable-
1304
+ * collections filter.
1305
+ *
1306
+ * Extraction is regex-based — for each entry's value text, looks for
1307
+ * `\bbase:\s*["']([^"']+)["']`. That covers the documented Nimbus pattern
1308
+ * (`docsCollection({ base: "..." })`, `partialsCollection({ base: "..." })`,
1309
+ * `componentsCollection({ base: "..." })`). Limitations:
1310
+ *
1311
+ * - Computed/dynamic bases (`base: someVar`) fall back to the collection
1312
+ * key. A future regression would silently miscount; for now,
1313
+ * accepted as a known v1 limitation.
1314
+ * - Hand-rolled `defineCollection({ loader: glob({ base: "./src/content/x" }) })`
1315
+ * puts a *path* in `base`, not a folder name. The extracted value
1316
+ * starts with `./src/content/` and won't match a folder under
1317
+ * `src/content/` directly. Users who write the loader by hand can
1318
+ * keep folder names matching collection keys, or accept that
1319
+ * `duplicate-slug` won't see their non-conforming collection until
1320
+ * they migrate to the Nimbus helpers.
1321
+ *
1322
+ * Returns `null` when the file is missing or unparseable, matching
1323
+ * `parseContentCollections`'s contract.
1324
+ */
1325
+ async function parseCollectionBases(filePath) {
1326
+ let source;
1327
+ try {
1328
+ source = await fs$1.readFile(filePath, "utf8");
1329
+ } catch (err) {
1330
+ if (err.code === "ENOENT") return null;
1331
+ throw err;
1332
+ }
1333
+ const stripped = source.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
1334
+ const prefixMatch = stripped.match(EXPORT_PREFIX_PATTERN);
1335
+ if (!prefixMatch || prefixMatch.index === void 0) return null;
1336
+ const objectStart = prefixMatch.index + prefixMatch[0].length;
1337
+ const objectEnd = findMatchingBrace(stripped, objectStart - 1);
1338
+ if (objectEnd === -1) return null;
1339
+ const body = stripped.slice(objectStart, objectEnd);
1340
+ const out = /* @__PURE__ */ new Map();
1341
+ for (const raw of splitTopLevelCommas(body)) {
1342
+ const entry = raw.trim();
1343
+ if (!entry) continue;
1344
+ if (entry.startsWith("...")) continue;
1345
+ if (entry.startsWith("[")) continue;
1346
+ const colonIdx = entry.indexOf(":");
1347
+ const key = (colonIdx === -1 ? entry : entry.slice(0, colonIdx)).trim().replace(/^['"`]|['"`]$/g, "");
1348
+ if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) continue;
1349
+ const baseMatch = (colonIdx === -1 ? findLocalDeclarationValue(stripped, key) : entry.slice(colonIdx + 1)).match(/\bbase\s*:\s*["']([^"']+)["']/);
1350
+ out.set(key, baseMatch ? baseMatch[1] : key);
1351
+ }
1352
+ return out;
1353
+ }
1354
+ /**
1355
+ * Locate `const|let|var <identifier> = <value>` at any depth in the source
1356
+ * and return the captured `<value>` text. Used to resolve shorthand
1357
+ * collection entries (`{ docs }` in the registration object refers to a
1358
+ * `const docs = defineCollection(...)` declared somewhere above).
1359
+ *
1360
+ * The walker is brace/bracket/paren-aware and string-literal-aware so the
1361
+ * captured value spans nested object/function-call expressions without
1362
+ * losing depth. It stops at the next top-level `;` or end-of-input.
1363
+ *
1364
+ * Limitations (false negatives — never false positives):
1365
+ * - Identifiers imported from other modules can't be resolved (we don't
1366
+ * follow imports).
1367
+ * - Type-only annotations using `=>` (`const docs: () => X = …`) would
1368
+ * fool the `=` detection. Realistic collection declarations don't use
1369
+ * this shape; if a user does, the check falls back to the key.
1370
+ */
1371
+ function findLocalDeclarationValue(source, identifier) {
1372
+ const safeId = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1373
+ const declRe = new RegExp(`\\b(?:const|let|var)\\s+${safeId}\\b`, "g");
1374
+ let match;
1375
+ while ((match = declRe.exec(source)) !== null) {
1376
+ const eqIdx = findAssignmentEquals(source, match.index + match[0].length);
1377
+ if (eqIdx === -1) continue;
1378
+ const endIdx = findStatementEnd(source, eqIdx + 1);
1379
+ return source.slice(eqIdx + 1, endIdx);
1380
+ }
1381
+ return "";
1382
+ }
1383
+ /**
1384
+ * Find the first `=` after `from` that's an assignment operator —
1385
+ * skipping `==`, `===`, and `=>`. Used to locate where the assignment
1386
+ * value begins in `const id [: Type] = value;`.
1387
+ */
1388
+ function findAssignmentEquals(source, from) {
1389
+ for (let i = from; i < source.length; i++) {
1390
+ if (source[i] !== "=") continue;
1391
+ if (source[i + 1] === "=") {
1392
+ i++;
1393
+ continue;
1394
+ }
1395
+ if (source[i + 1] === ">") {
1396
+ i++;
1397
+ continue;
1398
+ }
1399
+ if (source[i - 1] === "!" || source[i - 1] === "<" || source[i - 1] === ">") continue;
1400
+ return i;
1401
+ }
1402
+ return -1;
1403
+ }
1404
+ /**
1405
+ * Walk forward from `from` tracking brace/bracket/paren depth and string
1406
+ * literals; return the index of the statement terminator.
1407
+ *
1408
+ * Terminates at the first of:
1409
+ * 1. A top-level `;`.
1410
+ * 2. A top-level newline that occurs *after* the value expression has
1411
+ * produced any non-whitespace content. This is the ASI rule the
1412
+ * walker has to honor for semicolonless code:
1413
+ *
1414
+ * ```
1415
+ * const docs = defineCollection(docsCollection()) // ← stop here
1416
+ * export const collections = { … } // ← not part of `docs`
1417
+ * ```
1418
+ *
1419
+ * Without the ASI rule the walker would swallow the next statement
1420
+ * and the `base:` regex could read an unrelated value.
1421
+ * 3. End-of-input.
1422
+ *
1423
+ * The "after non-whitespace" gate handles the leading-newline case:
1424
+ *
1425
+ * ```
1426
+ * const docs =
1427
+ * defineCollection(docsCollection({ base: "x" }))
1428
+ * ```
1429
+ *
1430
+ * Here the newline right after `=` doesn't terminate; the walker
1431
+ * keeps scanning until the value starts. Once content has been seen,
1432
+ * the next top-level newline ends the statement.
1433
+ *
1434
+ * Limitations: method-chain continuations like `defineCollection(…)\n .extend(…)`
1435
+ * stop at the first `)`. That captures the inner call's options (where any
1436
+ * `base:` would live), so the result is still correct in practice.
1437
+ */
1438
+ function findStatementEnd(source, from) {
1439
+ let depth = 0;
1440
+ let inString = null;
1441
+ let sawContent = false;
1442
+ for (let i = from; i < source.length; i++) {
1443
+ const ch = source[i];
1444
+ if (inString) {
1445
+ if (ch === "\\") {
1446
+ i++;
1447
+ continue;
1448
+ }
1449
+ if (ch === inString) inString = null;
1450
+ continue;
1451
+ }
1452
+ if (ch === "\"" || ch === "'" || ch === "`") {
1453
+ inString = ch;
1454
+ sawContent = true;
1455
+ } else if (ch === "{" || ch === "[" || ch === "(") {
1456
+ depth++;
1457
+ sawContent = true;
1458
+ } else if (ch === "}" || ch === "]" || ch === ")") depth--;
1459
+ else if (ch === ";" && depth === 0) return i;
1460
+ else if (ch === "\n" && depth === 0 && sawContent) return i;
1461
+ else if (ch !== " " && ch !== " " && ch !== "\r" && ch !== "\n") sawContent = true;
1462
+ }
1463
+ return source.length;
1464
+ }
1465
+ /**
887
1466
  * Starting from an opening brace at `openIdx`, walk forward tracking brace
888
1467
  * depth (and skipping string literals + nested brackets) and return the
889
1468
  * index of the matching close brace. Returns `-1` if no match is found —
@@ -1038,52 +1617,6 @@ function titleAndLangTransformer() {
1038
1617
  };
1039
1618
  }
1040
1619
 
1041
- //#endregion
1042
- //#region src/_internal/levenshtein.ts
1043
- /**
1044
- * Tiny Levenshtein distance + "did you mean" suggester.
1045
- *
1046
- * Used by the MDX PascalCase validator and any framework diagnostic that
1047
- * wants to suggest a near-match on a misspelled name. Kept internal — user
1048
- * code that wants the same hint duplicates ~10 lines rather than depending
1049
- * on a framework wrapper. See the north-star guardrail on thin wrappers.
1050
- */
1051
- function levenshtein(a, b) {
1052
- if (a === b) return 0;
1053
- if (a.length === 0) return b.length;
1054
- if (b.length === 0) return a.length;
1055
- const v0 = new Array(b.length + 1);
1056
- const v1 = new Array(b.length + 1);
1057
- for (let i = 0; i <= b.length; i++) v0[i] = i;
1058
- for (let i = 0; i < a.length; i++) {
1059
- v1[0] = i + 1;
1060
- for (let j = 0; j < b.length; j++) {
1061
- const cost = a[i] === b[j] ? 0 : 1;
1062
- v1[j + 1] = Math.min(v1[j] + 1, v0[j + 1] + 1, v0[j] + cost);
1063
- }
1064
- for (let j = 0; j <= b.length; j++) v0[j] = v1[j];
1065
- }
1066
- return v1[b.length];
1067
- }
1068
- /**
1069
- * Return the closest candidate within `maxDist`, or null.
1070
- *
1071
- * Comparison is case-insensitive (so "tabs" suggests "Tabs"), but the
1072
- * returned name keeps its original casing.
1073
- */
1074
- function suggest(target, candidates, maxDist = 3) {
1075
- const targetLower = target.toLowerCase();
1076
- let best = null;
1077
- for (const c of candidates) {
1078
- const dist = levenshtein(targetLower, c.toLowerCase());
1079
- if (dist <= maxDist && (!best || dist < best.dist)) best = {
1080
- name: c,
1081
- dist
1082
- };
1083
- }
1084
- return best?.name ?? null;
1085
- }
1086
-
1087
1620
  //#endregion
1088
1621
  //#region src/_internal/validate-mdx-content.ts
1089
1622
  /**
@@ -1125,7 +1658,7 @@ async function validateMdxContent(options) {
1125
1658
  const files = await walkMdx(dir);
1126
1659
  for (const file of files) {
1127
1660
  if (options.skip?.(file)) continue;
1128
- const fileFailures = scanFile(await fs.readFile(file, "utf8"), globalsSet);
1661
+ const fileFailures = scanFile(await fs$1.readFile(file, "utf8"), globalsSet);
1129
1662
  for (const f of fileFailures) {
1130
1663
  const knownNames = [...globalsSet, ...f.imports];
1131
1664
  failures.push({
@@ -1156,7 +1689,7 @@ async function walkMdx(dir) {
1156
1689
  async function visit(current) {
1157
1690
  let entries;
1158
1691
  try {
1159
- entries = await fs.readdir(current, { withFileTypes: true });
1692
+ entries = await fs$1.readdir(current, { withFileTypes: true });
1160
1693
  } catch (err) {
1161
1694
  if (err.code === "ENOENT") return;
1162
1695
  throw err;
@@ -1283,16 +1816,20 @@ const headElementSchema = z.object({
1283
1816
  attrs: z.record(z.string(), z.string()).default({}),
1284
1817
  content: z.string().optional()
1285
1818
  });
1286
- const featuresSchema = z.object({
1287
- search: z.boolean().default(true),
1288
- editLinks: z.boolean().default(true),
1289
- pagination: z.boolean().default(true),
1290
- toc: z.boolean().default(true)
1819
+ const featuresSchema = withStrictKeys(z.object({
1820
+ sidebar: z.boolean().default(true),
1821
+ tableOfContents: z.boolean().default(true)
1822
+ }), {
1823
+ removedKeys: {
1824
+ toc: "was renamed to \"tableOfContents\". Replace `features: { toc: false }` with `features: { tableOfContents: false }`.",
1825
+ pagination: "was removed. To hide pagination site-wide, remove `<Pagination />` from `src/layouts/DocsLayout.astro` (it is user-owned).",
1826
+ editLinks: "was removed. To hide edit links site-wide, omit `editPattern` from the config — the default is null, which produces no edit URLs. Setting `github` alone does not enable edit links.",
1827
+ search: "moved to the top-level `search` field on the config. Replace `features: { search: false }` with `search: false`."
1828
+ },
1829
+ contextLabel: "features sub-key"
1291
1830
  }).default({
1292
- search: true,
1293
- editLinks: true,
1294
- pagination: true,
1295
- toc: true
1831
+ sidebar: true,
1832
+ tableOfContents: true
1296
1833
  });
1297
1834
  const searchSchema = z.union([z.literal(false), z.object({ provider: z.enum(["pagefind", "custom"]).default("pagefind") })]).optional();
1298
1835
  const sidebarSchema = z.object({
@@ -1331,16 +1868,14 @@ const versionsSchema = z.object({
1331
1868
  path: ["hidden", i]
1332
1869
  });
1333
1870
  }).optional();
1334
- const nimbusConfigSchema = z.object({
1871
+ const nimbusConfigSchema = withStrictKeys(z.object({
1335
1872
  site: z.string().url({ message: "\"site\" must be a valid URL" }),
1336
1873
  title: z.string(),
1337
1874
  description: z.string().optional(),
1338
- logo: z.string().max(2),
1339
1875
  locale: z.string().default("en"),
1340
1876
  homeLabel: z.string().default("Home"),
1341
1877
  github: z.string().url().nullable().default(null),
1342
1878
  editPattern: z.string().nullable().default(null).refine((v) => v === null || v.includes("{path}"), { message: "\"editPattern\" must contain the \"{path}\" placeholder, which is replaced with the entry source path. Example: \"https://github.com/my-org/my-repo/edit/main/{path}\"" }),
1343
- footer: z.string().default("Built with Nimbus"),
1344
1879
  socialImage: z.string({ error: "\"socialImage\" must be a string (path or URL)" }).optional(),
1345
1880
  socialImageAlt: z.string({ error: "\"socialImageAlt\" must be a string" }).optional(),
1346
1881
  head: z.array(headElementSchema).default([]),
@@ -1348,6 +1883,12 @@ const nimbusConfigSchema = z.object({
1348
1883
  features: featuresSchema,
1349
1884
  search: searchSchema,
1350
1885
  versions: versionsSchema
1886
+ }), {
1887
+ removedKeys: {
1888
+ logo: "was removed. The header now renders `config.title` as text. To use a logo image, edit `src/components/Header.astro` and drop in an <img> or <svg>.",
1889
+ footer: "was removed. The starter no longer ships a default `Footer.astro`. To add one, create your own component and render it in `src/layouts/DocsLayout.astro`."
1890
+ },
1891
+ contextLabel: "Config field"
1351
1892
  });
1352
1893
  function validateNimbusConfig(input) {
1353
1894
  const result = nimbusConfigSchema.safeParse(input);
@@ -1416,13 +1957,13 @@ function virtualConfigPlugin(config, extras) {
1416
1957
  * versioned-docs collections. Drafts (frontmatter `draft: true`) are
1417
1958
  * filtered. Consumers feed this into `buildVersionAlternates()`.
1418
1959
  */
1419
- const PRIMARY_COLLECTION$2 = "docs";
1960
+ const PRIMARY_COLLECTION = "docs";
1420
1961
  const EXTENSIONS = new Set([".mdx", ".md"]);
1421
1962
  async function scanVersionFrontmatter(options) {
1422
1963
  const { projectRoot, versions } = options;
1423
1964
  const out = [];
1424
1965
  const collectionsToScan = [{
1425
- collection: PRIMARY_COLLECTION$2,
1966
+ collection: PRIMARY_COLLECTION,
1426
1967
  dir: path.join(projectRoot, "src/content/docs")
1427
1968
  }, ...versions.others.map((slug) => ({
1428
1969
  collection: `docs-${slug}`,
@@ -1433,7 +1974,7 @@ async function scanVersionFrontmatter(options) {
1433
1974
  for (const file of files) {
1434
1975
  let source;
1435
1976
  try {
1436
- source = await fs.readFile(file, "utf8");
1977
+ source = await fs$1.readFile(file, "utf8");
1437
1978
  } catch {
1438
1979
  continue;
1439
1980
  }
@@ -1456,7 +1997,7 @@ async function walk(dir) {
1456
1997
  async function visit(current) {
1457
1998
  let entries;
1458
1999
  try {
1459
- entries = await fs.readdir(current, { withFileTypes: true });
2000
+ entries = await fs$1.readdir(current, { withFileTypes: true });
1460
2001
  } catch (err) {
1461
2002
  if (err.code === "ENOENT") return;
1462
2003
  throw err;
@@ -1585,7 +2126,6 @@ function idFromPath(collectionDir, filePath) {
1585
2126
 
1586
2127
  //#endregion
1587
2128
  //#region src/_internal/version-alternates.ts
1588
- const PRIMARY_COLLECTION$1 = "docs";
1589
2129
  /**
1590
2130
  * Build the alternates table for one site.
1591
2131
  *
@@ -1694,8 +2234,10 @@ function buildVersionAlternates(versions, entries) {
1694
2234
  *
1695
2235
  * Returns a list of `{ from, to }` redirect pairs ready for Astro's
1696
2236
  * `redirects` config. `from` is the URL the reader hit; `to` is the
1697
- * current-version sibling. Both are absolute paths, leading slash, no
1698
- * trailing slash. Astro applies trailing-slash normalisation on output.
2237
+ * current-version sibling. Both are absolute paths in the trailing-slash
2238
+ * browser-href form Astro serves under `build.format: "directory"`.
2239
+ * Astro's default `trailingSlash: "ignore"` matches incoming requests in
2240
+ * either form, so a reader landing on `/v1/foo` still resolves.
1699
2241
  */
1700
2242
  function computeMissingPageRedirects(versions, table, entries) {
1701
2243
  if (!versions || versions.all.length < 2) return [];
@@ -1714,9 +2256,7 @@ function computeMissingPageRedirects(versions, table, entries) {
1714
2256
  if (existing.has(`${oldVersion}:${entry.id}`)) continue;
1715
2257
  if (table[refKey({
1716
2258
  collection: entry.collection,
1717
- version,
1718
- slug: entry.id,
1719
- url: currentUrl
2259
+ slug: entry.id
1720
2260
  })]?.alternates.some((a) => a.version === oldVersion)) continue;
1721
2261
  redirects.push({
1722
2262
  from: pageUrl(versions, oldVersion, entry.id),
@@ -1744,10 +2284,14 @@ function collectionToVersion(versions, collection) {
1744
2284
  * `index.ts::resolveCollectionPrefix`:
1745
2285
  * - current version → root (`/foo`)
1746
2286
  * - others → `/<version>/<slug>`
2287
+ *
2288
+ * Runs through `canonicalEntryUrl` so the result matches what Astro
2289
+ * serves (lowercase + folder-index strip — see `_internal/astro-slug.ts`
2290
+ * for the why and known caveats). The `<link rel="alternate">` tags and
2291
+ * auto-redirect machinery both consume these URLs.
1747
2292
  */
1748
2293
  function pageUrl(versions, version, slug) {
1749
- if (version === versions.current) return `/${slug}`;
1750
- return `/${version}/${slug}`;
2294
+ return toBrowserHref(canonicalEntryUrl(version === versions.current ? "" : `/${version}`, slug));
1751
2295
  }
1752
2296
 
1753
2297
  //#endregion
@@ -1784,12 +2328,19 @@ function pageUrl(versions, version, slug) {
1784
2328
  */
1785
2329
  function nimbus(rawConfig, options = {}) {
1786
2330
  const config = validateNimbusConfig(rawConfig);
2331
+ const lintOptions = validateLintOptions({
2332
+ rules: options.rules,
2333
+ collections: options.collections
2334
+ }, IMPLEMENTED_CODES);
2335
+ let projectRootForBuild = "";
2336
+ let astroBaseForBuild = "";
1787
2337
  return {
1788
2338
  name: "nimbus-docs",
1789
2339
  hooks: {
1790
2340
  "astro:config:setup": async (params) => {
1791
2341
  const { updateConfig, config: astroConfig, logger } = params;
1792
2342
  const integrationsToAdd = [];
2343
+ materializeLintConfig(fileURLToPath(astroConfig.root), lintOptions.rules, lintOptions.collections, config.site);
1793
2344
  if (options.validateMdx !== false) {
1794
2345
  const validateOpts = typeof options.validateMdx === "object" ? options.validateMdx : {};
1795
2346
  const projectRoot = fileURLToPath(astroConfig.root);
@@ -1809,9 +2360,26 @@ function nimbus(rawConfig, options = {}) {
1809
2360
  }
1810
2361
  }
1811
2362
  const projectRoot = fileURLToPath(astroConfig.root);
1812
- const rawCollections = await parseContentCollections(path.join(projectRoot, "src/content.config.ts"));
2363
+ projectRootForBuild = projectRoot;
2364
+ astroBaseForBuild = astroConfig.base ?? "";
2365
+ const contentConfigPath = path.join(projectRoot, "src/content.config.ts");
2366
+ const rawCollections = await parseContentCollections(contentConfigPath);
2367
+ const collectionBases = await parseCollectionBases(contentConfigPath);
1813
2368
  const indexedCollections = rawCollections === null ? ["docs"] : filterIndexableCollections(rawCollections);
1814
2369
  if (rawCollections === null) logger.warn("nimbus-docs: `src/content.config.ts` is missing or doesn't expose a parseable `export const collections = { ... }`. Falling back to indexing the `docs` collection only.");
2370
+ const indexedSet = new Set(indexedCollections);
2371
+ const versionInfo = config.versions ? { others: config.versions.others ?? [] } : null;
2372
+ const indexedBases = /* @__PURE__ */ new Map();
2373
+ if (collectionBases !== null) {
2374
+ for (const [key, base] of collectionBases) if (indexedSet.has(key)) indexedBases.set(key, base);
2375
+ } else for (const key of indexedCollections) indexedBases.set(key, key);
2376
+ const contentOwners = enumerateEntriesByBase(path.join(projectRoot, "src/content"), indexedBases).map((entry) => ({
2377
+ url: contentEntryUrl(entry, versionInfo),
2378
+ source: `src/content/${entry.relPath}`
2379
+ }));
2380
+ const pageOwners = enumerateStaticPageRoutes(path.join(projectRoot, "src/pages"), projectRoot);
2381
+ const duplicateRoutes = findDuplicateRoutes([...contentOwners, ...pageOwners]);
2382
+ if (duplicateRoutes.length > 0) throw new Error(formatDuplicateRoutes(duplicateRoutes));
1815
2383
  if (config.versions && rawCollections !== null) {
1816
2384
  const registered = new Set(rawCollections);
1817
2385
  const missing = config.versions.others.filter((slug) => !registered.has(`docs-${slug}`));
@@ -1878,13 +2446,74 @@ function nimbus(rawConfig, options = {}) {
1878
2446
  ].join("\n")
1879
2447
  });
1880
2448
  },
1881
- "astro:build:done": async ({ dir }) => {
2449
+ "astro:build:done": async ({ dir, pages, logger }) => {
2450
+ materializeRouteTruthFromPages(projectRootForBuild, astroBaseForBuild, pages, logger);
1882
2451
  if (config.search === false || config.search?.provider === "custom") return;
1883
2452
  await runPagefind(fileURLToPath(dir));
1884
2453
  }
1885
2454
  }
1886
2455
  };
1887
2456
  }
2457
+ /**
2458
+ * Write the resolved authoring-lint config to `<root>/.nimbus/lint.json`
2459
+ * for the standalone CLI. Best-effort: any filesystem error is swallowed
2460
+ * so it can't fail an `astro build`. `.nimbus/` is a gitignored scratch
2461
+ * dir (same home the Vale recipe uses).
2462
+ *
2463
+ * `site` is materialized alongside the rules so site-aware rules
2464
+ * (`no-self-host-url`) get the project's deploy host without making the
2465
+ * user duplicate it in their lint config.
2466
+ */
2467
+ function materializeLintConfig(projectRoot, rules, collections, site) {
2468
+ try {
2469
+ const dir = path.join(projectRoot, ".nimbus");
2470
+ fs.mkdirSync(dir, { recursive: true });
2471
+ fs.writeFileSync(path.join(dir, "lint.json"), JSON.stringify({
2472
+ version: 1,
2473
+ rules,
2474
+ collections,
2475
+ site
2476
+ }, null, 2) + "\n", "utf8");
2477
+ } catch {}
2478
+ }
2479
+ /**
2480
+ * Write the site's route truth to `<root>/.nimbus/routes.json` from the
2481
+ * `pages` array Astro hands us at `astro:build:done`. Each entry in `pages`
2482
+ * is a real emitted URL — no reconstruction, no slug mirroring.
2483
+ *
2484
+ * Best-effort write, same as `materializeLintConfig`. When the file is
2485
+ * missing (e.g. lint ran before any `astro build`), `internal-link` skips
2486
+ * silently rather than false-positive.
2487
+ *
2488
+ * Duplicate-slug detection lives in `astro:config:setup` (above), not
2489
+ * here. Astro silently dedupes colliding routes before this hook fires,
2490
+ * so a post-build collision check on `pages` would never see the
2491
+ * collisions it claims to catch.
2492
+ */
2493
+ function materializeRouteTruthFromPages(projectRoot, base, pages, logger) {
2494
+ const canonical = /* @__PURE__ */ new Set();
2495
+ for (const { pathname } of pages) canonical.add(canonicalizePathname(pathname));
2496
+ const truth = {
2497
+ version: 1,
2498
+ base,
2499
+ knownRoutes: [...canonical].sort(),
2500
+ opaqueNamespaces: []
2501
+ };
2502
+ try {
2503
+ const dir = path.join(projectRoot, ".nimbus");
2504
+ fs.mkdirSync(dir, { recursive: true });
2505
+ fs.writeFileSync(path.join(dir, "routes.json"), JSON.stringify(truth, null, 2) + "\n", "utf8");
2506
+ } catch (err) {
2507
+ logger.debug?.(`failed to write .nimbus/routes.json — internal-link will skip: ${err.message}`);
2508
+ }
2509
+ }
2510
+ function canonicalizePathname(pathname) {
2511
+ let s = pathname;
2512
+ if (s === "") return "/";
2513
+ if (!s.startsWith("/")) s = `/${s}`;
2514
+ if (s.length > 1 && s.endsWith("/")) s = s.slice(0, -1);
2515
+ return s;
2516
+ }
1888
2517
  function runPagefind(siteDir) {
1889
2518
  const bin = process.platform === "win32" ? "pagefind.cmd" : "pagefind";
1890
2519
  return new Promise((resolve) => {
@@ -1910,8 +2539,6 @@ function runPagefind(siteDir) {
1910
2539
  * by our Vite plugin) and content entries from `astro:content`. Both
1911
2540
  * are external in tsdown and resolved at the consumer's build time.
1912
2541
  */
1913
- /** Primary collection name — kept in sync with `_internal/content.ts`. */
1914
- const PRIMARY_COLLECTION = "docs";
1915
2542
  /**
1916
2543
  * Define a typed Nimbus config. Returns the config unchanged but inferred.
1917
2544
  */
@@ -1930,55 +2557,20 @@ function defineConfig(config) {
1930
2557
  * - **Schema-tolerant.** Reads `title` and `description` if present;
1931
2558
  * falls back to the entry id for the title and omits the
1932
2559
  * description otherwise.
1933
- * - **Per-page filters baked in.** Drops entries with `llms: false`
1934
- * or `draft: true` when those fields exist; absent fields read as
1935
- * the docs-schema defaults (`llms: true`, `draft: false`).
2560
+ * - **Per-page filters baked in.** Drops entries with `draft: true`;
2561
+ * absent fields read as the docs-schema default (`draft: false`).
2562
+ * All published pages are indexed there is no per-page opt-out.
2563
+ * A page that genuinely shouldn't be agent-readable should be kept
2564
+ * out of the content collection entirely.
1936
2565
  *
1937
2566
  * The returned shape is identical regardless of which factory created
1938
2567
  * the collection: hand-rolled `defineCollection({ loader, schema })`
1939
2568
  * collections work without modification.
1940
2569
  */
1941
- /**
1942
- * Resolve the URL-prefix segment for a given collection ID.
1943
- *
1944
- * Rules, in order:
1945
- * 1. Primary `docs` collection mounts at root → returns `""`.
1946
- * 2. When `versions` is configured, a `docs-<slug>` collection whose
1947
- * slug appears in `versions.others` mounts under `/<slug>/` (not
1948
- * `/docs-<slug>/`). This is the versioning URL convention — a
1949
- * version is reached by its short label, not its collection ID.
1950
- * 3. Any other collection (`api`, `blog`, …) mounts at `/<collection>/`
1951
- * — the default multi-collection convention.
1952
- *
1953
- * Returned shape: empty string OR `/<segment>` with leading slash, no
1954
- * trailing slash. Callers append `/<entryId>` or `/index.md`.
1955
- */
1956
- function resolveCollectionPrefix(versions, collection) {
1957
- if (collection === PRIMARY_COLLECTION) return "";
1958
- if (versions && collection.startsWith("docs-")) {
1959
- const slug = collection.slice(5);
1960
- if (versions.others.includes(slug)) return `/${slug}`;
1961
- }
1962
- return `/${collection}`;
1963
- }
1964
- /**
1965
- * Resolve the URL-safe slug a collection should be referenced by — the
1966
- * label that appears in URLs and section headers. For version
1967
- * collections this is the manifest's short slug; for everything else
1968
- * it's the collection ID.
1969
- */
1970
- function resolveCollectionSlug(versions, collection) {
1971
- if (collection === PRIMARY_COLLECTION) return collection;
1972
- if (versions && collection.startsWith("docs-")) {
1973
- const slug = collection.slice(5);
1974
- if (versions.others.includes(slug)) return slug;
1975
- }
1976
- return collection;
1977
- }
1978
2570
  async function getIndexedEntries() {
1979
2571
  const { getCollection } = await import("astro:content");
1980
2572
  const collectionNames = await loadIndexedCollections();
1981
- const names = collectionNames.length > 0 ? collectionNames : [PRIMARY_COLLECTION];
2573
+ const names = collectionNames.length > 0 ? collectionNames : [PRIMARY_COLLECTION$1];
1982
2574
  const versions = await getVersions();
1983
2575
  const indexed = [];
1984
2576
  for (const name of names) {
@@ -1988,20 +2580,22 @@ async function getIndexedEntries() {
1988
2580
  } catch {
1989
2581
  continue;
1990
2582
  }
1991
- const prefix = resolveCollectionPrefix(versions, name);
2583
+ const prefix = collectionMountPrefix(name, versions);
1992
2584
  for (const entry of entries) {
1993
2585
  const data = entry.data ?? {};
1994
- if (data.llms === false) continue;
1995
2586
  if (data.draft === true) continue;
1996
2587
  const title = typeof data.title === "string" && data.title.length > 0 ? data.title : entry.id;
1997
2588
  const rawDescription = data.description;
1998
2589
  const description = typeof rawDescription === "string" && rawDescription.length > 0 ? rawDescription : void 0;
2590
+ const canonicalUrl = canonicalEntryUrl(prefix, entry.id);
2591
+ const markdownUrl = canonicalUrl === "/" ? "/index.md" : `${canonicalUrl}/index.md`;
1999
2592
  indexed.push({
2000
2593
  entry,
2001
2594
  collection: name,
2002
2595
  title,
2003
2596
  description,
2004
- url: `${prefix}/${entry.id}`
2597
+ url: toBrowserHref(canonicalUrl),
2598
+ markdownUrl
2005
2599
  });
2006
2600
  }
2007
2601
  }
@@ -2025,13 +2619,13 @@ async function getIndexedTopLevel() {
2025
2619
  const secondaryBuckets = /* @__PURE__ */ new Map();
2026
2620
  const versionSlugs = new Set(versions?.others ?? []);
2027
2621
  const hiddenSlugs = new Set(versions?.hidden ?? []);
2028
- for (const item of items) if (item.collection === PRIMARY_COLLECTION) {
2622
+ for (const item of items) if (item.collection === PRIMARY_COLLECTION$1) {
2029
2623
  const top = item.entry.id.split("/")[0];
2030
2624
  const bucket = primaryBuckets.get(top);
2031
2625
  if (bucket) bucket.push(item);
2032
2626
  else primaryBuckets.set(top, [item]);
2033
2627
  } else {
2034
- const slug = resolveCollectionSlug(versions, item.collection);
2628
+ const slug = collectionLabel(item.collection, versions);
2035
2629
  const bucket = secondaryBuckets.get(slug);
2036
2630
  if (bucket) bucket.push(item);
2037
2631
  else secondaryBuckets.set(slug, [item]);
@@ -2126,13 +2720,13 @@ async function getSidebarSections(currentSlug, options) {
2126
2720
  async function buildFullSidebarTree(currentSlug, pageCollection) {
2127
2721
  const runtimeConfig = await loadNimbusConfig();
2128
2722
  const versions = await getVersions();
2129
- let effectivePrimary = PRIMARY_COLLECTION;
2723
+ let effectivePrimary = PRIMARY_COLLECTION$1;
2130
2724
  let primaryPrefix = "";
2131
2725
  if (versions && pageCollection && pageCollection.startsWith("docs-") && versions.others.includes(pageCollection.slice(5))) {
2132
2726
  effectivePrimary = pageCollection;
2133
- primaryPrefix = resolveCollectionPrefix(versions, pageCollection);
2727
+ primaryPrefix = collectionMountPrefix(pageCollection, versions);
2134
2728
  }
2135
- const rewrittenItems = effectivePrimary !== PRIMARY_COLLECTION ? rewriteSidebarItemsForVersion(runtimeConfig.sidebar?.items, effectivePrimary) : runtimeConfig.sidebar?.items;
2729
+ const rewrittenItems = effectivePrimary !== PRIMARY_COLLECTION$1 ? rewriteSidebarItemsForVersion(runtimeConfig.sidebar?.items, effectivePrimary) : runtimeConfig.sidebar?.items;
2136
2730
  const referenced = collectSidebarCollectionRefs(rewrittenItems);
2137
2731
  return buildSidebarTree(await getVisibleEntriesByCollection([effectivePrimary, ...referenced.filter((c) => c !== effectivePrimary)]), effectivePrimary, currentSlug, runtimeConfig.sidebar ? {
2138
2732
  ...runtimeConfig.sidebar,
@@ -2151,7 +2745,7 @@ function rewriteSidebarItemsForVersion(items, effectivePrimary) {
2151
2745
  if (!item || typeof item !== "object") return item;
2152
2746
  const o = item;
2153
2747
  const autogen = o.autogenerate;
2154
- if (autogen && autogen.collection === PRIMARY_COLLECTION) return {
2748
+ if (autogen && autogen.collection === PRIMARY_COLLECTION$1) return {
2155
2749
  ...o,
2156
2750
  autogenerate: {
2157
2751
  ...autogen,
@@ -2170,9 +2764,20 @@ function rewriteSidebarItemsForVersion(items, effectivePrimary) {
2170
2764
  *
2171
2765
  * Walks the flattened sidebar; returns the surrounding entries. Honors
2172
2766
  * `prev`/`next` frontmatter overrides if provided.
2767
+ *
2768
+ * When an override uses the object form with an internal `link`
2769
+ * (e.g. `prev: { link: "/getting-started" }`), the link is validated
2770
+ * against every visible content entry's URL at build time. A pointer
2771
+ * to a missing page fails the build with a clear error — the same
2772
+ * staleness-detection mechanism used for `previousSlug` in versioning.
2773
+ * The string form (`prev: "Custom label"`) is a label-only override
2774
+ * and doesn't go through link validation.
2173
2775
  */
2174
2776
  async function getPrevNext(currentSlug, options) {
2175
- return getPrevNext$1(currentSlug, options?.sidebarTree ?? await getSidebar(currentSlug), options?.overrides);
2777
+ const tree = options?.sidebarTree ?? await getSidebar(currentSlug);
2778
+ const indexed = await getIndexedEntries();
2779
+ const validInternalLinks = new Set(indexed.map((e) => toRouteKey(e.url)));
2780
+ return getPrevNext$1(currentSlug, tree, options?.overrides, validInternalLinks);
2176
2781
  }
2177
2782
  /**
2178
2783
  * Build breadcrumb trail from "/" to the current page.
@@ -2383,7 +2988,7 @@ async function getVersions() {
2383
2988
  async function getCurrentVersion(collectionId) {
2384
2989
  const versions = await getVersions();
2385
2990
  if (!versions) return null;
2386
- if (collectionId === PRIMARY_COLLECTION) return versions.current;
2991
+ if (collectionId === PRIMARY_COLLECTION$1) return versions.current;
2387
2992
  if (!collectionId.startsWith("docs-")) return null;
2388
2993
  const suffix = collectionId.slice(5);
2389
2994
  return versions.all.includes(suffix) ? suffix : null;
@@ -2451,7 +3056,7 @@ async function getCanonicalUrl(collectionId, entryId) {
2451
3056
  * the wrong section.
2452
3057
  */
2453
3058
  async function getCollectionLlmsUrl(collectionId) {
2454
- if (collectionId === PRIMARY_COLLECTION) return "/llms.txt";
3059
+ if (collectionId === PRIMARY_COLLECTION$1) return "/llms.txt";
2455
3060
  const versions = await getVersions();
2456
3061
  if (versions && collectionId.startsWith("docs-")) {
2457
3062
  const slug = collectionId.slice(5);
@@ -2510,7 +3115,7 @@ async function getVersionLandingUrl(version) {
2510
3115
  const versions = await getVersions();
2511
3116
  if (!versions) return null;
2512
3117
  if (!versions.all.includes(version)) return null;
2513
- const targetCollection = version === versions.current ? PRIMARY_COLLECTION : `docs-${version}`;
3118
+ const targetCollection = version === versions.current ? PRIMARY_COLLECTION$1 : `docs-${version}`;
2514
3119
  const inVersion = (await getIndexedEntries()).filter((i) => i.collection === targetCollection);
2515
3120
  if (inVersion.length === 0) return null;
2516
3121
  const byId = new Map(inVersion.map((i) => [i.entry.id, i]));