nimbus-docs 0.1.4 → 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,14 +1,16 @@
1
- import { t as withStrictKeys } from "./strict-keys-D06tc9YZ.js";
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";
2
3
  import { execFile } from "node:child_process";
3
4
  import { promisify } from "node:util";
5
+ import fs from "node:fs";
4
6
  import path from "node:path";
5
7
  import { fileURLToPath } from "node:url";
6
8
  import mdx from "@astrojs/mdx";
7
9
  import { satteri } from "@astrojs/markdown-satteri";
8
10
  import sitemap from "@astrojs/sitemap";
9
- import fs from "node:fs/promises";
10
- import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from "@shikijs/transformers";
11
+ import fs$1 from "node:fs/promises";
11
12
  import { z } from "astro/zod";
13
+ import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from "@shikijs/transformers";
12
14
 
13
15
  //#region src/_internal/runtime-config.ts
14
16
  let _cached = null;
@@ -16,8 +18,9 @@ let _cachedCollections = null;
16
18
  let _cachedAlternates = null;
17
19
  async function loadNimbusConfig() {
18
20
  if (_cached) return _cached;
19
- _cached = (await import("virtual:nimbus/config")).config;
20
- return _cached;
21
+ const value = (await import("virtual:nimbus/config")).config;
22
+ _cached = value;
23
+ return value;
21
24
  }
22
25
  /**
23
26
  * Build-time-resolved list of collections the agent-facing routes
@@ -26,8 +29,9 @@ async function loadNimbusConfig() {
26
29
  */
27
30
  async function loadIndexedCollections() {
28
31
  if (_cachedCollections) return _cachedCollections;
29
- _cachedCollections = (await import("virtual:nimbus/config")).indexedCollections;
30
- return _cachedCollections;
32
+ const value = (await import("virtual:nimbus/config")).indexedCollections;
33
+ _cachedCollections = value;
34
+ return value;
31
35
  }
32
36
  /**
33
37
  * Build-time-resolved alternates table for cross-version SEO links.
@@ -36,14 +40,15 @@ async function loadIndexedCollections() {
36
40
  */
37
41
  async function loadVersionAlternates() {
38
42
  if (_cachedAlternates) return _cachedAlternates;
39
- _cachedAlternates = (await import("virtual:nimbus/config")).versionAlternates ?? {};
40
- return _cachedAlternates;
43
+ const value = (await import("virtual:nimbus/config")).versionAlternates ?? {};
44
+ _cachedAlternates = value;
45
+ return value;
41
46
  }
42
47
 
43
48
  //#endregion
44
49
  //#region src/_internal/content.ts
45
50
  /** Primary collection name. Hard-coded — see also `getDocsStaticPaths`. */
46
- const PRIMARY_COLLECTION$3 = "docs";
51
+ const PRIMARY_COLLECTION$2 = "docs";
47
52
  /**
48
53
  * Return visible entries from one or more collections. Drafts are
49
54
  * filtered out in production builds (matching the existing
@@ -58,7 +63,7 @@ const PRIMARY_COLLECTION$3 = "docs";
58
63
  * time. Callers that need per-collection type safety should call
59
64
  * `getCollection("api")` directly.
60
65
  */
