nimbus-docs 0.1.0 → 0.1.1

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
@@ -11,16 +11,38 @@ import { z } from "astro/zod";
11
11
 
12
12
  //#region src/_internal/runtime-config.ts
13
13
  let _cached = null;
14
+ let _cachedCollections = null;
15
+ let _cachedAlternates = null;
14
16
  async function loadNimbusConfig() {
15
17
  if (_cached) return _cached;
16
18
  _cached = (await import("virtual:nimbus/config")).config;
17
19
  return _cached;
18
20
  }
21
+ /**
22
+ * Build-time-resolved list of collections the agent-facing routes
23
+ * (llms.txt, per-page .md alternates) should iterate. Reserved names
24
+ * (`partials`, `_*`) are already filtered. See `getIndexedEntries()`.
25
+ */
26
+ async function loadIndexedCollections() {
27
+ if (_cachedCollections) return _cachedCollections;
28
+ _cachedCollections = (await import("virtual:nimbus/config")).indexedCollections;
29
+ return _cachedCollections;
30
+ }
31
+ /**
32
+ * Build-time-resolved alternates table for cross-version SEO links.
33
+ * Returns the same object on every call (cached after first load).
34
+ * Empty `{}` when the site is unversioned.
35
+ */
36
+ async function loadVersionAlternates() {
37
+ if (_cachedAlternates) return _cachedAlternates;
38
+ _cachedAlternates = (await import("virtual:nimbus/config")).versionAlternates ?? {};
39
+ return _cachedAlternates;
40
+ }
19
41
 
20
42
  //#endregion
21
43
  //#region src/_internal/content.ts
22
44
  /** Primary collection name. Hard-coded — see also `getDocsStaticPaths`. */
23
- const PRIMARY_COLLECTION$1 = "docs";
45
+ const PRIMARY_COLLECTION$3 = "docs";
24
46
  /**
25
47
  * Return visible entries from one or more collections. Drafts are
26
48
  * filtered out in production builds (matching the existing
@@ -35,7 +57,7 @@ const PRIMARY_COLLECTION$1 = "docs";
35
57
  * time. Callers that need per-collection type safety should call
36
58
  * `getCollection("api")` directly.
37
59
  */
