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/cli/index.js +25 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/content.d.ts +36 -1
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +19 -2
- package/dist/content.js.map +1 -1
- package/dist/index.d.ts +289 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1077 -28
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +24 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +17 -2
- package/dist/schemas.js.map +1 -1
- package/dist/types.d.ts +118 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/NimbusHead.astro +81 -2
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$
|
|
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$
|
|
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 ?
|
|
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({
|
|
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
|
-
|
|
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
|
-
*
|
|
1336
|
-
*
|
|
1337
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|