61
- async function getVisibleEntries(collections = [PRIMARY_COLLECTION$3]) {
66
+ async function getVisibleEntries(collections = [PRIMARY_COLLECTION$2]) {
62
67
  const { getCollection } = await import("astro:content");
63
68
  const all = (await Promise.all(collections.map((name) => getCollection(name).catch(() => [])))).flat();
64
69
  return import.meta.env.PROD ? all.filter((entry) => !entry.data.draft) : all;
@@ -78,6 +83,191 @@ async function getVisibleEntriesByCollection(collections) {
78
83
  return out;
79
84
  }
80
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
+
81
271
  //#endregion
82
272
  //#region src/_internal/sidebar.ts
83
273
  const sortKeyByItem = /* @__PURE__ */ new WeakMap();
@@ -90,17 +280,6 @@ function sortSidebarItems(a, b) {
90
280
  if (keyDiff !== 0) return keyDiff;
91
281
  return a.type.localeCompare(b.type);
92
282
  }
93
- /** Ensure internal href has leading /, no trailing slash (except root) */
94
- function normalizeInternalHref(href) {
95
- let h = href.split("?")[0].split("#")[0];
96
- if (!h.startsWith("/")) h = `/${h}`;
97
- if (h.length > 1 && h.endsWith("/")) h = h.slice(0, -1);
98
- return h;
99
- }
100
- /** Strip query and hash for active-state matching */
101
- function stripQueryHash(href) {
102
- return href.split("?")[0].split("#")[0];
103
- }
104
283
  function buildEntryIndex(entries) {
105
284
  const visible = entries.filter((e) => !e.data.sidebar?.hidden);
106
285
  const byId = /* @__PURE__ */ new Map();
@@ -116,9 +295,16 @@ function buildEntryIndex(entries) {
116
295
  hasChildren
117
296
  };
118
297
  }
119
- /** 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
+ */
120
306
  function joinHref(hrefPrefix, entryId) {
121
- return `${hrefPrefix.replace(/\/$/, "")}/${entryId}`;
307
+ return toBrowserHref(canonicalEntryUrl(hrefPrefix.replace(/\/$/, ""), entryId));
122
308
  }
123
309
  function createLink(entry, currentPath, hrefPrefix = "") {
124
310
  const href = joinHref(hrefPrefix, entry.id);
@@ -130,7 +316,7 @@ function createLink(entry, currentPath, hrefPrefix = "") {
130
316
  type: "link",
131
317
  label: entry.data.sidebar?.label ?? entry.data.title,
132
318
  href,
133
- isCurrent: currentPath === href,
319
+ isCurrent: toRouteKey(currentPath) === toRouteKey(href),
134
320
  badge,
135
321
  order: entry.data.sidebar?.order ?? Number.MAX_VALUE
136
322
  };
@@ -222,6 +408,7 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
222
408
  const result = [];
223
409
  for (let i = 0; i < configItems.length; i++) {
224
410
  const item = configItems[i];
411
+ if (!item) continue;
225
412
  const order = orderStart + i;
226
413
  if (typeof item === "string") {
227
414
  const entry = byId.get(item);
@@ -230,7 +417,7 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
230
417
  link.order = order;
231
418
  result.push(link);
232
419
  } else console.warn(`[sidebar] Page "${item}" referenced in config but not found in primary collection "${primaryCollection}"`);
233
- } else if ("link" in item) if (!item.link.startsWith("/")) {
420
+ } else if ("link" in item) if (isAbsoluteUrl(item.link) || !item.link.startsWith("/")) {
234
421
  const extLink = {
235
422
  type: "external",
236
423
  label: item.label,
@@ -240,15 +427,15 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
240
427
  };
241
428
  result.push(extLink);
242
429
  } else {
243
- const href = normalizeInternalHref(item.link);
244
- const matchPath = stripQueryHash(href);
245
- const lookup = href.slice(1);
246
- 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}"`);
247
434
  const link = {
248
435
  type: "link",
249
436
  label: item.label,
250
437
  href,
251
- isCurrent: currentPath === matchPath,
438
+ isCurrent: toRouteKey(currentPath) === routeKey,
252
439
  badge: item.badge,
253
440
  order
254
441
  };
@@ -334,7 +521,7 @@ function deriveSidebarSections(items) {
334
521
  if (links.length === 0) return [];
335
522
  return [{
336
523
  label: item.label,
337
- href: item._prefix ?? links[0].href,
524
+ href: toBrowserHref(item._prefix ?? links[0].href),
338
525
  isActive: links.some((link) => link.isCurrent === true)
339
526
  }];
340
527
  });
@@ -388,8 +575,8 @@ function processHideChildren(items, entries) {
388
575
  }
389
576
  if (item._indexId) {
390
577
  if (entryById.get(item._indexId)?.data.sidebar?.hideChildren) {
391
- const indexHref = `/${item._indexId}`;
392
- 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);
393
580
  if (indexLink) {
394
581
  const link = {
395
582
  ...indexLink,
@@ -452,6 +639,54 @@ function sidebarHash(items) {
452
639
  return (hash >>> 0).toString(36).padStart(7, "0");
453
640
  }
454
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
+
455
690
  //#endregion
456
691
  //#region src/_internal/transform.ts
457
692
  function protectCode(markdown) {
@@ -611,18 +846,11 @@ function getBreadcrumbs$1(slug, homeLabel = "Home") {
611
846
  path += `/${part}`;
612
847
  crumbs.push({
613
848
  label: part.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
614
- href: path
849
+ href: toBrowserHref(path)
615
850
  });
616
851
  }
617
852
  return crumbs;
618
853
  }
619
- function normalizeInternalPath(path) {
620
- const [withoutHash] = path.split("#", 1);
621
- const [pathname] = withoutHash.split("?", 1);
622
- if (!pathname) return "/";
623
- if (pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1);
624
- return pathname;
625
- }
626
854
  function resolveOverride(override, fallback, validInternalLinks) {
627
855
  if (override === false) return void 0;
628
856
  if (override === void 0) return fallback;
@@ -635,11 +863,11 @@ function resolveOverride(override, fallback, validInternalLinks) {
635
863
  }
636
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`);
637
865
  if (override.link?.startsWith("/") && validInternalLinks) {
638
- const targetPath = normalizeInternalPath(override.link);
866
+ const targetPath = toRouteKey(override.link);
639
867
  if (!validInternalLinks.has(targetPath)) throw new Error(`prev/next override link "${override.link}" does not match any existing internal docs route`);
640
868
  }
641
869
  const label = override.label ?? fallback?.label;
642
- const href = override.link ?? fallback?.href;
870
+ const href = override.link ? toBrowserHref(override.link) : fallback?.href;
643
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");
644
872
  if (!href) return void 0;
645
873
  return {
@@ -649,7 +877,8 @@ function resolveOverride(override, fallback, validInternalLinks) {
649
877
  }
650
878
  function getPrevNext$1(currentPath, sidebarTree, overrides, validInternalLinks) {
651
879
  const flat = flattenSidebar(sidebarTree);
652
- const index = flat.findIndex((item) => item.href === currentPath);
880
+ const currentKey = toRouteKey(currentPath);
881
+ const index = flat.findIndex((item) => toRouteKey(item.href) === currentKey);
653
882
  const sidebarPrev = index > 0 ? {
654
883
  label: flat[index - 1].label,
655
884
  href: flat[index - 1].href
@@ -771,7 +1000,7 @@ const EXPORT_PATTERN = /export\s+const\s+components\s*(?::\s*[^=]+)?=\s*\{([\s\S
771
1000
  async function parseComponentsRegistry(filePath) {
772
1001
  let source;
773
1002
  try {
774
- source = await fs.readFile(filePath, "utf8");
1003
+ source = await fs$1.readFile(filePath, "utf8");
775
1004
  } catch (err) {
776
1005
  if (err.code === "ENOENT") return null;
777
1006
  throw err;
@@ -823,6 +1052,178 @@ function splitTopLevelCommas$1(input) {
823
1052
  return result;
824
1053
  }
825
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
+
826
1227
  //#endregion
827
1228
  //#region src/_internal/parse-content-collections.ts
828
1229
  /**
@@ -862,7 +1263,7 @@ const EXPORT_PREFIX_PATTERN = /export\s+const\s+collections\s*(?::\s*[^=]+)?=\s*
862
1263
  async function parseContentCollections(filePath) {
863
1264
  let source;
864
1265
  try {
865
- source = await fs.readFile(filePath, "utf8");
1266
+ source = await fs$1.readFile(filePath, "utf8");
866
1267
  } catch (err) {
867
1268
  if (err.code === "ENOENT") return null;
868
1269
  throw err;
@@ -887,6 +1288,181 @@ async function parseContentCollections(filePath) {
887
1288
  return names;
888
1289
  }
889
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
+ /**
890
1466
  * Starting from an opening brace at `openIdx`, walk forward tracking brace
891
1467
  * depth (and skipping string literals + nested brackets) and return the
892
1468
  * index of the matching close brace. Returns `-1` if no match is found —
@@ -1041,52 +1617,6 @@ function titleAndLangTransformer() {
1041
1617
  };
1042
1618
  }
1043
1619
 
1044
- //#endregion
1045
- //#region src/_internal/levenshtein.ts
1046
- /**
1047
- * Tiny Levenshtein distance + "did you mean" suggester.
1048
- *
1049
- * Used by the MDX PascalCase validator and any framework diagnostic that
1050
- * wants to suggest a near-match on a misspelled name. Kept internal — user
1051
- * code that wants the same hint duplicates ~10 lines rather than depending
1052
- * on a framework wrapper. See the north-star guardrail on thin wrappers.
1053
- */
1054
- function levenshtein(a, b) {
1055
- if (a === b) return 0;
1056
- if (a.length === 0) return b.length;
1057
- if (b.length === 0) return a.length;
1058
- const v0 = new Array(b.length + 1);
1059
- const v1 = new Array(b.length + 1);
1060
- for (let i = 0; i <= b.length; i++) v0[i] = i;
1061
- for (let i = 0; i < a.length; i++) {
1062
- v1[0] = i + 1;
1063
- for (let j = 0; j < b.length; j++) {
1064
- const cost = a[i] === b[j] ? 0 : 1;
1065
- v1[j + 1] = Math.min(v1[j] + 1, v0[j + 1] + 1, v0[j] + cost);
1066
- }
1067
- for (let j = 0; j <= b.length; j++) v0[j] = v1[j];
1068
- }
1069
- return v1[b.length];
1070
- }
1071
- /**
1072
- * Return the closest candidate within `maxDist`, or null.
1073
- *
1074
- * Comparison is case-insensitive (so "tabs" suggests "Tabs"), but the
1075
- * returned name keeps its original casing.
1076
- */
1077
- function suggest(target, candidates, maxDist = 3) {
1078
- const targetLower = target.toLowerCase();
1079
- let best = null;
1080
- for (const c of candidates) {
1081
- const dist = levenshtein(targetLower, c.toLowerCase());
1082
- if (dist <= maxDist && (!best || dist < best.dist)) best = {
1083
- name: c,
1084
- dist
1085
- };
1086
- }
1087
- return best?.name ?? null;
1088
- }
1089
-
1090
1620
  //#endregion
1091
1621
  //#region src/_internal/validate-mdx-content.ts
1092
1622
  /**
@@ -1128,7 +1658,7 @@ async function validateMdxContent(options) {
1128
1658
  const files = await walkMdx(dir);
1129
1659
  for (const file of files) {
1130
1660
  if (options.skip?.(file)) continue;
1131
- const fileFailures = scanFile(await fs.readFile(file, "utf8"), globalsSet);
1661
+ const fileFailures = scanFile(await fs$1.readFile(file, "utf8"), globalsSet);
1132
1662
  for (const f of fileFailures) {
1133
1663
  const knownNames = [...globalsSet, ...f.imports];
1134
1664
  failures.push({
@@ -1159,7 +1689,7 @@ async function walkMdx(dir) {
1159
1689
  async function visit(current) {
1160
1690
  let entries;
1161
1691
  try {
1162
- entries = await fs.readdir(current, { withFileTypes: true });
1692
+ entries = await fs$1.readdir(current, { withFileTypes: true });
1163
1693
  } catch (err) {
1164
1694
  if (err.code === "ENOENT") return;
1165
1695
  throw err;
@@ -1427,13 +1957,13 @@ function virtualConfigPlugin(config, extras) {
1427
1957
  * versioned-docs collections. Drafts (frontmatter `draft: true`) are
1428
1958
  * filtered. Consumers feed this into `buildVersionAlternates()`.
1429
1959
  */
1430
- const PRIMARY_COLLECTION$2 = "docs";
1960
+ const PRIMARY_COLLECTION = "docs";
1431
1961
  const EXTENSIONS = new Set([".mdx", ".md"]);
1432
1962
  async function scanVersionFrontmatter(options) {
1433
1963
  const { projectRoot, versions } = options;
1434
1964
  const out = [];
1435
1965
  const collectionsToScan = [{
1436
- collection: PRIMARY_COLLECTION$2,
1966
+ collection: PRIMARY_COLLECTION,
1437
1967
  dir: path.join(projectRoot, "src/content/docs")
1438
1968
  }, ...versions.others.map((slug) => ({
1439
1969
  collection: `docs-${slug}`,
@@ -1444,7 +1974,7 @@ async function scanVersionFrontmatter(options) {
1444
1974
  for (const file of files) {
1445
1975
  let source;
1446
1976
  try {
1447
- source = await fs.readFile(file, "utf8");
1977
+ source = await fs$1.readFile(file, "utf8");
1448
1978
  } catch {
1449
1979
  continue;
1450
1980
  }
@@ -1467,7 +1997,7 @@ async function walk(dir) {
1467
1997
  async function visit(current) {
1468
1998
  let entries;
1469
1999
  try {
1470
- entries = await fs.readdir(current, { withFileTypes: true });
2000
+ entries = await fs$1.readdir(current, { withFileTypes: true });
1471
2001
  } catch (err) {
1472
2002
  if (err.code === "ENOENT") return;
1473
2003
  throw err;
@@ -1596,7 +2126,6 @@ function idFromPath(collectionDir, filePath) {
1596
2126
 
1597
2127
  //#endregion
1598
2128
  //#region src/_internal/version-alternates.ts
1599
- const PRIMARY_COLLECTION$1 = "docs";
1600
2129
  /**
1601
2130
  * Build the alternates table for one site.
1602
2131
  *
@@ -1705,8 +2234,10 @@ function buildVersionAlternates(versions, entries) {
1705
2234
  *
1706
2235
  * Returns a list of `{ from, to }` redirect pairs ready for Astro's
1707
2236
  * `redirects` config. `from` is the URL the reader hit; `to` is the
1708
- * current-version sibling. Both are absolute paths, leading slash, no
1709
- * 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.
1710
2241
  */
1711
2242
  function computeMissingPageRedirects(versions, table, entries) {
1712
2243
  if (!versions || versions.all.length < 2) return [];
@@ -1725,9 +2256,7 @@ function computeMissingPageRedirects(versions, table, entries) {
1725
2256
  if (existing.has(`${oldVersion}:${entry.id}`)) continue;
1726
2257
  if (table[refKey({
1727
2258
  collection: entry.collection,
1728
- version,
1729
- slug: entry.id,
1730
- url: currentUrl
2259
+ slug: entry.id
1731
2260
  })]?.alternates.some((a) => a.version === oldVersion)) continue;
1732
2261
  redirects.push({
1733
2262
  from: pageUrl(versions, oldVersion, entry.id),
@@ -1755,10 +2284,14 @@ function collectionToVersion(versions, collection) {
1755
2284
  * `index.ts::resolveCollectionPrefix`:
1756
2285
  * - current version → root (`/foo`)
1757
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.
1758
2292
  */
1759
2293
  function pageUrl(versions, version, slug) {
1760
- if (version === versions.current) return `/${slug}`;
1761
- return `/${version}/${slug}`;
2294
+ return toBrowserHref(canonicalEntryUrl(version === versions.current ? "" : `/${version}`, slug));
1762
2295
  }
1763
2296
 
1764
2297
  //#endregion
@@ -1795,12 +2328,19 @@ function pageUrl(versions, version, slug) {
1795
2328
  */
1796
2329
  function nimbus(rawConfig, options = {}) {
1797
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 = "";
1798
2337
  return {
1799
2338
  name: "nimbus-docs",
1800
2339
  hooks: {
1801
2340
  "astro:config:setup": async (params) => {
1802
2341
  const { updateConfig, config: astroConfig, logger } = params;
1803
2342
  const integrationsToAdd = [];
2343
+ materializeLintConfig(fileURLToPath(astroConfig.root), lintOptions.rules, lintOptions.collections, config.site);
1804
2344
  if (options.validateMdx !== false) {
1805
2345
  const validateOpts = typeof options.validateMdx === "object" ? options.validateMdx : {};
1806
2346
  const projectRoot = fileURLToPath(astroConfig.root);
@@ -1820,9 +2360,26 @@ function nimbus(rawConfig, options = {}) {
1820
2360
  }
1821
2361
  }
1822
2362
  const projectRoot = fileURLToPath(astroConfig.root);
1823
- 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);
1824
2368
  const indexedCollections = rawCollections === null ? ["docs"] : filterIndexableCollections(rawCollections);
1825
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));
1826
2383
  if (config.versions && rawCollections !== null) {
1827
2384
  const registered = new Set(rawCollections);
1828
2385
  const missing = config.versions.others.filter((slug) => !registered.has(`docs-${slug}`));
@@ -1889,13 +2446,74 @@ function nimbus(rawConfig, options = {}) {
1889
2446
  ].join("\n")
1890
2447
  });
1891
2448
  },
1892
- "astro:build:done": async ({ dir }) => {
2449
+ "astro:build:done": async ({ dir, pages, logger }) => {
2450
+ materializeRouteTruthFromPages(projectRootForBuild, astroBaseForBuild, pages, logger);
1893
2451
  if (config.search === false || config.search?.provider === "custom") return;
1894
2452
  await runPagefind(fileURLToPath(dir));
1895
2453
  }
1896
2454
  }
1897
2455
  };
1898
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
+ }
1899
2517
  function runPagefind(siteDir) {
1900
2518
  const bin = process.platform === "win32" ? "pagefind.cmd" : "pagefind";
1901
2519
  return new Promise((resolve) => {
@@ -1921,8 +2539,6 @@ function runPagefind(siteDir) {
1921
2539
  * by our Vite plugin) and content entries from `astro:content`. Both
1922
2540
  * are external in tsdown and resolved at the consumer's build time.
1923
2541
  */
1924
- /** Primary collection name — kept in sync with `_internal/content.ts`. */
1925
- const PRIMARY_COLLECTION = "docs";
1926
2542
  /**
1927
2543
  * Define a typed Nimbus config. Returns the config unchanged but inferred.
1928
2544
  */
@@ -1951,47 +2567,10 @@ function defineConfig(config) {
1951
2567
  * the collection: hand-rolled `defineCollection({ loader, schema })`
1952
2568
  * collections work without modification.
1953
2569
  */
1954
- /**
1955
- * Resolve the URL-prefix segment for a given collection ID.
1956
- *
1957
- * Rules, in order:
1958
- * 1. Primary `docs` collection mounts at root → returns `""`.
1959
- * 2. When `versions` is configured, a `docs-<slug>` collection whose
1960
- * slug appears in `versions.others` mounts under `/<slug>/` (not
1961
- * `/docs-<slug>/`). This is the versioning URL convention — a
1962
- * version is reached by its short label, not its collection ID.
1963
- * 3. Any other collection (`api`, `blog`, …) mounts at `/<collection>/`
1964
- * — the default multi-collection convention.
1965
- *
1966
- * Returned shape: empty string OR `/<segment>` with leading slash, no
1967
- * trailing slash. Callers append `/<entryId>` or `/index.md`.
1968
- */
1969
- function resolveCollectionPrefix(versions, collection) {
1970
- if (collection === PRIMARY_COLLECTION) return "";
1971
- if (versions && collection.startsWith("docs-")) {
1972
- const slug = collection.slice(5);
1973
- if (versions.others.includes(slug)) return `/${slug}`;
1974
- }
1975
- return `/${collection}`;
1976
- }
1977
- /**
1978
- * Resolve the URL-safe slug a collection should be referenced by — the
1979
- * label that appears in URLs and section headers. For version
1980
- * collections this is the manifest's short slug; for everything else
1981
- * it's the collection ID.
1982
- */
1983
- function resolveCollectionSlug(versions, collection) {
1984
- if (collection === PRIMARY_COLLECTION) return collection;
1985
- if (versions && collection.startsWith("docs-")) {
1986
- const slug = collection.slice(5);
1987
- if (versions.others.includes(slug)) return slug;
1988
- }
1989
- return collection;
1990
- }
1991
2570
  async function getIndexedEntries() {
1992
2571
  const { getCollection } = await import("astro:content");
1993
2572
  const collectionNames = await loadIndexedCollections();
1994
- const names = collectionNames.length > 0 ? collectionNames : [PRIMARY_COLLECTION];
2573
+ const names = collectionNames.length > 0 ? collectionNames : [PRIMARY_COLLECTION$1];
1995
2574
  const versions = await getVersions();
1996
2575
  const indexed = [];
1997
2576
  for (const name of names) {
@@ -2001,19 +2580,22 @@ async function getIndexedEntries() {
2001
2580
  } catch {
2002
2581
  continue;
2003
2582
  }
2004
- const prefix = resolveCollectionPrefix(versions, name);
2583
+ const prefix = collectionMountPrefix(name, versions);
2005
2584
  for (const entry of entries) {
2006
2585
  const data = entry.data ?? {};
2007
2586
  if (data.draft === true) continue;
2008
2587
  const title = typeof data.title === "string" && data.title.length > 0 ? data.title : entry.id;
2009
2588
  const rawDescription = data.description;
2010
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`;
2011
2592
  indexed.push({
2012
2593
  entry,
2013
2594
  collection: name,
2014
2595
  title,
2015
2596
  description,
2016
- url: `${prefix}/${entry.id}`
2597
+ url: toBrowserHref(canonicalUrl),
2598
+ markdownUrl
2017
2599
  });
2018
2600
  }
2019
2601
  }
@@ -2037,13 +2619,13 @@ async function getIndexedTopLevel() {
2037
2619
  const secondaryBuckets = /* @__PURE__ */ new Map();
2038
2620
  const versionSlugs = new Set(versions?.others ?? []);
2039
2621
  const hiddenSlugs = new Set(versions?.hidden ?? []);
2040
- for (const item of items) if (item.collection === PRIMARY_COLLECTION) {
2622
+ for (const item of items) if (item.collection === PRIMARY_COLLECTION$1) {
2041
2623
  const top = item.entry.id.split("/")[0];
2042
2624
  const bucket = primaryBuckets.get(top);
2043
2625
  if (bucket) bucket.push(item);
2044
2626
  else primaryBuckets.set(top, [item]);
2045
2627
  } else {
2046
- const slug = resolveCollectionSlug(versions, item.collection);
2628
+ const slug = collectionLabel(item.collection, versions);
2047
2629
  const bucket = secondaryBuckets.get(slug);
2048
2630
  if (bucket) bucket.push(item);
2049
2631
  else secondaryBuckets.set(slug, [item]);
@@ -2138,13 +2720,13 @@ async function getSidebarSections(currentSlug, options) {
2138
2720
  async function buildFullSidebarTree(currentSlug, pageCollection) {
2139
2721
  const runtimeConfig = await loadNimbusConfig();
2140
2722
  const versions = await getVersions();
2141
- let effectivePrimary = PRIMARY_COLLECTION;
2723
+ let effectivePrimary = PRIMARY_COLLECTION$1;
2142
2724
  let primaryPrefix = "";
2143
2725
  if (versions && pageCollection && pageCollection.startsWith("docs-") && versions.others.includes(pageCollection.slice(5))) {
2144
2726
  effectivePrimary = pageCollection;
2145
- primaryPrefix = resolveCollectionPrefix(versions, pageCollection);
2727
+ primaryPrefix = collectionMountPrefix(pageCollection, versions);
2146
2728
  }
2147
- 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;
2148
2730
  const referenced = collectSidebarCollectionRefs(rewrittenItems);
2149
2731
  return buildSidebarTree(await getVisibleEntriesByCollection([effectivePrimary, ...referenced.filter((c) => c !== effectivePrimary)]), effectivePrimary, currentSlug, runtimeConfig.sidebar ? {
2150
2732
  ...runtimeConfig.sidebar,
@@ -2163,7 +2745,7 @@ function rewriteSidebarItemsForVersion(items, effectivePrimary) {
2163
2745
  if (!item || typeof item !== "object") return item;
2164
2746
  const o = item;
2165
2747
  const autogen = o.autogenerate;
2166
- if (autogen && autogen.collection === PRIMARY_COLLECTION) return {
2748
+ if (autogen && autogen.collection === PRIMARY_COLLECTION$1) return {
2167
2749
  ...o,
2168
2750
  autogenerate: {
2169
2751
  ...autogen,
@@ -2194,7 +2776,7 @@ function rewriteSidebarItemsForVersion(items, effectivePrimary) {
2194
2776
  async function getPrevNext(currentSlug, options) {
2195
2777
  const tree = options?.sidebarTree ?? await getSidebar(currentSlug);
2196
2778
  const indexed = await getIndexedEntries();
2197
- const validInternalLinks = new Set(indexed.map((e) => e.url));
2779
+ const validInternalLinks = new Set(indexed.map((e) => toRouteKey(e.url)));
2198
2780
  return getPrevNext$1(currentSlug, tree, options?.overrides, validInternalLinks);
2199
2781
  }
2200
2782
  /**
@@ -2406,7 +2988,7 @@ async function getVersions() {
2406
2988
  async function getCurrentVersion(collectionId) {
2407
2989
  const versions = await getVersions();
2408
2990
  if (!versions) return null;
2409
- if (collectionId === PRIMARY_COLLECTION) return versions.current;
2991
+ if (collectionId === PRIMARY_COLLECTION$1) return versions.current;
2410
2992
  if (!collectionId.startsWith("docs-")) return null;
2411
2993
  const suffix = collectionId.slice(5);
2412
2994
  return versions.all.includes(suffix) ? suffix : null;
@@ -2474,7 +3056,7 @@ async function getCanonicalUrl(collectionId, entryId) {
2474
3056
  * the wrong section.
2475
3057
  */
2476
3058
  async function getCollectionLlmsUrl(collectionId) {
2477
- if (collectionId === PRIMARY_COLLECTION) return "/llms.txt";
3059
+ if (collectionId === PRIMARY_COLLECTION$1) return "/llms.txt";
2478
3060
  const versions = await getVersions();
2479
3061
  if (versions && collectionId.startsWith("docs-")) {
2480
3062
  const slug = collectionId.slice(5);
@@ -2533,7 +3115,7 @@ async function getVersionLandingUrl(version) {
2533
3115
  const versions = await getVersions();
2534
3116
  if (!versions) return null;
2535
3117
  if (!versions.all.includes(version)) return null;
2536
- const targetCollection = version === versions.current ? PRIMARY_COLLECTION : `docs-${version}`;
3118
+ const targetCollection = version === versions.current ? PRIMARY_COLLECTION$1 : `docs-${version}`;
2537
3119
  const inVersion = (await getIndexedEntries()).filter((i) => i.collection === targetCollection);
2538
3120
  if (inVersion.length === 0) return null;
2539
3121
  const byId = new Map(inVersion.map((i) => [i.entry.id, i]));