38
- async function getVisibleEntries(collections = [PRIMARY_COLLECTION$1]) {
60
+ async function getVisibleEntries(collections = [PRIMARY_COLLECTION$3]) {
39
61
  const { getCollection } = await import("astro:content");
40
62
  const all = (await Promise.all(collections.map((name) => getCollection(name).catch(() => [])))).flat();
41
63
  return import.meta.env.PROD ? all.filter((entry) => !entry.data.draft) : all;
@@ -193,7 +215,7 @@ function buildFilesystemTree(entries, currentPath, directory, hrefPrefix = "") {
193
215
  if (directory) return buildLevel(directory);
194
216
  return buildLevel("");
195
217
  }
196
- function resolveConfigItems(configItems, entriesByCollection, primaryCollection, currentPath, orderStart = 0) {
218
+ function resolveConfigItems(configItems, entriesByCollection, primaryCollection, currentPath, orderStart = 0, primaryPrefix = "") {
197
219
  const primaryEntries = entriesByCollection[primaryCollection] ?? [];
198
220
  const { byId } = buildEntryIndex(primaryEntries);
199
221
  const result = [];
@@ -203,7 +225,7 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
203
225
  if (typeof item === "string") {
204
226
  const entry = byId.get(item);
205
227
  if (entry) {
206
- const link = createLink(entry, currentPath);
228
+ const link = createLink(entry, currentPath, primaryPrefix);
207
229
  link.order = order;
208
230
  result.push(link);
209
231
  } else console.warn(`[sidebar] Page "${item}" referenced in config but not found in primary collection "${primaryCollection}"`);
@@ -239,8 +261,8 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
239
261
  if (!collectionEntries) {
240
262
  console.warn(`[sidebar] autogenerate references collection "${collectionName}" which is not registered in nimbus.config.collections; skipping`);
241
263
  autoItems = [];
242
- } else autoItems = buildFilesystemTree(collectionEntries, currentPath, void 0, item.autogenerate.prefix ?? (collectionName === primaryCollection ? "" : `/${collectionName}`));
243
- } else autoItems = buildFilesystemTree(primaryEntries, currentPath, item.autogenerate.directory);
264
+ } else autoItems = buildFilesystemTree(collectionEntries, currentPath, void 0, item.autogenerate.prefix ?? (collectionName === primaryCollection ? primaryPrefix : `/${collectionName}`));
265
+ } else autoItems = buildFilesystemTree(primaryEntries, currentPath, item.autogenerate.directory, primaryPrefix);
244
266
  if (item.label) {
245
267
  const group = {
246
268
  type: "group",
@@ -258,7 +280,7 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
258
280
  result.push(...autoItems);
259
281
  }
260
282
  } else if ("items" in item) {
261
- const children = resolveConfigItems(item.items, entriesByCollection, primaryCollection, currentPath);
283
+ const children = resolveConfigItems(item.items, entriesByCollection, primaryCollection, currentPath, 0, primaryPrefix);
262
284
  const group = {
263
285
  type: "group",
264
286
  label: item.label,
@@ -332,11 +354,11 @@ function flattenLinks(items) {
332
354
  * current section's children in the rail) is applied by the public
333
355
  * `getSidebar` helper via `scopeToCurrentSection`.
334
356
  */
335
- function buildSidebarTree(entriesByCollection, primaryCollection, currentPath, config) {
357
+ function buildSidebarTree(entriesByCollection, primaryCollection, currentPath, config, primaryPrefix = "") {
336
358
  const primaryEntries = entriesByCollection[primaryCollection] ?? [];
337
359
  let items;
338
- if (config?.items && config.items.length > 0) items = resolveConfigItems(config.items, entriesByCollection, primaryCollection, currentPath);
339
- else items = buildFilesystemTree(primaryEntries, currentPath);
360
+ if (config?.items && config.items.length > 0) items = resolveConfigItems(config.items, entriesByCollection, primaryCollection, currentPath, 0, primaryPrefix);
361
+ else items = buildFilesystemTree(primaryEntries, currentPath, void 0, primaryPrefix);
340
362
  const pooledEntries = Object.values(entriesByCollection).flat();
341
363
  items = processHideChildren(items, pooledEntries);
342
364
  return items;
@@ -747,7 +769,7 @@ async function parseComponentsRegistry(filePath) {
747
769
  if (!match) return null;
748
770
  const body = match[1].replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
749
771
  const names = [];
750
- for (const raw of splitTopLevelCommas(body)) {
772
+ for (const raw of splitTopLevelCommas$1(body)) {
751
773
  const entry = raw.trim();
752
774
  if (!entry) continue;
753
775
  if (entry.startsWith("...")) continue;
@@ -763,6 +785,130 @@ async function parseComponentsRegistry(filePath) {
763
785
  * `()`, or string literals). Required because object entries can themselves
764
786
  * contain commas (e.g. `Foo: bar({ a: 1, b: 2 })`).
765
787
  */
788
+ function splitTopLevelCommas$1(input) {
789
+ const result = [];
790
+ let depth = 0;
791
+ let start = 0;
792
+ let inString = null;
793
+ for (let i = 0; i < input.length; i++) {
794
+ const ch = input[i];
795
+ if (inString) {
796
+ if (ch === "\\") {
797
+ i++;
798
+ continue;
799
+ }
800
+ if (ch === inString) inString = null;
801
+ continue;
802
+ }
803
+ if (ch === "\"" || ch === "'" || ch === "`") inString = ch;
804
+ else if (ch === "{" || ch === "[" || ch === "(") depth++;
805
+ else if (ch === "}" || ch === "]" || ch === ")") depth--;
806
+ else if (ch === "," && depth === 0) {
807
+ result.push(input.slice(start, i));
808
+ start = i + 1;
809
+ }
810
+ }
811
+ result.push(input.slice(start));
812
+ return result;
813
+ }
814
+
815
+ //#endregion
816
+ //#region src/_internal/parse-content-collections.ts
817
+ /**
818
+ * Extract registered collection names from the user's `src/content.config.ts`.
819
+ *
820
+ * Used by `getIndexedEntries()` and the agent-facing routes (`llms.txt`,
821
+ * per-page `.md` alternates) so they don't have to hardcode `"docs"`.
822
+ * Adding a new collection to `content.config.ts` lights up every
823
+ * indexing surface automatically.
824
+ *
825
+ * Strategy: read the file as text and locate the `export const collections =
826
+ * { ... }` declaration, parse its top-level keys. Same approach used by
827
+ * `parse-components-registry.ts` — we never execute user code at build
828
+ * time.
829
+ *
830
+ * Supported entry shapes inside the object literal:
831
+ * - shorthand: `docs,` → "docs"
832
+ * - aliased: `docs: defineCollection(...),` → "docs" (the key)
833
+ * - string key: `"docs": defineCollection(...)` → "docs"
834
+ *
835
+ * Skipped:
836
+ * - spread elements (`...other`)
837
+ * - computed keys (`[expr]: value`)
838
+ *
839
+ * The result is *not* filtered against reserved names here — that's
840
+ * `getIndexedEntries()`'s job, so consumers that want the raw list (e.g.
841
+ * tooling) can still see it.
842
+ *
843
+ * Returns:
844
+ * - `string[]` of registered names when the file exists and the
845
+ * pattern matches.
846
+ * - `null` when the file is missing OR present but doesn't expose a
847
+ * parseable `export const collections = { ... }`. Callers decide
848
+ * whether to warn or fall back to `["docs"]`.
849
+ */
850
+ const EXPORT_PREFIX_PATTERN = /export\s+const\s+collections\s*(?::\s*[^=]+)?=\s*\{/;
851
+ async function parseContentCollections(filePath) {
852
+ let source;
853
+ try {
854
+ source = await fs.readFile(filePath, "utf8");
855
+ } catch (err) {
856
+ if (err.code === "ENOENT") return null;
857
+ throw err;
858
+ }
859
+ const stripped = source.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
860
+ const prefixMatch = stripped.match(EXPORT_PREFIX_PATTERN);
861
+ if (!prefixMatch || prefixMatch.index === void 0) return null;
862
+ const objectStart = prefixMatch.index + prefixMatch[0].length;
863
+ const objectEnd = findMatchingBrace(stripped, objectStart - 1);
864
+ if (objectEnd === -1) return null;
865
+ const body = stripped.slice(objectStart, objectEnd);
866
+ const names = [];
867
+ for (const raw of splitTopLevelCommas(body)) {
868
+ const entry = raw.trim();
869
+ if (!entry) continue;
870
+ if (entry.startsWith("...")) continue;
871
+ if (entry.startsWith("[")) continue;
872
+ const colonIdx = entry.indexOf(":");
873
+ const key = (colonIdx === -1 ? entry : entry.slice(0, colonIdx)).trim().replace(/^['"`]|['"`]$/g, "");
874
+ if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) names.push(key);
875
+ }
876
+ return names;
877
+ }
878
+ /**
879
+ * Starting from an opening brace at `openIdx`, walk forward tracking brace
880
+ * depth (and skipping string literals + nested brackets) and return the
881
+ * index of the matching close brace. Returns `-1` if no match is found —
882
+ * which only happens on a syntactically broken file.
883
+ */
884
+ function findMatchingBrace(input, openIdx) {
885
+ if (input[openIdx] !== "{") return -1;
886
+ let depth = 0;
887
+ let inString = null;
888
+ for (let i = openIdx; i < input.length; i++) {
889
+ const ch = input[i];
890
+ if (inString) {
891
+ if (ch === "\\") {
892
+ i++;
893
+ continue;
894
+ }
895
+ if (ch === inString) inString = null;
896
+ continue;
897
+ }
898
+ if (ch === "\"" || ch === "'" || ch === "`") inString = ch;
899
+ else if (ch === "{") depth++;
900
+ else if (ch === "}") {
901
+ depth--;
902
+ if (depth === 0) return i;
903
+ }
904
+ }
905
+ return -1;
906
+ }
907
+ /**
908
+ * Split a string on commas that are at depth 0 (not inside `{}`, `[]`,
909
+ * `()`, or string literals). Required because object entries can themselves
910
+ * contain commas (e.g. `docs: defineCollection(docsCollection({ a: 1 }))`).
911
+ */
766
912
  function splitTopLevelCommas(input) {
767
913
  const result = [];
768
914
  let depth = 0;
@@ -789,6 +935,21 @@ function splitTopLevelCommas(input) {
789
935
  result.push(input.slice(start));
790
936
  return result;
791
937
  }
938
+ /**
939
+ * Collection names that should never appear in the agent-facing index,
940
+ * regardless of how they were registered. The rule pair is intentionally
941
+ * minimal so the convention is easy to remember:
942
+ *
943
+ * - `partials` — Nimbus's built-in factory for `<Render slug=…/>`
944
+ * snippets. They're component content, not pages.
945
+ * - any name starting with `_` — author-chosen "loaded but internal"
946
+ * marker (e.g. `_drafts`, `_archive`, `_legacy`).
947
+ */
948
+ const RESERVED_LITERAL = new Set(["partials"]);
949
+ const RESERVED_PREFIX = "_";
950
+ function filterIndexableCollections(names) {
951
+ return names.filter((name) => !RESERVED_LITERAL.has(name) && !name.startsWith(RESERVED_PREFIX));
952
+ }
792
953
 
793
954
  //#endregion
794
955
  //#region src/_internal/code-transformers.ts
@@ -1126,7 +1287,42 @@ const featuresSchema = z.object({
1126
1287
  toc: true
1127
1288
  });
1128
1289
  const searchSchema = z.union([z.literal(false), z.object({ provider: z.enum(["pagefind", "custom"]).default("pagefind") })]).optional();
1129
- const sidebarSchema = z.object({ items: z.array(z.unknown()).optional() }).passthrough().optional();
1290
+ const sidebarSchema = z.object({
1291
+ items: z.array(z.unknown()).optional(),
1292
+ scope: z.enum(["full", "section"]).default("full")
1293
+ }).passthrough().optional();
1294
+ const versionSlugSchema = z.string({ error: "\"versions\" entries must be strings" }).min(1, { message: "Empty string is not a valid version slug" });
1295
+ const versionsSchema = z.object({
1296
+ current: versionSlugSchema,
1297
+ others: z.array(versionSlugSchema).default([]),
1298
+ deprecated: z.array(versionSlugSchema).default([]),
1299
+ hidden: z.array(versionSlugSchema).default([])
1300
+ }).superRefine((v, ctx) => {
1301
+ const seen = /* @__PURE__ */ new Set();
1302
+ v.others.forEach((slug, i) => {
1303
+ if (seen.has(slug)) ctx.addIssue({
1304
+ code: "custom",
1305
+ message: `Duplicate version slug "${slug}" in "others"`,
1306
+ path: ["others", i]
1307
+ });
1308
+ seen.add(slug);
1309
+ });
1310
+ if (v.others.includes(v.current)) ctx.addIssue({
1311
+ code: "custom",
1312
+ message: `"current" (${JSON.stringify(v.current)}) must not also appear in "others". The current version lives in the primary \`docs\` collection; entries in "others" describe older versions stored in \`docs-<slug>\` collections.`,
1313
+ path: ["current"]
1314
+ });
1315
+ for (const [i, slug] of v.deprecated.entries()) if (!v.others.includes(slug)) ctx.addIssue({
1316
+ code: "custom",
1317
+ message: `"deprecated" entry ${JSON.stringify(slug)} is not in "others". Every deprecated version must also be listed in "others".`,
1318
+ path: ["deprecated", i]
1319
+ });
1320
+ for (const [i, slug] of v.hidden.entries()) if (!v.others.includes(slug)) ctx.addIssue({
1321
+ code: "custom",
1322
+ message: `"hidden" entry ${JSON.stringify(slug)} is not in "others". Every hidden version must also be listed in "others".`,
1323
+ path: ["hidden", i]
1324
+ });
1325
+ }).optional();
1130
1326
  const nimbusConfigSchema = z.object({
1131
1327
  site: z.string().url({ message: "\"site\" must be a valid URL" }),
1132
1328
  title: z.string(),
@@ -1142,7 +1338,8 @@ const nimbusConfigSchema = z.object({
1142
1338
  head: z.array(headElementSchema).default([]),
1143
1339
  sidebar: sidebarSchema,
1144
1340
  features: featuresSchema,
1145
- search: searchSchema
1341
+ search: searchSchema,
1342
+ versions: versionsSchema
1146
1343
  });
1147
1344
  function validateNimbusConfig(input) {
1148
1345
  const result = nimbusConfigSchema.safeParse(input);
@@ -1183,18 +1380,368 @@ function formatReceived(input, path) {
1183
1380
  //#region src/_internal/virtual-config.ts
1184
1381
  const VIRTUAL_ID = "virtual:nimbus/config";
1185
1382
  const RESOLVED_ID = `\0${VIRTUAL_ID}`;
1186
- function virtualConfigPlugin(config) {
1383
+ function virtualConfigPlugin(config, extras) {
1187
1384
  return {
1188
1385
  name: "nimbus-docs:virtual-config",
1189
1386
  resolveId(id) {
1190
1387
  if (id === VIRTUAL_ID) return RESOLVED_ID;
1191
1388
  },
1192
1389
  load(id) {
1193
- if (id === RESOLVED_ID) return `export const config = ${JSON.stringify(config)};\n`;
1390
+ if (id === RESOLVED_ID) return `export const config = ${JSON.stringify(config)};\nexport const indexedCollections = ${JSON.stringify(extras.indexedCollections)};\nexport const versionAlternates = ${JSON.stringify(extras.versionAlternates)};\n`;
1194
1391
  }
1195
1392
  };
1196
1393
  }
1197
1394
 
1395
+ //#endregion
1396
+ //#region src/_internal/scan-version-frontmatter.ts
1397
+ /**
1398
+ * Walk every version-collection directory and extract the frontmatter
1399
+ * fields the alternates table needs (`previousSlug`, `draft`).
1400
+ *
1401
+ * Runs at `astro:config:setup` — before Astro's content layer is
1402
+ * initialized, so we can't use `getCollection()`. Walks the filesystem
1403
+ * directly, slices the YAML frontmatter from each MDX/MD file, and
1404
+ * pulls the two fields we care about. Same "never execute user code"
1405
+ * posture as `parse-content-collections.ts` and `parse-components-registry.ts`.
1406
+ *
1407
+ * Returns one `VersionEntryInput` per visible entry across the
1408
+ * versioned-docs collections. Drafts (frontmatter `draft: true`) are
1409
+ * filtered. Consumers feed this into `buildVersionAlternates()`.
1410
+ */
1411
+ const PRIMARY_COLLECTION$2 = "docs";
1412
+ const EXTENSIONS = new Set([".mdx", ".md"]);
1413
+ async function scanVersionFrontmatter(options) {
1414
+ const { projectRoot, versions } = options;
1415
+ const out = [];
1416
+ const collectionsToScan = [{
1417
+ collection: PRIMARY_COLLECTION$2,
1418
+ dir: path.join(projectRoot, "src/content/docs")
1419
+ }, ...versions.others.map((slug) => ({
1420
+ collection: `docs-${slug}`,
1421
+ dir: path.join(projectRoot, `src/content/docs-${slug}`)
1422
+ }))];
1423
+ for (const { collection, dir } of collectionsToScan) {
1424
+ const files = await walk(dir);
1425
+ for (const file of files) {
1426
+ let source;
1427
+ try {
1428
+ source = await fs.readFile(file, "utf8");
1429
+ } catch {
1430
+ continue;
1431
+ }
1432
+ const front = extractFrontmatter(source);
1433
+ if (front === null) continue;
1434
+ if (parseBoolField(front, "draft") === true) continue;
1435
+ const previousSlug = parsePreviousSlugField(front);
1436
+ const id = idFromPath(dir, file);
1437
+ out.push({
1438
+ collection,
1439
+ id,
1440
+ previousSlug
1441
+ });
1442
+ }
1443
+ }
1444
+ return out;
1445
+ }
1446
+ async function walk(dir) {
1447
+ const out = [];
1448
+ async function visit(current) {
1449
+ let entries;
1450
+ try {
1451
+ entries = await fs.readdir(current, { withFileTypes: true });
1452
+ } catch (err) {
1453
+ if (err.code === "ENOENT") return;
1454
+ throw err;
1455
+ }
1456
+ for (const entry of entries) {
1457
+ const full = path.join(current, entry.name);
1458
+ if (entry.isDirectory()) {
1459
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
1460
+ await visit(full);
1461
+ } else if (entry.isFile()) {
1462
+ const ext = path.extname(entry.name);
1463
+ if (EXTENSIONS.has(ext)) out.push(full);
1464
+ }
1465
+ }
1466
+ }
1467
+ await visit(dir);
1468
+ return out;
1469
+ }
1470
+ /**
1471
+ * Slice the YAML frontmatter block from a source file. Returns the body
1472
+ * between the leading `---` and the closing `---` (without the markers),
1473
+ * or `null` if no frontmatter is present.
1474
+ *
1475
+ * Matches the same convention Astro's content layer enforces — leading
1476
+ * `---\n`, closing `\n---\n` (or `\n---` at EOF).
1477
+ */
1478
+ function extractFrontmatter(source) {
1479
+ if (!source.startsWith("---")) return null;
1480
+ const afterFirstMarker = source.indexOf("\n");
1481
+ if (afterFirstMarker === -1) return null;
1482
+ if (source.slice(0, afterFirstMarker).trim() !== "---") return null;
1483
+ const rest = source.slice(afterFirstMarker + 1);
1484
+ const closingMatch = rest.match(/(^|\n)---\s*(\n|$)/);
1485
+ if (!closingMatch || closingMatch.index === void 0) return null;
1486
+ const endIndex = closingMatch[1] === "\n" ? closingMatch.index : closingMatch.index;
1487
+ return rest.slice(0, endIndex);
1488
+ }
1489
+ /**
1490
+ * Find a top-level boolean field in YAML frontmatter. Returns the
1491
+ * boolean value or `undefined` if the field is absent / malformed.
1492
+ *
1493
+ * Handles only the shape Nimbus uses: `<field>: true` / `<field>: false`
1494
+ * on a single line, no indentation, no quotes.
1495
+ */
1496
+ function parseBoolField(yaml, field) {
1497
+ const re = new RegExp(`^${escapeRe(field)}\\s*:\\s*(true|false)\\s*$`, "m");
1498
+ const m = yaml.match(re);
1499
+ if (!m) return void 0;
1500
+ return m[1] === "true";
1501
+ }
1502
+ /**
1503
+ * Find the top-level `previousSlug` field in YAML frontmatter. Accepts:
1504
+ * - scalar: `previousSlug: foo`
1505
+ * - inline array: `previousSlug: [foo, "bar", 'baz']`
1506
+ * - multiline block array:
1507
+ * ```yaml
1508
+ * previousSlug:
1509
+ * - foo
1510
+ * - bar
1511
+ * ```
1512
+ *
1513
+ * Returns:
1514
+ * - `string` for a scalar
1515
+ * - `string[]` for either array form
1516
+ * - `undefined` if absent
1517
+ *
1518
+ * The multiline form is the canonical YAML list syntax; the scanner
1519
+ * previously accepted only scalar and inline-array, which silently
1520
+ * dropped valid block lists at build time. The schema validates the
1521
+ * post-parse shape; the scanner has to match it.
1522
+ */
1523
+ function parsePreviousSlugField(yaml) {
1524
+ const blockHeader = yaml.match(/^previousSlug\s*:\s*$/m);
1525
+ if (blockHeader && blockHeader.index !== void 0) return parseBlockList(yaml.slice(blockHeader.index + blockHeader[0].length + 1));
1526
+ const arr = yaml.match(/^previousSlug\s*:\s*\[([^\]]*)\]\s*$/m);
1527
+ if (arr) return arr[1].split(",").map((s) => unquote(s.trim())).filter((s) => s.length > 0);
1528
+ const scalar = yaml.match(/^previousSlug\s*:\s*(?!\[)(.+?)\s*$/m);
1529
+ if (scalar) {
1530
+ const raw = scalar[1].trim();
1531
+ if (raw.length === 0) return void 0;
1532
+ return unquote(raw);
1533
+ }
1534
+ }
1535
+ /**
1536
+ * Parse a YAML block list (one `- value` per line, leading indent).
1537
+ * Stops at the first non-list, non-blank line (i.e. the next sibling
1538
+ * frontmatter field at the same indentation as the list header).
1539
+ *
1540
+ * previousSlug:
1541
+ * - foo
1542
+ * - "bar"
1543
+ * - 'baz'
1544
+ * title: Whatever ← stops here
1545
+ */
1546
+ function parseBlockList(source) {
1547
+ const lines = source.split("\n");
1548
+ const out = [];
1549
+ for (const line of lines) {
1550
+ if (line.trim().length === 0) continue;
1551
+ const m = line.match(/^\s+-\s+(.+?)\s*$/);
1552
+ if (!m) break;
1553
+ const value = unquote(m[1].trim());
1554
+ if (value.length > 0) out.push(value);
1555
+ }
1556
+ return out;
1557
+ }
1558
+ function unquote(s) {
1559
+ if (s.startsWith("\"") && s.endsWith("\"") || s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
1560
+ return s;
1561
+ }
1562
+ function escapeRe(s) {
1563
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1564
+ }
1565
+ /**
1566
+ * Compute the Astro entry id (the slug) from a file's absolute path,
1567
+ * relative to the collection directory.
1568
+ *
1569
+ * Examples:
1570
+ * - <dir>/index.mdx → "index"
1571
+ * - <dir>/foo.mdx → "foo"
1572
+ * - <dir>/guides/setup.mdx → "guides/setup"
1573
+ */
1574
+ function idFromPath(collectionDir, filePath) {
1575
+ return path.relative(collectionDir, filePath).replace(/\.(mdx|md)$/, "").split(path.sep).join("/");
1576
+ }
1577
+
1578
+ //#endregion
1579
+ //#region src/_internal/version-alternates.ts
1580
+ const PRIMARY_COLLECTION$1 = "docs";
1581
+ /**
1582
+ * Build the alternates table for one site.
1583
+ *
1584
+ * Pass:
1585
+ * - `versions`: resolved manifest (or null when the site is unversioned).
1586
+ * - `entries`: every visible entry from every docs-shaped version
1587
+ * collection. Drafts already filtered.
1588
+ *
1589
+ * Returns an empty table when `versions` is null or only one version is
1590
+ * configured (no cross-linking work to do).
1591
+ */
1592
+ function buildVersionAlternates(versions, entries) {
1593
+ if (!versions || versions.all.length < 2) return {};
1594
+ const refs = [];
1595
+ for (const entry of entries) {
1596
+ const version = collectionToVersion(versions, entry.collection);
1597
+ if (version === null) continue;
1598
+ refs.push({
1599
+ collection: entry.collection,
1600
+ version,
1601
+ slug: entry.id,
1602
+ url: pageUrl(versions, version, entry.id)
1603
+ });
1604
+ }
1605
+ const indexByKey = /* @__PURE__ */ new Map();
1606
+ refs.forEach((ref, i) => indexByKey.set(refKey(ref), i));
1607
+ const parent = refs.map((_, i) => i);
1608
+ const find = (i) => {
1609
+ let root = i;
1610
+ while (parent[root] !== root) root = parent[root];
1611
+ let cursor = i;
1612
+ while (parent[cursor] !== cursor) {
1613
+ const next = parent[cursor];
1614
+ parent[cursor] = root;
1615
+ cursor = next;
1616
+ }
1617
+ return root;
1618
+ };
1619
+ const union = (a, b) => {
1620
+ const ra = find(a);
1621
+ const rb = find(b);
1622
+ if (ra !== rb) parent[ra] = rb;
1623
+ };
1624
+ const bySlug = /* @__PURE__ */ new Map();
1625
+ for (let i = 0; i < refs.length; i++) {
1626
+ const slug = refs[i].slug;
1627
+ const bucket = bySlug.get(slug);
1628
+ if (bucket) bucket.push(i);
1629
+ else bySlug.set(slug, [i]);
1630
+ }
1631
+ for (const ids of bySlug.values()) for (let i = 1; i < ids.length; i++) union(ids[0], ids[i]);
1632
+ const orderIndex = /* @__PURE__ */ new Map();
1633
+ versions.all.forEach((v, i) => orderIndex.set(v, i));
1634
+ for (let i = 0; i < entries.length; i++) {
1635
+ const entry = entries[i];
1636
+ if (!entry.previousSlug) continue;
1637
+ const ref = refs[refs.findIndex((r) => r.collection === entry.collection && r.slug === entry.id)];
1638
+ if (!ref) continue;
1639
+ const selfOrder = orderIndex.get(ref.version);
1640
+ if (selfOrder === void 0) continue;
1641
+ const previousSlugs = Array.isArray(entry.previousSlug) ? entry.previousSlug : [entry.previousSlug];
1642
+ for (const prevSlug of previousSlugs) for (let j = 0; j < refs.length; j++) {
1643
+ const other = refs[j];
1644
+ if (other.slug !== prevSlug) continue;
1645
+ const otherOrder = orderIndex.get(other.version);
1646
+ if (otherOrder === void 0) continue;
1647
+ if (otherOrder <= selfOrder) continue;
1648
+ const selfIdx = refs.indexOf(ref);
1649
+ if (selfIdx >= 0) union(selfIdx, j);
1650
+ }
1651
+ }
1652
+ const groups = /* @__PURE__ */ new Map();
1653
+ for (let i = 0; i < refs.length; i++) {
1654
+ const root = find(i);
1655
+ const g = groups.get(root);
1656
+ if (g) g.push(i);
1657
+ else groups.set(root, [i]);
1658
+ }
1659
+ const table = {};
1660
+ for (const memberIndices of groups.values()) {
1661
+ memberIndices.sort((a, b) => {
1662
+ return (orderIndex.get(refs[a].version) ?? Number.MAX_SAFE_INTEGER) - (orderIndex.get(refs[b].version) ?? Number.MAX_SAFE_INTEGER);
1663
+ });
1664
+ const members = memberIndices.map((i) => refs[i]);
1665
+ const currentRef = members.find((m) => m.version === versions.current) ?? null;
1666
+ const hiddenVersions = new Set(versions.hidden);
1667
+ for (const self of members) {
1668
+ const alternates = members.filter((m) => m !== self && !hiddenVersions.has(m.version));
1669
+ const canonical = currentRef && currentRef !== self ? currentRef : null;
1670
+ table[refKey(self)] = {
1671
+ self,
1672
+ alternates,
1673
+ canonical
1674
+ };
1675
+ }
1676
+ }
1677
+ return table;
1678
+ }
1679
+ /**
1680
+ * Compute the slugs that exist in `current` but are absent in a given
1681
+ * older version — the set that should auto-redirect from `/v/<slug>` to
1682
+ * `/<slug>` when a reader follows a stale link. Includes slugs reached
1683
+ * via `previousSlug` (so a renamed page's old slug in the old version
1684
+ * also redirects correctly when the user types the original new URL by
1685
+ * accident under the old prefix).
1686
+ *
1687
+ * Returns a list of `{ from, to }` redirect pairs ready for Astro's
1688
+ * `redirects` config. `from` is the URL the reader hit; `to` is the
1689
+ * current-version sibling. Both are absolute paths, leading slash, no
1690
+ * trailing slash. Astro applies trailing-slash normalisation on output.
1691
+ */
1692
+ function computeMissingPageRedirects(versions, table, entries) {
1693
+ if (!versions || versions.all.length < 2) return [];
1694
+ const existing = /* @__PURE__ */ new Set();
1695
+ for (const entry of entries) {
1696
+ const version = collectionToVersion(versions, entry.collection);
1697
+ if (version === null) continue;
1698
+ existing.add(`${version}:${entry.id}`);
1699
+ }
1700
+ const redirects = [];
1701
+ for (const entry of entries) {
1702
+ const version = collectionToVersion(versions, entry.collection);
1703
+ if (version !== versions.current) continue;
1704
+ const currentUrl = pageUrl(versions, version, entry.id);
1705
+ for (const oldVersion of versions.others) {
1706
+ if (existing.has(`${oldVersion}:${entry.id}`)) continue;
1707
+ if (table[refKey({
1708
+ collection: entry.collection,
1709
+ version,
1710
+ slug: entry.id,
1711
+ url: currentUrl
1712
+ })]?.alternates.some((a) => a.version === oldVersion)) continue;
1713
+ redirects.push({
1714
+ from: pageUrl(versions, oldVersion, entry.id),
1715
+ to: currentUrl
1716
+ });
1717
+ }
1718
+ }
1719
+ return redirects;
1720
+ }
1721
+ function refKey(ref) {
1722
+ return `${ref.collection}:${ref.slug}`;
1723
+ }
1724
+ /**
1725
+ * Resolve the version slug for a given Astro collection ID, or null if
1726
+ * the collection is not part of the versioning manifest.
1727
+ */
1728
+ function collectionToVersion(versions, collection) {
1729
+ if (collection === PRIMARY_COLLECTION$1) return versions.current;
1730
+ if (!collection.startsWith("docs-")) return null;
1731
+ const slug = collection.slice(5);
1732
+ return versions.all.includes(slug) ? slug : null;
1733
+ }
1734
+ /**
1735
+ * Build the URL for a `(version, slug)` pair. Matches the convention in
1736
+ * `index.ts::resolveCollectionPrefix`:
1737
+ * - current version → root (`/foo`)
1738
+ * - others → `/<version>/<slug>`
1739
+ */
1740
+ function pageUrl(versions, version, slug) {
1741
+ if (version === versions.current) return `/${slug}`;
1742
+ return `/${version}/${slug}`;
1743
+ }
1744
+
1198
1745
  //#endregion
1199
1746
  //#region src/integration.ts
1200
1747
  /**
@@ -1253,6 +1800,37 @@ function nimbus(rawConfig, options = {}) {
1253
1800
  logger.info(`MDX validation passed — ${globals.length} global component${globals.length === 1 ? "" : "s"} registered, ${contentDirs.length} content dir${contentDirs.length === 1 ? "" : "s"} scanned.`);
1254
1801
  }
1255
1802
  }
1803
+ const projectRoot = fileURLToPath(astroConfig.root);
1804
+ const rawCollections = await parseContentCollections(path.join(projectRoot, "src/content.config.ts"));
1805
+ const indexedCollections = rawCollections === null ? ["docs"] : filterIndexableCollections(rawCollections);
1806
+ 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.");
1807
+ if (config.versions && rawCollections !== null) {
1808
+ const registered = new Set(rawCollections);
1809
+ const missing = config.versions.others.filter((slug) => !registered.has(`docs-${slug}`));
1810
+ if (missing.length > 0) {
1811
+ const lines = missing.map((slug) => {
1812
+ return ` - "${slug}" → expected a collection named "docs-${slug}" in src/content.config.ts (e.g. \`"docs-${slug}": docsCollection({ base: "docs-${slug}" })\`)`;
1813
+ });
1814
+ throw new Error(`nimbus-docs: \`versions.others\` references slugs without matching collections:\n${lines.join("\n")}\n\nEvery entry in \`versions.others\` must correspond to a registered Astro content collection. Register the collection(s) above in src/content.config.ts and try again.`);
1815
+ }
1816
+ }
1817
+ let versionAlternates = {};
1818
+ let versionRedirects = [];
1819
+ if (config.versions) {
1820
+ const resolved = {
1821
+ current: config.versions.current,
1822
+ others: config.versions.others ?? [],
1823
+ deprecated: config.versions.deprecated ?? [],
1824
+ hidden: config.versions.hidden ?? [],
1825
+ all: [config.versions.current, ...config.versions.others ?? []]
1826
+ };
1827
+ const versionEntries = await scanVersionFrontmatter({
1828
+ projectRoot,
1829
+ versions: resolved
1830
+ });
1831
+ versionAlternates = buildVersionAlternates(resolved, versionEntries);
1832
+ versionRedirects = computeMissingPageRedirects(resolved, versionAlternates, versionEntries);
1833
+ }
1256
1834
  integrationsToAdd.push(mdx(options.mdx ?? {}));
1257
1835
  if (options.sitemap !== false && Boolean(config.site)) integrationsToAdd.push(sitemap());
1258
1836
  updateConfig({
@@ -1269,7 +1847,11 @@ function nimbus(rawConfig, options = {}) {
1269
1847
  transformers: defaultCodeTransformers()
1270
1848
  }
1271
1849
  },
1272
- vite: { plugins: [virtualConfigPlugin(config)] }
1850
+ ...versionRedirects.length > 0 ? { redirects: Object.fromEntries(versionRedirects.map(({ from, to }) => [from, to])) } : {},
1851
+ vite: { plugins: [virtualConfigPlugin(config, {
1852
+ indexedCollections,
1853
+ versionAlternates
1854
+ })] }
1273
1855
  });
1274
1856
  },
1275
1857
  "astro:config:done": ({ injectTypes }) => {
@@ -1277,8 +1859,12 @@ function nimbus(rawConfig, options = {}) {
1277
1859
  filename: "virtual-config.d.ts",
1278
1860
  content: [
1279
1861
  "declare module \"virtual:nimbus/config\" {",
1280
- " import type { NimbusConfig } from \"nimbus-docs/types\";",
1862
+ " import type { NimbusConfig, VersionAlternatesTable } from \"nimbus-docs/types\";",
1281
1863
  " export const config: NimbusConfig;",
1864
+ " /** Build-time list of indexable collection names. See `getIndexedEntries()`. */",
1865
+ " export const indexedCollections: readonly string[];",
1866
+ " /** Build-time cross-version alternates table. See `getVersionAlternates()`. */",
1867
+ " export const versionAlternates: VersionAlternatesTable;",
1282
1868
  "}",
1283
1869
  ""
1284
1870
  ].join("\n")
@@ -1325,6 +1911,152 @@ function defineConfig(config) {
1325
1911
  return config;
1326
1912
  }
1327
1913
  /**
1914
+ * Cross-collection entry list for the agent-facing routes
1915
+ * (`llms.txt`, per-page `.md` alternates, future `llms-full.txt` and
1916
+ * `rag.jsonl`). Implements the indexing baseline of the two-layer
1917
+ * architecture documented at `/features/llms-txt`:
1918
+ *
1919
+ * - **Multi-collection by default, zero opt-in.** Iterates every
1920
+ * collection registered in `src/content.config.ts` except names
1921
+ * matching `partials` or starting with `_` (reserved).
1922
+ * - **Schema-tolerant.** Reads `title` and `description` if present;
1923
+ * falls back to the entry id for the title and omits the
1924
+ * description otherwise.
1925
+ * - **Per-page filters baked in.** Drops entries with `llms: false`
1926
+ * or `draft: true` when those fields exist; absent fields read as
1927
+ * the docs-schema defaults (`llms: true`, `draft: false`).
1928
+ *
1929
+ * The returned shape is identical regardless of which factory created
1930
+ * the collection: hand-rolled `defineCollection({ loader, schema })`
1931
+ * collections work without modification.
1932
+ */
1933
+ /**
1934
+ * Resolve the URL-prefix segment for a given collection ID.
1935
+ *
1936
+ * Rules, in order:
1937
+ * 1. Primary `docs` collection mounts at root → returns `""`.
1938
+ * 2. When `versions` is configured, a `docs-<slug>` collection whose
1939
+ * slug appears in `versions.others` mounts under `/<slug>/` (not
1940
+ * `/docs-<slug>/`). This is the versioning URL convention — a
1941
+ * version is reached by its short label, not its collection ID.
1942
+ * 3. Any other collection (`api`, `blog`, …) mounts at `/<collection>/`
1943
+ * — the default multi-collection convention.
1944
+ *
1945
+ * Returned shape: empty string OR `/<segment>` with leading slash, no
1946
+ * trailing slash. Callers append `/<entryId>` or `/index.md`.
1947
+ */
1948
+ function resolveCollectionPrefix(versions, collection) {
1949
+ if (collection === PRIMARY_COLLECTION) return "";
1950
+ if (versions && collection.startsWith("docs-")) {
1951
+ const slug = collection.slice(5);
1952
+ if (versions.others.includes(slug)) return `/${slug}`;
1953
+ }
1954
+ return `/${collection}`;
1955
+ }
1956
+ /**
1957
+ * Resolve the URL-safe slug a collection should be referenced by — the
1958
+ * label that appears in URLs and section headers. For version
1959
+ * collections this is the manifest's short slug; for everything else
1960
+ * it's the collection ID.
1961
+ */
1962
+ function resolveCollectionSlug(versions, collection) {
1963
+ if (collection === PRIMARY_COLLECTION) return collection;
1964
+ if (versions && collection.startsWith("docs-")) {
1965
+ const slug = collection.slice(5);
1966
+ if (versions.others.includes(slug)) return slug;
1967
+ }
1968
+ return collection;
1969
+ }
1970
+ async function getIndexedEntries() {
1971
+ const { getCollection } = await import("astro:content");
1972
+ const collectionNames = await loadIndexedCollections();
1973
+ const names = collectionNames.length > 0 ? collectionNames : [PRIMARY_COLLECTION];
1974
+ const versions = await getVersions();
1975
+ const indexed = [];
1976
+ for (const name of names) {
1977
+ let entries;
1978
+ try {
1979
+ entries = await getCollection(name);
1980
+ } catch {
1981
+ continue;
1982
+ }
1983
+ const prefix = resolveCollectionPrefix(versions, name);
1984
+ for (const entry of entries) {
1985
+ const data = entry.data ?? {};
1986
+ if (data.llms === false) continue;
1987
+ if (data.draft === true) continue;
1988
+ const title = typeof data.title === "string" && data.title.length > 0 ? data.title : entry.id;
1989
+ const rawDescription = data.description;
1990
+ const description = typeof rawDescription === "string" && rawDescription.length > 0 ? rawDescription : void 0;
1991
+ indexed.push({
1992
+ entry,
1993
+ collection: name,
1994
+ title,
1995
+ description,
1996
+ url: `${prefix}/${entry.id}`
1997
+ });
1998
+ }
1999
+ }
2000
+ return indexed;
2001
+ }
2002
+ /**
2003
+ * Partition the indexed entries into the shape the root `/llms.txt`
2004
+ * and `/[section]/llms.txt` routes need.
2005
+ *
2006
+ * Convention:
2007
+ * - Primary `"docs"` entries follow the leaf/group rule based on
2008
+ * their `entry.id` top segment (matches single-collection behavior).
2009
+ * - Every other collection becomes a single top-level group named
2010
+ * after the collection, regardless of how many entries it has.
2011
+ * This matches the URL convention (`/api/...`, `/blog/...`).
2012
+ */
2013
+ async function getIndexedTopLevel() {
2014
+ const items = await getIndexedEntries();
2015
+ const versions = await getVersions();
2016
+ const primaryBuckets = /* @__PURE__ */ new Map();
2017
+ const secondaryBuckets = /* @__PURE__ */ new Map();
2018
+ const versionSlugs = new Set(versions?.others ?? []);
2019
+ const hiddenSlugs = new Set(versions?.hidden ?? []);
2020
+ for (const item of items) if (item.collection === PRIMARY_COLLECTION) {
2021
+ const top = item.entry.id.split("/")[0];
2022
+ const bucket = primaryBuckets.get(top);
2023
+ if (bucket) bucket.push(item);
2024
+ else primaryBuckets.set(top, [item]);
2025
+ } else {
2026
+ const slug = resolveCollectionSlug(versions, item.collection);
2027
+ const bucket = secondaryBuckets.get(slug);
2028
+ if (bucket) bucket.push(item);
2029
+ else secondaryBuckets.set(slug, [item]);
2030
+ }
2031
+ const leaves = [];
2032
+ const groups = [];
2033
+ for (const [slug, members] of primaryBuckets) if (members.length === 1 && members[0].entry.id === slug) leaves.push(members[0]);
2034
+ else groups.push({
2035
+ slug,
2036
+ label: slug,
2037
+ members,
2038
+ kind: "primary",
2039
+ hidden: false
2040
+ });
2041
+ for (const [slug, members] of secondaryBuckets) {
2042
+ const kind = versionSlugs.has(slug) ? "version" : "secondary";
2043
+ groups.push({
2044
+ slug,
2045
+ label: slug,
2046
+ members,
2047
+ kind,
2048
+ hidden: hiddenSlugs.has(slug)
2049
+ });
2050
+ }
2051
+ leaves.sort((a, b) => a.url.localeCompare(b.url));
2052
+ groups.sort((a, b) => a.slug.localeCompare(b.slug));
2053
+ for (const g of groups) g.members.sort((a, b) => a.url.localeCompare(b.url));
2054
+ return {
2055
+ leaves,
2056
+ groups
2057
+ };
2058
+ }
2059
+ /**
1328
2060
  * Build the sidebar tree for the given current path, scoped to the
1329
2061
  * top-level section containing that page.
1330
2062
  *
@@ -1332,16 +2064,30 @@ function defineConfig(config) {
1332
2064
  * resolves config-driven sidebar. Otherwise auto-generates from filesystem
1333
2065
  * (i.e. the `docs` collection's entry IDs).
1334
2066
  *
1335
- * The returned tree is always scoped: only the current section's children
1336
- * are returned. To enumerate every top-level section (for header tabs or
1337
- * a section switcher), use `getSidebarSections`.
2067
+ * Returned shape depends on `sidebar.scope` in `nimbus.config.ts`:
2068
+ * - `"full"` (default) every top-level item on every page.
2069
+ * - `"section"` — only the current top-level section's children. Use
2070
+ * the header section-tab strip (via `getSidebarSections`) for
2071
+ * cross-section nav when this mode is on.
2072
+ *
2073
+ * **Versioning awareness.** When the page is in a version collection
2074
+ * (`docs-<v>` where `<v>` is in `versions.others`), pass `collection` as
2075
+ * the second argument. The sidebar build will swap any
2076
+ * `{ autogenerate: { collection: "docs" } }` items to autogenerate from
2077
+ * that version's collection instead, and treat it as the primary for
2078
+ * the build. Without this, version pages render the current-version
2079
+ * sidebar and prev/next derives from the wrong tree.
1338
2080
  *
1339
2081
  * @param currentSlug - The current page's URL path (e.g. "/getting-started").
1340
2082
  * Used to set `isCurrent` on matching links and to pick
1341
- * which top-level section to surface.
2083
+ * which top-level section to surface when scoping.
2084
+ * @param options.collection - The current page's Astro collection ID.
2085
+ * Pass `entry.collection` from your route.
1342
2086
  */
1343
- async function getSidebar(currentSlug) {
1344
- return scopeToCurrentSection(await buildFullSidebarTree(currentSlug), currentSlug);
2087
+ async function getSidebar(currentSlug, options) {
2088
+ const config = await loadNimbusConfig();
2089
+ const tree = await buildFullSidebarTree(currentSlug, options?.collection);
2090
+ return config.sidebar?.scope === "section" ? scopeToCurrentSection(tree, currentSlug) : tree;
1345
2091
  }
1346
2092
  /**
1347
2093
  * Derive one section per top-level group in the sidebar — used by
@@ -1350,17 +2096,66 @@ async function getSidebar(currentSlug) {
1350
2096
  *
1351
2097
  * Reads the un-scoped tree so every section is visible, then collapses
1352
2098
  * each top-level group to `{ label, href, isActive }`.
2099
+ *
2100
+ * Accepts the same `collection` option as `getSidebar` so version pages
2101
+ * see version-scoped section tabs.
1353
2102
  */
1354
- async function getSidebarSections(currentSlug) {
1355
- return deriveSidebarSections(await buildFullSidebarTree(currentSlug));
2103
+ async function getSidebarSections(currentSlug, options) {
2104
+ return deriveSidebarSections(await buildFullSidebarTree(currentSlug, options?.collection));
1356
2105
  }
1357
2106
  /**
1358
2107
  * Internal: build the un-scoped sidebar tree. Shared by `getSidebar` and
1359
2108
  * `getSidebarSections`.
2109
+ *
2110
+ * When `pageCollection` names a registered version collection
2111
+ * (`docs-<v>` with `<v>` in `versions.others`), the sidebar treats that
2112
+ * collection as the primary for this build: autogen items referencing
2113
+ * `docs` are rewritten to reference the version collection, and the
2114
+ * `primary` argument to `buildSidebarTree` is set accordingly. The net
2115
+ * effect: version pages get the right sidebar tree and the right
2116
+ * prev/next ordering.
1360
2117
  */
1361
- async function buildFullSidebarTree(currentSlug) {
2118
+ async function buildFullSidebarTree(currentSlug, pageCollection) {
1362
2119
  const runtimeConfig = await loadNimbusConfig();
1363
- return buildSidebarTree(await getVisibleEntriesByCollection([PRIMARY_COLLECTION, ...collectSidebarCollectionRefs(runtimeConfig.sidebar?.items).filter((c) => c !== PRIMARY_COLLECTION)]), PRIMARY_COLLECTION, currentSlug, runtimeConfig.sidebar);
2120
+ const versions = await getVersions();
2121
+ let effectivePrimary = PRIMARY_COLLECTION;
2122
+ let primaryPrefix = "";
2123
+ if (versions && pageCollection && pageCollection.startsWith("docs-") && versions.others.includes(pageCollection.slice(5))) {
2124
+ effectivePrimary = pageCollection;
2125
+ primaryPrefix = resolveCollectionPrefix(versions, pageCollection);
2126
+ }
2127
+ const rewrittenItems = effectivePrimary !== PRIMARY_COLLECTION ? rewriteSidebarItemsForVersion(runtimeConfig.sidebar?.items, effectivePrimary) : runtimeConfig.sidebar?.items;
2128
+ const referenced = collectSidebarCollectionRefs(rewrittenItems);
2129
+ return buildSidebarTree(await getVisibleEntriesByCollection([effectivePrimary, ...referenced.filter((c) => c !== effectivePrimary)]), effectivePrimary, currentSlug, runtimeConfig.sidebar ? {
2130
+ ...runtimeConfig.sidebar,
2131
+ items: rewrittenItems
2132
+ } : void 0, primaryPrefix);
2133
+ }
2134
+ /**
2135
+ * Substitute the primary collection (`docs`) for `effectivePrimary`
2136
+ * inside any sidebar item that autogenerates from a named collection.
2137
+ * Used by `buildFullSidebarTree` to make version pages render their
2138
+ * own collection's sidebar instead of the current version's.
2139
+ */
2140
+ function rewriteSidebarItemsForVersion(items, effectivePrimary) {
2141
+ if (!items) return items;
2142
+ return items.map((item) => {
2143
+ if (!item || typeof item !== "object") return item;
2144
+ const o = item;
2145
+ const autogen = o.autogenerate;
2146
+ if (autogen && autogen.collection === PRIMARY_COLLECTION) return {
2147
+ ...o,
2148
+ autogenerate: {
2149
+ ...autogen,
2150
+ collection: effectivePrimary
2151
+ }
2152
+ };
2153
+ if (Array.isArray(o.items)) return {
2154
+ ...o,
2155
+ items: rewriteSidebarItemsForVersion(o.items, effectivePrimary)
2156
+ };
2157
+ return item;
2158
+ });
1364
2159
  }
1365
2160
  /**
1366
2161
  * Resolve prev/next links for the current page.
@@ -1474,7 +2269,261 @@ async function getDocsPageProps(astro) {
1474
2269
  headings
1475
2270
  };
1476
2271
  }
2272
+ /**
2273
+ * `getStaticPaths` implementation for a catch-all route over a non-primary
2274
+ * collection (`api`, `blog`, …). Companion to `getDocsStaticPaths`.
2275
+ *
2276
+ * Returns one path per visible entry in the named collection. Drafts are
2277
+ * filtered in production (same rule as `getDocsStaticPaths`). Each path
2278
+ * passes `{ entry }` as props for `getCollectionPageProps()`.
2279
+ *
2280
+ * Usage:
2281
+ *
2282
+ * // src/pages/api/[...slug].astro
2283
+ * export const prerender = true;
2284
+ * export const getStaticPaths = getCollectionStaticPaths("api");
2285
+ *
2286
+ * Why a sibling helper instead of an option on `getDocsStaticPaths`: the
2287
+ * `Docs` name carries the "primary collection mounted at root" semantic.
2288
+ * Non-primary collections mount under their own URL namespace
2289
+ * (`/<collection>/...`) by convention; the helper name reflects that.
2290
+ */
2291
+ function getCollectionStaticPaths(collection) {
2292
+ return async () => {
2293
+ return (await getVisibleEntries([collection])).map((entry) => ({
2294
+ params: { slug: entry.id },
2295
+ props: { entry }
2296
+ }));
2297
+ };
2298
+ }
2299
+ /**
2300
+ * Read the current entry from `Astro.props`, render it, and return the
2301
+ * pieces a docs-style page needs — typed for an arbitrary collection.
2302
+ *
2303
+ * Companion to `getCollectionStaticPaths`. Use this in routes mounted at
2304
+ * non-primary collections (`api`, `blog`, …) instead of `getDocsPageProps`,
2305
+ * which is typed to the `docs` collection.
2306
+ *
2307
+ * Pass the collection name as a type parameter for the entry's data
2308
+ * shape to narrow correctly:
2309
+ *
2310
+ * const { entry, Content, headings } = await getCollectionPageProps<"api">(Astro);
2311
+ */
2312
+ async function getCollectionPageProps(astro) {
2313
+ const entry = astro.props.entry;
2314
+ if (!entry) throw new Error("getCollectionPageProps(): expected `entry` in Astro.props. Ensure your route uses `getStaticPaths = getCollectionStaticPaths(<collection>)`.");
2315
+ const { render } = await import("astro:content");
2316
+ const { Content, headings } = await render(entry);
2317
+ return {
2318
+ entry,
2319
+ Content,
2320
+ headings
2321
+ };
2322
+ }
2323
+ /**
2324
+ * Return the resolved versioning manifest for the current site, or `null`
2325
+ * if the site is unversioned (`nimbus.config.ts` has no `versions` block).
2326
+ *
2327
+ * Optional fields are normalised to empty arrays (`deprecated`, `hidden`)
2328
+ * and `all` is `[current, ...others]` in manifest order — convenient for
2329
+ * picker enumeration or anywhere you need every known version slug.
2330
+ *
2331
+ * Usage:
2332
+ *
2333
+ * const versions = await getVersions();
2334
+ * if (versions) {
2335
+ * for (const slug of versions.all) {
2336
+ * // …enumerate
2337
+ * }
2338
+ * }
2339
+ *
2340
+ * Reads from `virtual:nimbus/config`, so the cost is one cached dynamic
2341
+ * import per build.
2342
+ */
2343
+ async function getVersions() {
2344
+ const v = (await loadNimbusConfig()).versions;
2345
+ if (!v) return null;
2346
+ const others = v.others ?? [];
2347
+ return {
2348
+ current: v.current,
2349
+ others,
2350
+ deprecated: v.deprecated ?? [],
2351
+ hidden: v.hidden ?? [],
2352
+ all: [v.current, ...others]
2353
+ };
2354
+ }
2355
+ /**
2356
+ * Return the version slug a given Astro content collection ID belongs to,
2357
+ * or `null` if the collection is not a version of the primary docs.
2358
+ *
2359
+ * Rules:
2360
+ * - `"docs"` → `versions.current` (the current version's label).
2361
+ * - `"docs-<slug>"` where `<slug>` appears in `versions.current` or
2362
+ * `versions.others` → `<slug>`.
2363
+ * - Anything else (e.g. `"blog"`, `"api"`, `"docs-archive"` when
2364
+ * `archive` isn't in the manifest) → `null`.
2365
+ *
2366
+ * Returns `null` whenever the site has no `versions` config at all,
2367
+ * regardless of collection ID.
2368
+ *
2369
+ * Usage in a route:
2370
+ *
2371
+ * const { entry } = Astro.props;
2372
+ * const version = await getCurrentVersion(entry.collection);
2373
+ * // version === "v3" for entries in `docs`, "v2" for entries in `docs-v2`, …
2374
+ */
2375
+ async function getCurrentVersion(collectionId) {
2376
+ const versions = await getVersions();
2377
+ if (!versions) return null;
2378
+ if (collectionId === PRIMARY_COLLECTION) return versions.current;
2379
+ if (!collectionId.startsWith("docs-")) return null;
2380
+ const suffix = collectionId.slice(5);
2381
+ return versions.all.includes(suffix) ? suffix : null;
2382
+ }
2383
+ /**
2384
+ * Look up the cross-version alternates for a given Astro entry.
2385
+ *
2386
+ * Returns `null` when the entry is not part of a versioning manifest
2387
+ * (unversioned site, non-`docs` collection like `blog`/`api`, or the
2388
+ * lookup misses for any other reason). Otherwise returns a record with:
2389
+ *
2390
+ * - `self`: the entry being looked up, expressed as a `VersionPageRef`.
2391
+ * - `alternates`: every other version's sibling page for the same
2392
+ * logical content (same slug or linked via `previousSlug`). Sorted
2393
+ * in manifest version order.
2394
+ * - `canonical`: the current-version sibling when one exists and
2395
+ * isn't `self`. `null` when `self` is already the current version
2396
+ * or no current-version sibling exists.
2397
+ *
2398
+ * Routes inject `<link rel="alternate">` for every entry in
2399
+ * `alternates`, and `<link rel="canonical">` pointing at `canonical.url`
2400
+ * when canonical is non-null.
2401
+ *
2402
+ * Usage in a route:
2403
+ *
2404
+ * const { entry } = Astro.props;
2405
+ * const alts = await getVersionAlternates(entry.collection, entry.id);
2406
+ *
2407
+ * {alts?.alternates.map((a) => (
2408
+ * <link rel="alternate" data-version={a.version} href={a.url} />
2409
+ * ))}
2410
+ * {alts?.canonical && <link rel="canonical" href={alts.canonical.url} />}
2411
+ */
2412
+ async function getVersionAlternates(collectionId, entryId) {
2413
+ return (await loadVersionAlternates())[`${collectionId}:${entryId}`] ?? null;
2414
+ }
2415
+ /**
2416
+ * Convenience wrapper: returns just the canonical URL for an entry, or
2417
+ * `null` when none applies. Equivalent to
2418
+ * `(await getVersionAlternates(c, e))?.canonical?.url ?? null` — handy
2419
+ * when a route only needs the canonical and not the full alternates list.
2420
+ */
2421
+ async function getCanonicalUrl(collectionId, entryId) {
2422
+ return (await getVersionAlternates(collectionId, entryId))?.canonical?.url ?? null;
2423
+ }
2424
+ /**
2425
+ * Return the agent index URL path (the `/llms.txt` route) that
2426
+ * corresponds to a given Astro collection. The path is mount-point
2427
+ * aware: pages in version collections point at the per-version index,
2428
+ * pages in non-primary collections point at their per-collection index,
2429
+ * and the primary `docs` collection points at the root.
2430
+ *
2431
+ * - `"docs"` → `"/llms.txt"`
2432
+ * - `"docs-v1"` → `"/v1/llms.txt"` (when `v1` is in `versions.others`)
2433
+ * - `"blog"` → `"/blog/llms.txt"`
2434
+ * - `"api"` → `"/api/llms.txt"`
2435
+ * - `"docs-archive"` (unrecognised version slug) → `"/docs-archive/llms.txt"`
2436
+ *
2437
+ * Returns a path with a leading slash and no trailing slash. Routes
2438
+ * resolve it against `Astro.site` to produce a full URL.
2439
+ *
2440
+ * Used by `BaseLayout` and `AgentDirective` to surface the correct
2441
+ * agent index hint on every page — readers landing on `/v1/foo` get
2442
+ * pointed at `/v1/llms.txt`, not `/llms.txt`, so agents don't crawl
2443
+ * the wrong section.
2444
+ */
2445
+ async function getCollectionLlmsUrl(collectionId) {
2446
+ if (collectionId === PRIMARY_COLLECTION) return "/llms.txt";
2447
+ const versions = await getVersions();
2448
+ if (versions && collectionId.startsWith("docs-")) {
2449
+ const slug = collectionId.slice(5);
2450
+ if (versions.others.includes(slug)) {
2451
+ if (versions.hidden.includes(slug)) return "/llms.txt";
2452
+ return `/${slug}/llms.txt`;
2453
+ }
2454
+ }
2455
+ return `/${collectionId}/llms.txt`;
2456
+ }
2457
+ /**
2458
+ * Look up the versioning status for a page's collection — what the
2459
+ * layout needs to decide whether to render the deprecation banner,
2460
+ * apply the Pagefind facet filters, or exclude the page from search
2461
+ * entirely.
2462
+ *
2463
+ * Returns `null` when the site is unversioned or the page is not part
2464
+ * of a version collection (regular `docs`, `blog`, `api`, …). Layouts
2465
+ * treat that as "no versioning UI to apply" — render normally.
2466
+ *
2467
+ * Usage:
2468
+ *
2469
+ * const status = await getVersionStatus(entry.collection);
2470
+ * if (status?.isDeprecated) {
2471
+ * // render the deprecation banner
2472
+ * }
2473
+ */
2474
+ /**
2475
+ * Resolve a URL that's guaranteed to exist within a given version's
2476
+ * collection. Used by the picker (and any other "jump to that version"
2477
+ * surface) to avoid landing readers on a 404 when the current page has
2478
+ * no same-logical-page sibling in the target version.
2479
+ *
2480
+ * Resolution order:
2481
+ * 1. If `docs-<v>/index` exists, return its URL (the conventional
2482
+ * "version landing page").
2483
+ * 2. If `docs-<v>/overview` exists, return its URL (common alternate
2484
+ * name for a landing page).
2485
+ * 3. Otherwise return the first indexed entry's URL in that version,
2486
+ * sorted by URL — matches `getIndexedTopLevel()`'s sort so the
2487
+ * choice is deterministic across builds.
2488
+ * 4. If the version has no indexed entries at all, return `null`.
2489
+ * Callers should treat that as "this version has nothing to link
2490
+ * to" and either omit the picker entry or fall back to the
2491
+ * version's URL prefix root (which may still 404, but that's the
2492
+ * authoring problem to fix, not the picker's).
2493
+ *
2494
+ * `version` is the manifest slug (e.g. `"v0"`), NOT the collection ID
2495
+ * (`"docs-v0"`). For the current version, returns `"/"` when at least
2496
+ * one current-version entry exists, else `null`.
2497
+ *
2498
+ * Reads from `getIndexedEntries()`, so the cost is one cached lookup
2499
+ * per build (the indexed list is computed once per page render).
2500
+ */
2501
+ async function getVersionLandingUrl(version) {
2502
+ const versions = await getVersions();
2503
+ if (!versions) return null;
2504
+ if (!versions.all.includes(version)) return null;
2505
+ const targetCollection = version === versions.current ? PRIMARY_COLLECTION : `docs-${version}`;
2506
+ const inVersion = (await getIndexedEntries()).filter((i) => i.collection === targetCollection);
2507
+ if (inVersion.length === 0) return null;
2508
+ const byId = new Map(inVersion.map((i) => [i.entry.id, i]));
2509
+ const preferred = byId.get("index") ?? byId.get("overview");
2510
+ if (preferred) return preferred.url;
2511
+ inVersion.sort((a, b) => a.url.localeCompare(b.url));
2512
+ return inVersion[0].url;
2513
+ }
2514
+ async function getVersionStatus(collectionId) {
2515
+ const versions = await getVersions();
2516
+ if (!versions) return null;
2517
+ const version = await getCurrentVersion(collectionId);
2518
+ if (version === null) return null;
2519
+ return {
2520
+ version,
2521
+ isCurrent: version === versions.current,
2522
+ isDeprecated: versions.deprecated.includes(version),
2523
+ isHidden: versions.hidden.includes(version)
2524
+ };
2525
+ }
1477
2526
 
1478
2527
  //#endregion
1479
- export { nimbus as default, defaultCodeTransformers, defineConfig, getBreadcrumbs, getDocsPageProps, getDocsStaticPaths, getEditUrl, getLastUpdated, getPrevNext, getSidebar, getSidebarSections, getTOC, getVisibleEntries, renderEntryAsMarkdown, sidebarHash };
2528
+ export { nimbus as default, defaultCodeTransformers, defineConfig, getBreadcrumbs, getCanonicalUrl, getCollectionLlmsUrl, getCollectionPageProps, getCollectionStaticPaths, getCurrentVersion, getDocsPageProps, getDocsStaticPaths, getEditUrl, getIndexedEntries, getIndexedTopLevel, getLastUpdated, getPrevNext, getSidebar, getSidebarSections, getTOC, getVersionAlternates, getVersionLandingUrl, getVersionStatus, getVersions, getVisibleEntries, renderEntryAsMarkdown, sidebarHash };
1480
2529
  //# sourceMappingURL=index.js.map