nimbus-docs 0.0.2
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/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +692 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client.d.ts +167 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +367 -0
- package/dist/client.js.map +1 -0
- package/dist/content.d.ts +126 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +57 -0
- package/dist/content.js.map +1 -0
- package/dist/index.d.ts +255 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1478 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/pkgm.d.ts +41 -0
- package/dist/lib/pkgm.d.ts.map +1 -0
- package/dist/lib/pkgm.js +76 -0
- package/dist/lib/pkgm.js.map +1 -0
- package/dist/schemas.d.ts +164 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +110 -0
- package/dist/schemas.js.map +1 -0
- package/dist/types.d.ts +274 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +81 -0
- package/src/components/NimbusHead.astro +161 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import mdx from "@astrojs/mdx";
|
|
6
|
+
import { satteri } from "@astrojs/markdown-satteri";
|
|
7
|
+
import sitemap from "@astrojs/sitemap";
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from "@shikijs/transformers";
|
|
10
|
+
import { z } from "astro/zod";
|
|
11
|
+
|
|
12
|
+
//#region src/_internal/runtime-config.ts
|
|
13
|
+
let _cached = null;
|
|
14
|
+
async function loadNimbusConfig() {
|
|
15
|
+
if (_cached) return _cached;
|
|
16
|
+
_cached = (await import("virtual:nimbus/config")).config;
|
|
17
|
+
return _cached;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/_internal/content.ts
|
|
22
|
+
/** Primary collection name. Hard-coded — see also `getDocsStaticPaths`. */
|
|
23
|
+
const PRIMARY_COLLECTION$1 = "docs";
|
|
24
|
+
/**
|
|
25
|
+
* Return visible entries from one or more collections. Drafts are
|
|
26
|
+
* filtered out in production builds (matching the existing
|
|
27
|
+
* single-collection behaviour).
|
|
28
|
+
*
|
|
29
|
+
* Defaults to `["docs"]` — the framework's primary collection.
|
|
30
|
+
* Cross-collection callers (llms.txt aggregators, custom indexes,
|
|
31
|
+
* etc.) pass an explicit list.
|
|
32
|
+
*
|
|
33
|
+
* Returns a flat `CollectionEntry<string>[]` so cross-collection
|
|
34
|
+
* traversal doesn't need to know the user's collection names at type
|
|
35
|
+
* time. Callers that need per-collection type safety should call
|
|
36
|
+
* `getCollection("api")` directly.
|
|
37
|
+
*/
|
|
38
|
+
async function getVisibleEntries(collections = [PRIMARY_COLLECTION$1]) {
|
|
39
|
+
const { getCollection } = await import("astro:content");
|
|
40
|
+
const all = (await Promise.all(collections.map((name) => getCollection(name).catch(() => [])))).flat();
|
|
41
|
+
return import.meta.env.PROD ? all.filter((entry) => !entry.data.draft) : all;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Return visible entries grouped by collection. Used by the sidebar
|
|
45
|
+
* builder so `collection:` autogenerate can look up entries by name
|
|
46
|
+
* without re-fetching.
|
|
47
|
+
*/
|
|
48
|
+
async function getVisibleEntriesByCollection(collections) {
|
|
49
|
+
const { getCollection } = await import("astro:content");
|
|
50
|
+
const out = {};
|
|
51
|
+
await Promise.all(collections.map(async (name) => {
|
|
52
|
+
const all = await getCollection(name).catch(() => []);
|
|
53
|
+
out[name] = import.meta.env.PROD ? all.filter((entry) => !entry.data.draft) : all;
|
|
54
|
+
}));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/_internal/sidebar.ts
|
|
60
|
+
const sortKeyByItem = /* @__PURE__ */ new WeakMap();
|
|
61
|
+
function sortSidebarItems(a, b) {
|
|
62
|
+
const orderDiff = a.order - b.order;
|
|
63
|
+
if (orderDiff !== 0) return orderDiff;
|
|
64
|
+
const keyA = sortKeyByItem.get(a) ?? ("href" in a ? a.href : a.label);
|
|
65
|
+
const keyB = sortKeyByItem.get(b) ?? ("href" in b ? b.href : b.label);
|
|
66
|
+
const keyDiff = keyA.localeCompare(keyB);
|
|
67
|
+
if (keyDiff !== 0) return keyDiff;
|
|
68
|
+
return a.type.localeCompare(b.type);
|
|
69
|
+
}
|
|
70
|
+
/** Ensure internal href has leading /, no trailing slash (except root) */
|
|
71
|
+
function normalizeInternalHref(href) {
|
|
72
|
+
let h = href.split("?")[0].split("#")[0];
|
|
73
|
+
if (!h.startsWith("/")) h = `/${h}`;
|
|
74
|
+
if (h.length > 1 && h.endsWith("/")) h = h.slice(0, -1);
|
|
75
|
+
return h;
|
|
76
|
+
}
|
|
77
|
+
/** Strip query and hash for active-state matching */
|
|
78
|
+
function stripQueryHash(href) {
|
|
79
|
+
return href.split("?")[0].split("#")[0];
|
|
80
|
+
}
|
|
81
|
+
function buildEntryIndex(entries) {
|
|
82
|
+
const visible = entries.filter((e) => !e.data.sidebar?.hidden);
|
|
83
|
+
const byId = /* @__PURE__ */ new Map();
|
|
84
|
+
for (const entry of visible) byId.set(entry.id, entry);
|
|
85
|
+
const hasChildren = /* @__PURE__ */ new Set();
|
|
86
|
+
for (const entry of visible) {
|
|
87
|
+
const parts = entry.id.split("/");
|
|
88
|
+
for (let i = 1; i < parts.length; i++) hasChildren.add(parts.slice(0, i).join("/"));
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
visible,
|
|
92
|
+
byId,
|
|
93
|
+
hasChildren
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Compose a final href for an entry. `hrefPrefix` is the collection mount path (e.g. `/api`). */
|
|
97
|
+
function joinHref(hrefPrefix, entryId) {
|
|
98
|
+
return `${hrefPrefix.replace(/\/$/, "")}/${entryId}`;
|
|
99
|
+
}
|
|
100
|
+
function createLink(entry, currentPath, hrefPrefix = "") {
|
|
101
|
+
const href = joinHref(hrefPrefix, entry.id);
|
|
102
|
+
const badge = entry.data.draft ? entry.data.sidebar?.badge ?? {
|
|
103
|
+
text: "Draft",
|
|
104
|
+
variant: "warning"
|
|
105
|
+
} : entry.data.sidebar?.badge;
|
|
106
|
+
const link = {
|
|
107
|
+
type: "link",
|
|
108
|
+
label: entry.data.sidebar?.label ?? entry.data.title,
|
|
109
|
+
href,
|
|
110
|
+
isCurrent: currentPath === href,
|
|
111
|
+
badge,
|
|
112
|
+
order: entry.data.sidebar?.order ?? Number.MAX_VALUE
|
|
113
|
+
};
|
|
114
|
+
sortKeyByItem.set(link, entry.id);
|
|
115
|
+
return link;
|
|
116
|
+
}
|
|
117
|
+
function buildFilesystemTree(entries, currentPath, directory, hrefPrefix = "") {
|
|
118
|
+
const { visible, byId, hasChildren } = buildEntryIndex(entries);
|
|
119
|
+
const scoped = directory ? visible.filter((e) => e.id === directory || e.id.startsWith(`${directory}/`)) : visible;
|
|
120
|
+
function buildLevel(parentPath) {
|
|
121
|
+
const result = [];
|
|
122
|
+
const groupsAtLevel = /* @__PURE__ */ new Map();
|
|
123
|
+
for (const entry of scoped) {
|
|
124
|
+
if (entry.id === "index") continue;
|
|
125
|
+
const id = entry.id;
|
|
126
|
+
const relativeTo = directory ?? "";
|
|
127
|
+
const relativeId = relativeTo ? id === relativeTo ? "" : id.slice(relativeTo.length + 1) : id;
|
|
128
|
+
if (parentPath === "") if (!relativeId || relativeId.includes("/") === false) {
|
|
129
|
+
if (!relativeId) continue;
|
|
130
|
+
if (hasChildren.has(id)) {
|
|
131
|
+
if (!groupsAtLevel.has(id)) {
|
|
132
|
+
const group = createGroupFromEntry(id, entry, currentPath, byId);
|
|
133
|
+
groupsAtLevel.set(id, group);
|
|
134
|
+
result.push(group);
|
|
135
|
+
}
|
|
136
|
+
} else result.push(createLink(entry, currentPath, hrefPrefix));
|
|
137
|
+
} else {
|
|
138
|
+
const firstSeg = relativeId.split("/")[0];
|
|
139
|
+
const topDir = directory ? `${directory}/${firstSeg}` : firstSeg;
|
|
140
|
+
if (!groupsAtLevel.has(topDir)) {
|
|
141
|
+
const group = createGroupFromEntry(topDir, byId.get(topDir), currentPath, byId);
|
|
142
|
+
groupsAtLevel.set(topDir, group);
|
|
143
|
+
result.push(group);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
if (!id.startsWith(`${parentPath}/`)) continue;
|
|
148
|
+
const remainderParts = id.slice(parentPath.length + 1).split("/");
|
|
149
|
+
if (remainderParts.length === 1) if (hasChildren.has(id)) {
|
|
150
|
+
if (!groupsAtLevel.has(id)) {
|
|
151
|
+
const group = createGroupFromEntry(id, entry, currentPath, byId);
|
|
152
|
+
groupsAtLevel.set(id, group);
|
|
153
|
+
result.push(group);
|
|
154
|
+
}
|
|
155
|
+
} else result.push(createLink(entry, currentPath, hrefPrefix));
|
|
156
|
+
else {
|
|
157
|
+
const nextDir = `${parentPath}/${remainderParts[0]}`;
|
|
158
|
+
if (!groupsAtLevel.has(nextDir)) {
|
|
159
|
+
const group = createGroupFromEntry(nextDir, byId.get(nextDir), currentPath, byId);
|
|
160
|
+
groupsAtLevel.set(nextDir, group);
|
|
161
|
+
result.push(group);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const [groupPath, group] of groupsAtLevel) {
|
|
167
|
+
const nestedChildren = buildLevel(groupPath);
|
|
168
|
+
group.children = [...group.children, ...nestedChildren].sort(sortSidebarItems);
|
|
169
|
+
if (group.children.length > 0) {
|
|
170
|
+
const minChildOrder = Math.min(...group.children.map((item) => item.order));
|
|
171
|
+
group.order = Math.min(group.order, minChildOrder);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return result.sort(sortSidebarItems);
|
|
175
|
+
}
|
|
176
|
+
function createGroupFromEntry(dirPath, indexEntry, currentPath, _byId) {
|
|
177
|
+
const dirSegment = dirPath.split("/").pop();
|
|
178
|
+
const groupLabel = indexEntry?.data.sidebar?.label ?? formatLabel(dirSegment);
|
|
179
|
+
const groupOrder = indexEntry?.data.sidebar?.order ?? Number.MAX_VALUE;
|
|
180
|
+
const children = [];
|
|
181
|
+
if (indexEntry) children.push(createLink(indexEntry, currentPath, hrefPrefix));
|
|
182
|
+
const group = {
|
|
183
|
+
type: "group",
|
|
184
|
+
label: groupLabel,
|
|
185
|
+
order: groupOrder,
|
|
186
|
+
badge: indexEntry?.data.sidebar?.badge,
|
|
187
|
+
children,
|
|
188
|
+
_indexId: indexEntry?.id
|
|
189
|
+
};
|
|
190
|
+
sortKeyByItem.set(group, dirPath);
|
|
191
|
+
return group;
|
|
192
|
+
}
|
|
193
|
+
if (directory) return buildLevel(directory);
|
|
194
|
+
return buildLevel("");
|
|
195
|
+
}
|
|
196
|
+
function resolveConfigItems(configItems, entriesByCollection, primaryCollection, currentPath, orderStart = 0) {
|
|
197
|
+
const primaryEntries = entriesByCollection[primaryCollection] ?? [];
|
|
198
|
+
const { byId } = buildEntryIndex(primaryEntries);
|
|
199
|
+
const result = [];
|
|
200
|
+
for (let i = 0; i < configItems.length; i++) {
|
|
201
|
+
const item = configItems[i];
|
|
202
|
+
const order = orderStart + i;
|
|
203
|
+
if (typeof item === "string") {
|
|
204
|
+
const entry = byId.get(item);
|
|
205
|
+
if (entry) {
|
|
206
|
+
const link = createLink(entry, currentPath);
|
|
207
|
+
link.order = order;
|
|
208
|
+
result.push(link);
|
|
209
|
+
} else console.warn(`[sidebar] Page "${item}" referenced in config but not found in primary collection "${primaryCollection}"`);
|
|
210
|
+
} else if ("link" in item) if (!item.link.startsWith("/")) {
|
|
211
|
+
const extLink = {
|
|
212
|
+
type: "external",
|
|
213
|
+
label: item.label,
|
|
214
|
+
href: item.link,
|
|
215
|
+
badge: item.badge,
|
|
216
|
+
order
|
|
217
|
+
};
|
|
218
|
+
result.push(extLink);
|
|
219
|
+
} else {
|
|
220
|
+
const href = normalizeInternalHref(item.link);
|
|
221
|
+
const matchPath = stripQueryHash(href);
|
|
222
|
+
const lookup = href.slice(1);
|
|
223
|
+
if (!lookup.includes("/") && href !== "/" && !byId.has(lookup)) console.warn(`[sidebar] Internal link "${item.link}" (label: "${item.label}") does not match any entry in primary collection "${primaryCollection}"`);
|
|
224
|
+
const link = {
|
|
225
|
+
type: "link",
|
|
226
|
+
label: item.label,
|
|
227
|
+
href,
|
|
228
|
+
isCurrent: currentPath === matchPath,
|
|
229
|
+
badge: item.badge,
|
|
230
|
+
order
|
|
231
|
+
};
|
|
232
|
+
result.push(link);
|
|
233
|
+
}
|
|
234
|
+
else if ("autogenerate" in item) {
|
|
235
|
+
let autoItems;
|
|
236
|
+
if ("collection" in item.autogenerate) {
|
|
237
|
+
const collectionName = item.autogenerate.collection;
|
|
238
|
+
const collectionEntries = entriesByCollection[collectionName];
|
|
239
|
+
if (!collectionEntries) {
|
|
240
|
+
console.warn(`[sidebar] autogenerate references collection "${collectionName}" which is not registered in nimbus.config.collections; skipping`);
|
|
241
|
+
autoItems = [];
|
|
242
|
+
} else autoItems = buildFilesystemTree(collectionEntries, currentPath, void 0, item.autogenerate.prefix ?? (collectionName === primaryCollection ? "" : `/${collectionName}`));
|
|
243
|
+
} else autoItems = buildFilesystemTree(primaryEntries, currentPath, item.autogenerate.directory);
|
|
244
|
+
if (item.label) {
|
|
245
|
+
const group = {
|
|
246
|
+
type: "group",
|
|
247
|
+
label: item.label,
|
|
248
|
+
order,
|
|
249
|
+
collapsed: item.collapsed,
|
|
250
|
+
badge: item.badge,
|
|
251
|
+
children: autoItems
|
|
252
|
+
};
|
|
253
|
+
result.push(group);
|
|
254
|
+
} else {
|
|
255
|
+
if (item.collapsed !== void 0) {
|
|
256
|
+
for (const ai of autoItems) if (ai.type === "group") ai.collapsed = item.collapsed;
|
|
257
|
+
}
|
|
258
|
+
result.push(...autoItems);
|
|
259
|
+
}
|
|
260
|
+
} else if ("items" in item) {
|
|
261
|
+
const children = resolveConfigItems(item.items, entriesByCollection, primaryCollection, currentPath);
|
|
262
|
+
const group = {
|
|
263
|
+
type: "group",
|
|
264
|
+
label: item.label,
|
|
265
|
+
order,
|
|
266
|
+
collapsed: item.collapsed,
|
|
267
|
+
badge: item.badge,
|
|
268
|
+
children
|
|
269
|
+
};
|
|
270
|
+
result.push(group);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return result;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Return only the children of the top-level group containing the current
|
|
277
|
+
* page. Falls back to the full tree if the current page isn't inside any
|
|
278
|
+
* group (e.g. a top-level link, or a path that doesn't resolve).
|
|
279
|
+
*/
|
|
280
|
+
function scopeToCurrentSection(items, currentPath) {
|
|
281
|
+
if (!currentPath.split("/").filter(Boolean)[0]) return items;
|
|
282
|
+
for (const item of items) if (item.type === "group") {
|
|
283
|
+
if (hasActivePage(item, currentPath)) return item.children;
|
|
284
|
+
}
|
|
285
|
+
return items;
|
|
286
|
+
}
|
|
287
|
+
function hasActivePage(item, currentPath) {
|
|
288
|
+
if (item.type === "link") return item.isCurrent === true;
|
|
289
|
+
if (item.type === "external") return false;
|
|
290
|
+
return item.children.some((child) => hasActivePage(child, currentPath));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Derive one section per top-level group in the sidebar tree. Used by
|
|
294
|
+
* `Header.astro` to render the section tab strip. Caller must pass the
|
|
295
|
+
* *un-scoped* tree (the result of `buildSidebarTree`, not `getSidebar`);
|
|
296
|
+
* otherwise only the current section's children are visible and the
|
|
297
|
+
* derivation collapses to a single item.
|
|
298
|
+
*/
|
|
299
|
+
function deriveSidebarSections(items) {
|
|
300
|
+
return items.flatMap((item) => {
|
|
301
|
+
if (item.type !== "group") return [];
|
|
302
|
+
const links = flattenLinks(item.children);
|
|
303
|
+
if (links.length === 0) return [];
|
|
304
|
+
return [{
|
|
305
|
+
label: item.label,
|
|
306
|
+
href: links[0].href,
|
|
307
|
+
isActive: links.some((link) => link.isCurrent === true)
|
|
308
|
+
}];
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
/** Depth-first walk; collect every internal link descendant. */
|
|
312
|
+
function flattenLinks(items) {
|
|
313
|
+
const out = [];
|
|
314
|
+
for (const item of items) if (item.type === "link") out.push(item);
|
|
315
|
+
else if (item.type === "group") out.push(...flattenLinks(item.children));
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Build the un-scoped sidebar tree from config + content entries.
|
|
320
|
+
*
|
|
321
|
+
* `entriesByCollection` is a name → entries map covering every
|
|
322
|
+
* collection the user listed in `NimbusConfig.collections`. The
|
|
323
|
+
* `primaryCollection` (first entry of that list) is what
|
|
324
|
+
* filesystem-fallback, `directory:` autogenerate, and bare-slug
|
|
325
|
+
* references read from. Other collections only contribute when an
|
|
326
|
+
* explicit `autogenerate: { collection: "<name>" }` references them.
|
|
327
|
+
*
|
|
328
|
+
* - If config has items: resolve them (config takes priority)
|
|
329
|
+
* - If config has no items: auto-generate from primary collection
|
|
330
|
+
*
|
|
331
|
+
* Always returns the full top-level tree. Scoping (showing only the
|
|
332
|
+
* current section's children in the rail) is applied by the public
|
|
333
|
+
* `getSidebar` helper via `scopeToCurrentSection`.
|
|
334
|
+
*/
|
|
335
|
+
function buildSidebarTree(entriesByCollection, primaryCollection, currentPath, config) {
|
|
336
|
+
const primaryEntries = entriesByCollection[primaryCollection] ?? [];
|
|
337
|
+
let items;
|
|
338
|
+
if (config?.items && config.items.length > 0) items = resolveConfigItems(config.items, entriesByCollection, primaryCollection, currentPath);
|
|
339
|
+
else items = buildFilesystemTree(primaryEntries, currentPath);
|
|
340
|
+
const pooledEntries = Object.values(entriesByCollection).flat();
|
|
341
|
+
items = processHideChildren(items, pooledEntries);
|
|
342
|
+
return items;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Process hideChildren: replace groups whose index has hideChildren=true
|
|
346
|
+
* with a single link to the index page.
|
|
347
|
+
*/
|
|
348
|
+
function processHideChildren(items, entries) {
|
|
349
|
+
const entryById = /* @__PURE__ */ new Map();
|
|
350
|
+
for (const e of entries) entryById.set(e.id, e);
|
|
351
|
+
function process(items) {
|
|
352
|
+
const result = [];
|
|
353
|
+
for (const item of items) {
|
|
354
|
+
if (item.type !== "group") {
|
|
355
|
+
result.push(item);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (item._indexId) {
|
|
359
|
+
if (entryById.get(item._indexId)?.data.sidebar?.hideChildren) {
|
|
360
|
+
const indexHref = `/${item._indexId}`;
|
|
361
|
+
const indexLink = item.children.find((c) => c.type === "link" && c.href === indexHref);
|
|
362
|
+
if (indexLink) {
|
|
363
|
+
const link = {
|
|
364
|
+
...indexLink,
|
|
365
|
+
label: item.label
|
|
366
|
+
};
|
|
367
|
+
result.push(link);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
item.children = process(item.children);
|
|
373
|
+
result.push(item);
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
return process(items);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Walk a sidebar config items array (recursively, through nested
|
|
381
|
+
* `items:` groups) and collect every collection name referenced by an
|
|
382
|
+
* `autogenerate: { collection: ... }` entry.
|
|
383
|
+
*
|
|
384
|
+
* The framework uses this to figure out which collections to load for
|
|
385
|
+
* the sidebar — there's no separate `collections: string[]` config
|
|
386
|
+
* field. The primary collection (`docs`) is always included by the
|
|
387
|
+
* caller; this helper returns only the *extra* names referenced by
|
|
388
|
+
* sidebar items.
|
|
389
|
+
*/
|
|
390
|
+
function collectSidebarCollectionRefs(items) {
|
|
391
|
+
if (!items) return [];
|
|
392
|
+
const found = /* @__PURE__ */ new Set();
|
|
393
|
+
function walk(items) {
|
|
394
|
+
for (const item of items) {
|
|
395
|
+
if (typeof item === "string") continue;
|
|
396
|
+
if ("autogenerate" in item && "collection" in item.autogenerate) found.add(item.autogenerate.collection);
|
|
397
|
+
else if ("items" in item) walk(item.items);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
walk(items);
|
|
401
|
+
return [...found];
|
|
402
|
+
}
|
|
403
|
+
/** Flatten sidebar tree into a list of links (for pagination) */
|
|
404
|
+
function flattenSidebar(items) {
|
|
405
|
+
const flat = [];
|
|
406
|
+
for (const item of items) if (item.type === "link") flat.push(item);
|
|
407
|
+
else if (item.type === "group") flat.push(...flattenSidebar(item.children));
|
|
408
|
+
return flat;
|
|
409
|
+
}
|
|
410
|
+
function formatLabel(segment) {
|
|
411
|
+
return segment.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
412
|
+
}
|
|
413
|
+
function buildSidebarIdentity(items) {
|
|
414
|
+
return items.flatMap((item) => item.type === "group" ? item.label + buildSidebarIdentity(item.children) : item.label + ("href" in item ? item.href : "")).join("");
|
|
415
|
+
}
|
|
416
|
+
/** Hash the sidebar structure into a short string for sessionStorage invalidation. */
|
|
417
|
+
function sidebarHash(items) {
|
|
418
|
+
const identity = buildSidebarIdentity(items);
|
|
419
|
+
let hash = 0;
|
|
420
|
+
for (let i = 0; i < identity.length; i++) hash = (hash << 5) - hash + identity.charCodeAt(i);
|
|
421
|
+
return (hash >>> 0).toString(36).padStart(7, "0");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/_internal/transform.ts
|
|
426
|
+
function protectCode(markdown) {
|
|
427
|
+
const protectedChunks = [];
|
|
428
|
+
function store(chunk) {
|
|
429
|
+
const token = `@@NIMBUS_MD_CODE_${protectedChunks.length}@@`;
|
|
430
|
+
protectedChunks.push(chunk.startsWith("```") ? chunk.replace(/\n[ \t]{4}/g, "\n") : chunk);
|
|
431
|
+
return token;
|
|
432
|
+
}
|
|
433
|
+
let next = markdown.replace(/```[\s\S]*?```/g, store);
|
|
434
|
+
next = next.replace(/`[^`\n]+`/g, store);
|
|
435
|
+
return {
|
|
436
|
+
markdown: next,
|
|
437
|
+
restore(value) {
|
|
438
|
+
return value.replace(/@@NIMBUS_MD_CODE_(\d+)@@/g, (_match, index) => protectedChunks[Number(index)] ?? "");
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function parseAttrs(raw = "") {
|
|
443
|
+
const attrs = {};
|
|
444
|
+
for (const match of raw.matchAll(/([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}|([^\s>]+)))?/g)) {
|
|
445
|
+
const [, name, dq, sq, expr, bare] = match;
|
|
446
|
+
if (!name) continue;
|
|
447
|
+
attrs[name] = dq ?? sq ?? expr?.trim() ?? bare ?? true;
|
|
448
|
+
}
|
|
449
|
+
return attrs;
|
|
450
|
+
}
|
|
451
|
+
function cleanChildren(children) {
|
|
452
|
+
return children.replace(/^\s+/g, "").replace(/\s+$/g, "").replace(/\n[ \t]+/g, "\n");
|
|
453
|
+
}
|
|
454
|
+
function blockquote(body) {
|
|
455
|
+
return body.split("\n").map((line) => line ? `> ${line}` : ">").join("\n");
|
|
456
|
+
}
|
|
457
|
+
function asTitle(value, fallback) {
|
|
458
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
459
|
+
}
|
|
460
|
+
function renderPackageManagers(attrs) {
|
|
461
|
+
const pkg = typeof attrs.pkg === "string" ? attrs.pkg : void 0;
|
|
462
|
+
const args = typeof attrs.args === "string" ? attrs.args : void 0;
|
|
463
|
+
const type = typeof attrs.type === "string" ? attrs.type : "install";
|
|
464
|
+
const dev = attrs.dev === true || attrs.dev === "true";
|
|
465
|
+
let commands;
|
|
466
|
+
if (type === "run") {
|
|
467
|
+
const command = args ?? "dev";
|
|
468
|
+
commands = [
|
|
469
|
+
`npm run ${command}`,
|
|
470
|
+
`pnpm ${command}`,
|
|
471
|
+
`yarn ${command}`,
|
|
472
|
+
`bun run ${command}`
|
|
473
|
+
];
|
|
474
|
+
} else if (type === "exec") {
|
|
475
|
+
const command = args ?? pkg ?? "";
|
|
476
|
+
commands = [
|
|
477
|
+
`npx ${command}`,
|
|
478
|
+
`pnpm exec ${command}`,
|
|
479
|
+
`yarn exec ${command}`,
|
|
480
|
+
`bunx ${command}`
|
|
481
|
+
];
|
|
482
|
+
} else if (type === "dlx") {
|
|
483
|
+
const command = args ?? pkg ?? "";
|
|
484
|
+
commands = [
|
|
485
|
+
`npx ${command}`,
|
|
486
|
+
`pnpm dlx ${command}`,
|
|
487
|
+
`yarn dlx ${command}`,
|
|
488
|
+
`bunx ${command}`
|
|
489
|
+
];
|
|
490
|
+
} else if (pkg) commands = [
|
|
491
|
+
`npm install ${dev ? "--save-dev " : ""}${pkg}`,
|
|
492
|
+
`pnpm add ${dev ? "-D " : ""}${pkg}`,
|
|
493
|
+
`yarn add ${dev ? "-D " : ""}${pkg}`,
|
|
494
|
+
`bun add ${dev ? "-d " : ""}${pkg}`
|
|
495
|
+
];
|
|
496
|
+
else return "";
|
|
497
|
+
return [
|
|
498
|
+
"```sh",
|
|
499
|
+
...commands,
|
|
500
|
+
"```"
|
|
501
|
+
].join("\n");
|
|
502
|
+
}
|
|
503
|
+
function applyDefaultComponentTransforms(markdown) {
|
|
504
|
+
let out = markdown;
|
|
505
|
+
out = out.replace(/<PackageManagers\b([^>]*)\/>/g, (_match, rawAttrs) => renderPackageManagers(parseAttrs(rawAttrs)));
|
|
506
|
+
out = out.replace(/<Aside\b([^>]*)>([\s\S]*?)<\/Aside>/g, (_match, rawAttrs, children) => {
|
|
507
|
+
const attrs = parseAttrs(rawAttrs);
|
|
508
|
+
const type = asTitle(attrs.type, "note").toUpperCase();
|
|
509
|
+
return blockquote(`**${asTitle(attrs.title, type.charAt(0) + type.slice(1).toLowerCase())}**\n\n${cleanChildren(children)}`);
|
|
510
|
+
});
|
|
511
|
+
out = out.replace(/<Card\b([^>]*)>([\s\S]*?)<\/Card>/g, (_match, rawAttrs, children) => {
|
|
512
|
+
const title = asTitle(parseAttrs(rawAttrs).title, "Card");
|
|
513
|
+
const body = cleanChildren(children);
|
|
514
|
+
return `- **${title}**${body ? ` — ${body}` : ""}`;
|
|
515
|
+
});
|
|
516
|
+
out = out.replace(/<\/?CardGrid\b[^>]*>/g, "");
|
|
517
|
+
out = out.replace(/<Steps\b[^>]*>([\s\S]*?)<\/Steps>/g, (_match, children) => {
|
|
518
|
+
let index = 0;
|
|
519
|
+
return children.replace(/<Step\b([^>]*)>([\s\S]*?)<\/Step>/g, (_stepMatch, rawAttrs, stepChildren) => {
|
|
520
|
+
index += 1;
|
|
521
|
+
const title = asTitle(parseAttrs(rawAttrs).title, `Step ${index}`);
|
|
522
|
+
const body = cleanChildren(stepChildren);
|
|
523
|
+
return `${index}. **${title}**${body ? `\n\n ${body.replace(/\n/g, "\n ")}` : ""}`;
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
out = out.replace(/<Tabs\b[^>]*>([\s\S]*?)<\/Tabs>/g, (_match, children) => children.replace(/<TabItem\b([^>]*)>([\s\S]*?)<\/TabItem>/g, (_tabMatch, rawAttrs, tabChildren) => {
|
|
527
|
+
return `### ${asTitle(parseAttrs(rawAttrs).label, "Option")}\n\n${cleanChildren(tabChildren)}`;
|
|
528
|
+
}));
|
|
529
|
+
out = out.replace(/<([A-Z][A-Za-z0-9]*)\b[^>]*>([\s\S]*?)<\/\1>/g, "$2");
|
|
530
|
+
out = out.replace(/<([A-Z][A-Za-z0-9]*)\b[^>]*\/>/g, "");
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
function applyCustomComponentTransforms(markdown, componentMap) {
|
|
534
|
+
let out = markdown;
|
|
535
|
+
for (const [name, render] of Object.entries(componentMap)) {
|
|
536
|
+
const paired = new RegExp(`<${name}\\b([^>]*)>([\\s\\S]*?)<\\/${name}>`, "g");
|
|
537
|
+
out = out.replace(paired, (_match, rawAttrs, children) => render({
|
|
538
|
+
name,
|
|
539
|
+
attrs: parseAttrs(rawAttrs),
|
|
540
|
+
children: cleanChildren(children)
|
|
541
|
+
}));
|
|
542
|
+
const selfClosing = new RegExp(`<${name}\\b([^>]*)\\/>`, "g");
|
|
543
|
+
out = out.replace(selfClosing, (_match, rawAttrs) => render({
|
|
544
|
+
name,
|
|
545
|
+
attrs: parseAttrs(rawAttrs),
|
|
546
|
+
children: ""
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Render an Astro content entry's raw MDX body as plain markdown.
|
|
553
|
+
*
|
|
554
|
+
* This handles the starter's default MDX components. Users can pass a
|
|
555
|
+
* `componentMap` to override individual component renderers or replace this
|
|
556
|
+
* function entirely from their user-owned `.md` route.
|
|
557
|
+
*/
|
|
558
|
+
function renderEntryAsMarkdown(entry, options = {}) {
|
|
559
|
+
const stripFrontmatter = options.stripFrontmatter ?? true;
|
|
560
|
+
let markdown = entry.body ?? "";
|
|
561
|
+
if (stripFrontmatter) markdown = markdown.replace(/^---\n[\s\S]*?\n---\n?/, "");
|
|
562
|
+
const protectedCode = protectCode(markdown);
|
|
563
|
+
markdown = protectedCode.markdown;
|
|
564
|
+
if (options.componentMap) markdown = applyCustomComponentTransforms(markdown, options.componentMap);
|
|
565
|
+
markdown = applyDefaultComponentTransforms(markdown);
|
|
566
|
+
markdown = protectedCode.restore(markdown);
|
|
567
|
+
return markdown.replace(/^[ \t]+(- \*\*)/gm, "$1").replace(/^[ \t]+(\d+\. \*\*)/gm, "$1").replace(/^[ \t]+(### )/gm, "$1").replace(/^[ \t]+(```)/gm, "$1").replace(/^[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/_internal/navigation.ts
|
|
572
|
+
function getBreadcrumbs$1(slug, homeLabel = "Home") {
|
|
573
|
+
const parts = slug.split("/").filter(Boolean);
|
|
574
|
+
const crumbs = [{
|
|
575
|
+
label: homeLabel,
|
|
576
|
+
href: "/"
|
|
577
|
+
}];
|
|
578
|
+
let path = "";
|
|
579
|
+
for (const part of parts) {
|
|
580
|
+
path += `/${part}`;
|
|
581
|
+
crumbs.push({
|
|
582
|
+
label: part.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
583
|
+
href: path
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return crumbs;
|
|
587
|
+
}
|
|
588
|
+
function normalizeInternalPath(path) {
|
|
589
|
+
const [withoutHash] = path.split("#", 1);
|
|
590
|
+
const [pathname] = withoutHash.split("?", 1);
|
|
591
|
+
return pathname || "/";
|
|
592
|
+
}
|
|
593
|
+
function resolveOverride(override, fallback, validInternalLinks) {
|
|
594
|
+
if (override === false) return void 0;
|
|
595
|
+
if (override === void 0) return fallback;
|
|
596
|
+
if (typeof override === "string") {
|
|
597
|
+
if (!fallback) return void 0;
|
|
598
|
+
return {
|
|
599
|
+
label: override,
|
|
600
|
+
href: fallback.href
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (override.link && !override.link.startsWith("/") && !override.link.startsWith("http")) throw new Error(`prev/next override link "${override.link}" must be an absolute path (starting with /) or a full URL`);
|
|
604
|
+
if (override.link?.startsWith("/") && validInternalLinks) {
|
|
605
|
+
const targetPath = normalizeInternalPath(override.link);
|
|
606
|
+
if (!validInternalLinks.has(targetPath)) throw new Error(`prev/next override link "${override.link}" does not match any existing internal docs route`);
|
|
607
|
+
}
|
|
608
|
+
const label = override.label ?? fallback?.label;
|
|
609
|
+
const href = override.link ?? fallback?.href;
|
|
610
|
+
if (!fallback && (label === void 0 || href === void 0)) throw new Error("prev/next object override requires both `label` and `link` when no sidebar neighbor exists");
|
|
611
|
+
if (!href) return void 0;
|
|
612
|
+
return {
|
|
613
|
+
label: label ?? "",
|
|
614
|
+
href
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
function getPrevNext$1(currentPath, sidebarTree, overrides, validInternalLinks) {
|
|
618
|
+
const flat = flattenSidebar(sidebarTree);
|
|
619
|
+
const index = flat.findIndex((item) => item.href === currentPath);
|
|
620
|
+
const sidebarPrev = index > 0 ? {
|
|
621
|
+
label: flat[index - 1].label,
|
|
622
|
+
href: flat[index - 1].href
|
|
623
|
+
} : void 0;
|
|
624
|
+
const sidebarNext = index >= 0 && index < flat.length - 1 ? {
|
|
625
|
+
label: flat[index + 1].label,
|
|
626
|
+
href: flat[index + 1].href
|
|
627
|
+
} : void 0;
|
|
628
|
+
if (!overrides) return {
|
|
629
|
+
prev: sidebarPrev,
|
|
630
|
+
next: sidebarNext
|
|
631
|
+
};
|
|
632
|
+
return {
|
|
633
|
+
prev: resolveOverride(overrides.prev, sidebarPrev, validInternalLinks),
|
|
634
|
+
next: resolveOverride(overrides.next, sidebarNext, validInternalLinks)
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
//#endregion
|
|
639
|
+
//#region src/_internal/toc.ts
|
|
640
|
+
function getHeadings(headings, config) {
|
|
641
|
+
const min = config?.minHeadingLevel ?? 2;
|
|
642
|
+
const max = config?.maxHeadingLevel ?? 3;
|
|
643
|
+
return headings.filter((h) => h.depth >= min && h.depth <= max);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/_internal/git-last-updated.ts
|
|
648
|
+
/**
|
|
649
|
+
* git-last-updated.ts — Derive a per-page `lastUpdated` from `git log`.
|
|
650
|
+
*
|
|
651
|
+
* Uses the **author date** (`%aI`) instead of the committer date (`%cI`)
|
|
652
|
+
* so the value stays stable when a branch is rebased: rebases rewrite
|
|
653
|
+
* commit dates but preserve author dates for unchanged content. Squash
|
|
654
|
+
* merges produce a single new commit that touches the file, so the
|
|
655
|
+
* date naturally reflects the squash moment — which is the right answer
|
|
656
|
+
* for "when did this content last change in the published history."
|
|
657
|
+
*
|
|
658
|
+
* Returns `undefined` on every failure mode so the caller can fall back
|
|
659
|
+
* cleanly to frontmatter (or render nothing):
|
|
660
|
+
*
|
|
661
|
+
* - `git` not on PATH (CI image without git, container without it)
|
|
662
|
+
* - File isn't tracked yet (new content in a draft branch, untracked)
|
|
663
|
+
* - Repo is a shallow clone / partial clone and the file's history
|
|
664
|
+
* isn't in the local pack (Vercel default `fetch-depth: 1`,
|
|
665
|
+
* Cloudflare Pages similar). Users who want git-derived dates in
|
|
666
|
+
* production should set `fetch-depth: 0` on `actions/checkout` or
|
|
667
|
+
* equivalent.
|
|
668
|
+
* - Process isn't inside a git working tree at all
|
|
669
|
+
*
|
|
670
|
+
* Results are cached per-process. A typical docs build calls this once
|
|
671
|
+
* per entry; the cache prevents redundant subprocess spawns when the
|
|
672
|
+
* same entry's filePath shows up across multiple pages (e.g. sidebar
|
|
673
|
+
* preview, full render).
|
|
674
|
+
*/
|
|
675
|
+
const execFileAsync = promisify(execFile);
|
|
676
|
+
const cache = /* @__PURE__ */ new Map();
|
|
677
|
+
/**
|
|
678
|
+
* Run `git log -1 --format=%aI -- <filePath>` and parse the result as a
|
|
679
|
+
* `Date`. Returns `undefined` on any error or empty result.
|
|
680
|
+
*
|
|
681
|
+
* Pass either the entry's `filePath` (Astro provides this on every
|
|
682
|
+
* content entry) or an explicit relative path. Relative paths resolve
|
|
683
|
+
* against the current working directory (Astro builds run from the
|
|
684
|
+
* project root, which is inside the git repo).
|
|
685
|
+
*/
|
|
686
|
+
async function getLastUpdatedFromGit(filePath) {
|
|
687
|
+
if (!filePath) return void 0;
|
|
688
|
+
if (cache.has(filePath)) return cache.get(filePath);
|
|
689
|
+
let result;
|
|
690
|
+
try {
|
|
691
|
+
const { stdout } = await execFileAsync("git", [
|
|
692
|
+
"log",
|
|
693
|
+
"-1",
|
|
694
|
+
"--format=%aI",
|
|
695
|
+
"--",
|
|
696
|
+
filePath
|
|
697
|
+
], { windowsHide: true });
|
|
698
|
+
const trimmed = stdout.trim();
|
|
699
|
+
if (trimmed) {
|
|
700
|
+
const parsed = new Date(trimmed);
|
|
701
|
+
if (!Number.isNaN(parsed.getTime())) result = parsed;
|
|
702
|
+
}
|
|
703
|
+
} catch {
|
|
704
|
+
result = void 0;
|
|
705
|
+
}
|
|
706
|
+
cache.set(filePath, result);
|
|
707
|
+
return result;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region src/_internal/parse-components-registry.ts
|
|
712
|
+
/**
|
|
713
|
+
* Extract registered MDX global names from the user's `src/components.ts`.
|
|
714
|
+
*
|
|
715
|
+
* The framework needs this list to validate PascalCase tags in MDX at
|
|
716
|
+
* build time, but it must not execute user code at build time. Strategy:
|
|
717
|
+
* read the file as text, locate the `export const components = { ... }`
|
|
718
|
+
* declaration, and parse its top-level keys.
|
|
719
|
+
*
|
|
720
|
+
* Supported entry shapes inside the object literal:
|
|
721
|
+
* - shorthand: `Foo,` → "Foo"
|
|
722
|
+
* - aliased: `Foo: Other,` → "Foo" (the key)
|
|
723
|
+
* - string key: `"Foo": Other,` → "Foo"
|
|
724
|
+
*
|
|
725
|
+
* Skipped (no false-positive failures):
|
|
726
|
+
* - spread elements (`...other`)
|
|
727
|
+
* - computed keys (`[expr]: value`)
|
|
728
|
+
* - lowercase keys (not PascalCase, so not validator-relevant)
|
|
729
|
+
*
|
|
730
|
+
* Returns:
|
|
731
|
+
* - `string[]` of registered names when the file exists and the pattern
|
|
732
|
+
* matches.
|
|
733
|
+
* - `null` when the file is missing OR present but doesn't expose a
|
|
734
|
+
* parseable `export const components = { ... }`. The caller decides
|
|
735
|
+
* whether to warn or skip validation.
|
|
736
|
+
*/
|
|
737
|
+
const EXPORT_PATTERN = /export\s+const\s+components\s*(?::\s*[^=]+)?=\s*\{([\s\S]*?)\n\s*\}\s*(?:as\s+const)?\s*;?/;
|
|
738
|
+
async function parseComponentsRegistry(filePath) {
|
|
739
|
+
let source;
|
|
740
|
+
try {
|
|
741
|
+
source = await fs.readFile(filePath, "utf8");
|
|
742
|
+
} catch (err) {
|
|
743
|
+
if (err.code === "ENOENT") return null;
|
|
744
|
+
throw err;
|
|
745
|
+
}
|
|
746
|
+
const match = source.match(EXPORT_PATTERN);
|
|
747
|
+
if (!match) return null;
|
|
748
|
+
const body = match[1].replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
749
|
+
const names = [];
|
|
750
|
+
for (const raw of splitTopLevelCommas(body)) {
|
|
751
|
+
const entry = raw.trim();
|
|
752
|
+
if (!entry) continue;
|
|
753
|
+
if (entry.startsWith("...")) continue;
|
|
754
|
+
if (entry.startsWith("[")) continue;
|
|
755
|
+
const colonIdx = entry.indexOf(":");
|
|
756
|
+
const key = (colonIdx === -1 ? entry : entry.slice(0, colonIdx)).trim().replace(/^['"`]|['"`]$/g, "");
|
|
757
|
+
if (/^[A-Z][A-Za-z0-9_]*$/.test(key)) names.push(key);
|
|
758
|
+
}
|
|
759
|
+
return names;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Split a string on commas that are at depth 0 (not inside `{}`, `[]`,
|
|
763
|
+
* `()`, or string literals). Required because object entries can themselves
|
|
764
|
+
* contain commas (e.g. `Foo: bar({ a: 1, b: 2 })`).
|
|
765
|
+
*/
|
|
766
|
+
function splitTopLevelCommas(input) {
|
|
767
|
+
const result = [];
|
|
768
|
+
let depth = 0;
|
|
769
|
+
let start = 0;
|
|
770
|
+
let inString = null;
|
|
771
|
+
for (let i = 0; i < input.length; i++) {
|
|
772
|
+
const ch = input[i];
|
|
773
|
+
if (inString) {
|
|
774
|
+
if (ch === "\\") {
|
|
775
|
+
i++;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
if (ch === inString) inString = null;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (ch === "\"" || ch === "'" || ch === "`") inString = ch;
|
|
782
|
+
else if (ch === "{" || ch === "[" || ch === "(") depth++;
|
|
783
|
+
else if (ch === "}" || ch === "]" || ch === ")") depth--;
|
|
784
|
+
else if (ch === "," && depth === 0) {
|
|
785
|
+
result.push(input.slice(start, i));
|
|
786
|
+
start = i + 1;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
result.push(input.slice(start));
|
|
790
|
+
return result;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
//#endregion
|
|
794
|
+
//#region src/_internal/code-transformers.ts
|
|
795
|
+
/**
|
|
796
|
+
* Parse Shiki meta string (the bit after the language fence:
|
|
797
|
+
* ```ts title="src/foo.ts" {1,3}`) for the `title="..."` key.
|
|
798
|
+
* Returns `undefined` when the meta has no title.
|
|
799
|
+
*/
|
|
800
|
+
function parseTitle(meta) {
|
|
801
|
+
if (!meta) return void 0;
|
|
802
|
+
return (meta.match(/\btitle="([^"]+)"/) ?? meta.match(/\btitle='([^']+)'/))?.[1];
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* The canonical Shiki transformer chain for Nimbus. Returns a fresh
|
|
806
|
+
* array each call so callers don't accidentally mutate a shared list.
|
|
807
|
+
*
|
|
808
|
+
* Used by:
|
|
809
|
+
* - `integration.ts` → `shikiConfig.transformers` (fenced MDX blocks)
|
|
810
|
+
* - `Code.astro` in the starter → `transformers` prop on Astro's
|
|
811
|
+
* built-in `<Code>` component (and by extension, anything that
|
|
812
|
+
* composes `<Code>` such as `<CodeGroup>`)
|
|
813
|
+
*/
|
|
814
|
+
function defaultCodeTransformers() {
|
|
815
|
+
return [
|
|
816
|
+
transformerNotationDiff(),
|
|
817
|
+
transformerNotationHighlight(),
|
|
818
|
+
transformerNotationFocus(),
|
|
819
|
+
transformerNotationErrorLevel(),
|
|
820
|
+
transformerNotationWordHighlight(),
|
|
821
|
+
transformerMetaHighlight(),
|
|
822
|
+
transformerMetaWordHighlight(),
|
|
823
|
+
titleAndLangTransformer()
|
|
824
|
+
];
|
|
825
|
+
}
|
|
826
|
+
function titleAndLangTransformer() {
|
|
827
|
+
return {
|
|
828
|
+
name: "nimbus:title-and-lang",
|
|
829
|
+
pre(preNode) {
|
|
830
|
+
const lang = this.options.lang || "text";
|
|
831
|
+
const meta = this.options.meta?.__raw;
|
|
832
|
+
const title = parseTitle(meta);
|
|
833
|
+
preNode.properties = preNode.properties ?? {};
|
|
834
|
+
preNode.properties["data-nb-lang"] = lang;
|
|
835
|
+
if (!title) return preNode;
|
|
836
|
+
return {
|
|
837
|
+
type: "element",
|
|
838
|
+
tagName: "figure",
|
|
839
|
+
properties: {
|
|
840
|
+
class: "nb-code-figure",
|
|
841
|
+
"data-nb-lang": lang
|
|
842
|
+
},
|
|
843
|
+
children: [{
|
|
844
|
+
type: "element",
|
|
845
|
+
tagName: "figcaption",
|
|
846
|
+
properties: { class: "nb-code-title" },
|
|
847
|
+
children: [{
|
|
848
|
+
type: "element",
|
|
849
|
+
tagName: "span",
|
|
850
|
+
properties: { class: "nb-code-title-name" },
|
|
851
|
+
children: [{
|
|
852
|
+
type: "text",
|
|
853
|
+
value: title
|
|
854
|
+
}]
|
|
855
|
+
}, {
|
|
856
|
+
type: "element",
|
|
857
|
+
tagName: "span",
|
|
858
|
+
properties: { class: "nb-code-title-lang" },
|
|
859
|
+
children: [{
|
|
860
|
+
type: "text",
|
|
861
|
+
value: lang
|
|
862
|
+
}]
|
|
863
|
+
}]
|
|
864
|
+
}, preNode]
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
//#endregion
|
|
871
|
+
//#region src/_internal/levenshtein.ts
|
|
872
|
+
/**
|
|
873
|
+
* Tiny Levenshtein distance + "did you mean" suggester.
|
|
874
|
+
*
|
|
875
|
+
* Used by the MDX PascalCase validator and any framework diagnostic that
|
|
876
|
+
* wants to suggest a near-match on a misspelled name. Kept internal — user
|
|
877
|
+
* code that wants the same hint duplicates ~10 lines rather than depending
|
|
878
|
+
* on a framework wrapper. See the north-star guardrail on thin wrappers.
|
|
879
|
+
*/
|
|
880
|
+
function levenshtein(a, b) {
|
|
881
|
+
if (a === b) return 0;
|
|
882
|
+
if (a.length === 0) return b.length;
|
|
883
|
+
if (b.length === 0) return a.length;
|
|
884
|
+
const v0 = new Array(b.length + 1);
|
|
885
|
+
const v1 = new Array(b.length + 1);
|
|
886
|
+
for (let i = 0; i <= b.length; i++) v0[i] = i;
|
|
887
|
+
for (let i = 0; i < a.length; i++) {
|
|
888
|
+
v1[0] = i + 1;
|
|
889
|
+
for (let j = 0; j < b.length; j++) {
|
|
890
|
+
const cost = a[i] === b[j] ? 0 : 1;
|
|
891
|
+
v1[j + 1] = Math.min(v1[j] + 1, v0[j + 1] + 1, v0[j] + cost);
|
|
892
|
+
}
|
|
893
|
+
for (let j = 0; j <= b.length; j++) v0[j] = v1[j];
|
|
894
|
+
}
|
|
895
|
+
return v1[b.length];
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Return the closest candidate within `maxDist`, or null.
|
|
899
|
+
*
|
|
900
|
+
* Comparison is case-insensitive (so "tabs" suggests "Tabs"), but the
|
|
901
|
+
* returned name keeps its original casing.
|
|
902
|
+
*/
|
|
903
|
+
function suggest(target, candidates, maxDist = 3) {
|
|
904
|
+
const targetLower = target.toLowerCase();
|
|
905
|
+
let best = null;
|
|
906
|
+
for (const c of candidates) {
|
|
907
|
+
const dist = levenshtein(targetLower, c.toLowerCase());
|
|
908
|
+
if (dist <= maxDist && (!best || dist < best.dist)) best = {
|
|
909
|
+
name: c,
|
|
910
|
+
dist
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
return best?.name ?? null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/_internal/validate-mdx-content.ts
|
|
918
|
+
/**
|
|
919
|
+
* MDX PascalCase tag validator — runs as a content pass, not a remark
|
|
920
|
+
* plugin, so it works regardless of which markdown processor the user
|
|
921
|
+
* has wired into `markdown.processor` (Sätteri replaces unified's
|
|
922
|
+
* pipeline, which silently disables remark plugins attached via
|
|
923
|
+
* `mdx({ remarkPlugins })`).
|
|
924
|
+
*
|
|
925
|
+
* Strategy:
|
|
926
|
+
*
|
|
927
|
+
* 1. Walk the configured content directories for `.mdx` files.
|
|
928
|
+
* 2. For each file: split frontmatter, parse imports + JSX tags from
|
|
929
|
+
* the body, validate every PascalCase tag against globals + per-file
|
|
930
|
+
* imports.
|
|
931
|
+
* 3. Collect every failure across every file (don't fail-fast), then
|
|
932
|
+
* throw one error with all locations and "did you mean" hints.
|
|
933
|
+
*
|
|
934
|
+
* Parsing approach is intentionally regex-based and not a full MDX
|
|
935
|
+
* parser. Tradeoffs:
|
|
936
|
+
*
|
|
937
|
+
* - Pro: zero MDX/remark deps, runs in milliseconds, no pipeline
|
|
938
|
+
* coupling. Survives processor swaps (satteri / unified / future).
|
|
939
|
+
* - Pro: tolerates malformed MDX — the validator's job is to find
|
|
940
|
+
* unknown tags, not to be the parser of record.
|
|
941
|
+
* - Con: a few edge cases (JSX inside string literals inside expression
|
|
942
|
+
* children, deeply nested fenced code with `~~~`) can produce false
|
|
943
|
+
* positives. Code blocks (``` and indented) are stripped before
|
|
944
|
+
* scanning to keep the common case clean.
|
|
945
|
+
*
|
|
946
|
+
* Catches the silent-failure case where MDX renders unknown PascalCase
|
|
947
|
+
* tags as literal text on the deployed page — the bug appears in
|
|
948
|
+
* production, not in the build log.
|
|
949
|
+
*/
|
|
950
|
+
async function validateMdxContent(options) {
|
|
951
|
+
const globalsSet = new Set(options.globals);
|
|
952
|
+
const failures = [];
|
|
953
|
+
for (const dir of options.contentDirs) {
|
|
954
|
+
const files = await walkMdx(dir);
|
|
955
|
+
for (const file of files) {
|
|
956
|
+
if (options.skip?.(file)) continue;
|
|
957
|
+
const fileFailures = scanFile(await fs.readFile(file, "utf8"), globalsSet);
|
|
958
|
+
for (const f of fileFailures) {
|
|
959
|
+
const knownNames = [...globalsSet, ...f.imports];
|
|
960
|
+
failures.push({
|
|
961
|
+
filePath: options.projectRoot ? path.relative(options.projectRoot, file) : file,
|
|
962
|
+
tag: f.tag,
|
|
963
|
+
line: f.line,
|
|
964
|
+
column: f.column,
|
|
965
|
+
hint: suggest(f.tag, knownNames)
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return failures;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Format a list of failures into a single multi-line error message
|
|
974
|
+
* suitable for `throw new Error(...)`.
|
|
975
|
+
*/
|
|
976
|
+
function formatFailures(failures, globalsCount) {
|
|
977
|
+
const lines = failures.map((f) => {
|
|
978
|
+
const fix = f.hint ? `Did you mean <${f.hint} />?` : globalsCount === 0 ? `Register it in src/components.ts, or add an explicit \`import\` at the top of this file.` : `Register it in src/components.ts, or add an explicit \`import\` at the top of this file.`;
|
|
979
|
+
return ` ${f.filePath}:${f.line}:${f.column} <${f.tag} /> → ${fix}`;
|
|
980
|
+
});
|
|
981
|
+
return `[nimbus-docs] Unknown MDX component ${failures.length === 1 ? "tag" : "tags"}:\n` + lines.join("\n") + "\n\nA PascalCase tag in MDX must either be registered in src/components.ts (the global registry) or imported at the top of the file. Without either, MDX renders the tag as literal text on the page — a silent failure this validator turns into a build error.";
|
|
982
|
+
}
|
|
983
|
+
async function walkMdx(dir) {
|
|
984
|
+
const out = [];
|
|
985
|
+
async function visit(current) {
|
|
986
|
+
let entries;
|
|
987
|
+
try {
|
|
988
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
989
|
+
} catch (err) {
|
|
990
|
+
if (err.code === "ENOENT") return;
|
|
991
|
+
throw err;
|
|
992
|
+
}
|
|
993
|
+
for (const entry of entries) {
|
|
994
|
+
const full = path.join(current, entry.name);
|
|
995
|
+
if (entry.isDirectory()) {
|
|
996
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
997
|
+
await visit(full);
|
|
998
|
+
} else if (entry.isFile() && entry.name.endsWith(".mdx")) out.push(full);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
await visit(dir);
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
function scanFile(source, globalsSet) {
|
|
1005
|
+
const { body, bodyOffset } = stripFrontmatter(source);
|
|
1006
|
+
const imports = parseImports(body);
|
|
1007
|
+
const tags = findPascalCaseTags(stripCodeBlocks(body));
|
|
1008
|
+
const failures = [];
|
|
1009
|
+
for (const tag of tags) {
|
|
1010
|
+
if (globalsSet.has(tag.name) || imports.has(tag.name)) continue;
|
|
1011
|
+
const position = absolutePosition(source, bodyOffset + tag.offset);
|
|
1012
|
+
failures.push({
|
|
1013
|
+
tag: tag.name,
|
|
1014
|
+
line: position.line,
|
|
1015
|
+
column: position.column,
|
|
1016
|
+
imports
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
return failures;
|
|
1020
|
+
}
|
|
1021
|
+
function stripFrontmatter(source) {
|
|
1022
|
+
const match = source.match(/^---\n[\s\S]*?\n---\n?/);
|
|
1023
|
+
if (!match) return {
|
|
1024
|
+
body: source,
|
|
1025
|
+
bodyOffset: 0
|
|
1026
|
+
};
|
|
1027
|
+
return {
|
|
1028
|
+
body: source.slice(match[0].length),
|
|
1029
|
+
bodyOffset: match[0].length
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Extract names introduced by top-level `import` statements. Handles
|
|
1034
|
+
* default, named (with optional aliases), and namespace imports.
|
|
1035
|
+
*/
|
|
1036
|
+
function parseImports(body) {
|
|
1037
|
+
const names = /* @__PURE__ */ new Set();
|
|
1038
|
+
for (const match of body.matchAll(/^\s*import\s+([^"';]+?)\s+from\s+["'][^"']+["']\s*;?/gm)) {
|
|
1039
|
+
const clause = match[1];
|
|
1040
|
+
const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
1041
|
+
if (namespaceMatch) {
|
|
1042
|
+
names.add(namespaceMatch[1]);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const beforeBrace = clause.split("{")[0].trim().replace(/,\s*$/, "");
|
|
1046
|
+
if (beforeBrace && /^[A-Za-z_$][\w$]*$/.test(beforeBrace)) names.add(beforeBrace);
|
|
1047
|
+
const braceMatch = clause.match(/\{([^}]*)\}/);
|
|
1048
|
+
if (braceMatch) for (const raw of braceMatch[1].split(",")) {
|
|
1049
|
+
const spec = raw.trim();
|
|
1050
|
+
if (!spec) continue;
|
|
1051
|
+
const aliasMatch = spec.match(/^[A-Za-z_$][\w$]*\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
1052
|
+
if (aliasMatch) names.add(aliasMatch[1]);
|
|
1053
|
+
else if (/^[A-Za-z_$][\w$]*$/.test(spec)) names.add(spec);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return names;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Remove fenced code blocks and inline code spans so JSX-looking text
|
|
1060
|
+
* inside code samples doesn't trip the validator.
|
|
1061
|
+
*/
|
|
1062
|
+
function stripCodeBlocks(body) {
|
|
1063
|
+
return body.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length)).replace(/~~~[\s\S]*?~~~/g, (m) => " ".repeat(m.length)).replace(/`[^`\n]*`/g, (m) => " ".repeat(m.length));
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Find PascalCase JSX-like tags. Matches `<Capital...` at the start of
|
|
1067
|
+
* an element (opening or self-closing). Closing tags `</Capital>` and
|
|
1068
|
+
* JSX fragments `<>` are not counted (the opener already covers
|
|
1069
|
+
* registration; counting closers would double-report).
|
|
1070
|
+
*/
|
|
1071
|
+
function findPascalCaseTags(body) {
|
|
1072
|
+
const out = [];
|
|
1073
|
+
for (const match of body.matchAll(/<([A-Z][A-Za-z0-9_]*)\b/g)) out.push({
|
|
1074
|
+
name: match[1],
|
|
1075
|
+
offset: match.index ?? 0
|
|
1076
|
+
});
|
|
1077
|
+
return out;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Compute 1-based line + column for an absolute character offset in the
|
|
1081
|
+
* original source.
|
|
1082
|
+
*/
|
|
1083
|
+
function absolutePosition(source, offset) {
|
|
1084
|
+
let line = 1;
|
|
1085
|
+
let column = 1;
|
|
1086
|
+
const end = Math.min(offset, source.length);
|
|
1087
|
+
for (let i = 0; i < end; i++) if (source[i] === "\n") {
|
|
1088
|
+
line++;
|
|
1089
|
+
column = 1;
|
|
1090
|
+
} else column++;
|
|
1091
|
+
return {
|
|
1092
|
+
line,
|
|
1093
|
+
column
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region src/_internal/validate.ts
|
|
1099
|
+
/**
|
|
1100
|
+
* Config validation.
|
|
1101
|
+
*
|
|
1102
|
+
* Errors target content authors, not framework developers.
|
|
1103
|
+
* Astro 6 ships Zod v4 via `astro/zod` — single `error` field, not v3 patterns.
|
|
1104
|
+
*/
|
|
1105
|
+
const headElementSchema = z.object({
|
|
1106
|
+
tag: z.enum([
|
|
1107
|
+
"meta",
|
|
1108
|
+
"link",
|
|
1109
|
+
"script",
|
|
1110
|
+
"style"
|
|
1111
|
+
]),
|
|
1112
|
+
attrs: z.record(z.string(), z.string()).default({}),
|
|
1113
|
+
content: z.string().optional()
|
|
1114
|
+
});
|
|
1115
|
+
const featuresSchema = z.object({
|
|
1116
|
+
search: z.boolean().default(true),
|
|
1117
|
+
editLinks: z.boolean().default(true),
|
|
1118
|
+
pagination: z.boolean().default(true),
|
|
1119
|
+
toc: z.boolean().default(true)
|
|
1120
|
+
}).default({
|
|
1121
|
+
search: true,
|
|
1122
|
+
editLinks: true,
|
|
1123
|
+
pagination: true,
|
|
1124
|
+
toc: true
|
|
1125
|
+
});
|
|
1126
|
+
const searchSchema = z.union([z.literal(false), z.object({ provider: z.enum(["pagefind", "custom"]).default("pagefind") })]).optional();
|
|
1127
|
+
const sidebarSchema = z.object({ items: z.array(z.unknown()).optional() }).passthrough().optional();
|
|
1128
|
+
const nimbusConfigSchema = z.object({
|
|
1129
|
+
site: z.string().url({ message: "\"site\" must be a valid URL" }),
|
|
1130
|
+
title: z.string(),
|
|
1131
|
+
description: z.string().optional(),
|
|
1132
|
+
logo: z.string().max(2),
|
|
1133
|
+
locale: z.string().default("en"),
|
|
1134
|
+
homeLabel: z.string().default("Home"),
|
|
1135
|
+
github: z.string().url().nullable().default(null),
|
|
1136
|
+
editPattern: z.string().nullable().default(null).refine((v) => v === null || v.includes("{path}"), { message: "\"editPattern\" must contain the \"{path}\" placeholder, which is replaced with the entry source path. Example: \"https://github.com/my-org/my-repo/edit/main/{path}\"" }),
|
|
1137
|
+
footer: z.string().default("Built with Nimbus"),
|
|
1138
|
+
socialImage: z.string({ error: "\"socialImage\" must be a string (path or URL)" }).optional(),
|
|
1139
|
+
socialImageAlt: z.string({ error: "\"socialImageAlt\" must be a string" }).optional(),
|
|
1140
|
+
head: z.array(headElementSchema).default([]),
|
|
1141
|
+
sidebar: sidebarSchema,
|
|
1142
|
+
features: featuresSchema,
|
|
1143
|
+
search: searchSchema
|
|
1144
|
+
});
|
|
1145
|
+
function validateNimbusConfig(input) {
|
|
1146
|
+
const result = nimbusConfigSchema.safeParse(input);
|
|
1147
|
+
if (result.success) return result.data;
|
|
1148
|
+
const issues = result.error.issues.map((issue) => {
|
|
1149
|
+
const issuePath = issue.path.filter((p) => typeof p !== "symbol");
|
|
1150
|
+
const display = issuePath.length > 0 ? issuePath.join(".") : "(root)";
|
|
1151
|
+
const received = formatReceived(input, issuePath);
|
|
1152
|
+
const tail = received === null ? "" : `\n received: ${received}`;
|
|
1153
|
+
return ` - ${display}: ${issue.message}${tail}`;
|
|
1154
|
+
}).join("\n");
|
|
1155
|
+
throw new Error(`Invalid nimbus.config — fix these issues:\n${issues}\n\nSee https://nimbus-docs.dev/config for the full config schema.`);
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Resolve the value at `path` inside the raw input and format it for an
|
|
1159
|
+
* error message. Returns null when the path is unreachable (e.g. a
|
|
1160
|
+
* required key is missing entirely — in that case the message itself
|
|
1161
|
+
* already says "Required", so we don't double up).
|
|
1162
|
+
*/
|
|
1163
|
+
function formatReceived(input, path) {
|
|
1164
|
+
let cursor = input;
|
|
1165
|
+
for (const key of path) {
|
|
1166
|
+
if (cursor === null || typeof cursor !== "object") return null;
|
|
1167
|
+
cursor = cursor[key];
|
|
1168
|
+
if (cursor === void 0) return null;
|
|
1169
|
+
}
|
|
1170
|
+
if (cursor === void 0) return null;
|
|
1171
|
+
try {
|
|
1172
|
+
const json = JSON.stringify(cursor);
|
|
1173
|
+
if (json === void 0) return String(cursor);
|
|
1174
|
+
return json.length > 120 ? `${json.slice(0, 117)}...` : json;
|
|
1175
|
+
} catch {
|
|
1176
|
+
return String(cursor);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
//#endregion
|
|
1181
|
+
//#region src/_internal/virtual-config.ts
|
|
1182
|
+
const VIRTUAL_ID = "virtual:nimbus/config";
|
|
1183
|
+
const RESOLVED_ID = `\0${VIRTUAL_ID}`;
|
|
1184
|
+
function virtualConfigPlugin(config) {
|
|
1185
|
+
return {
|
|
1186
|
+
name: "nimbus-docs:virtual-config",
|
|
1187
|
+
resolveId(id) {
|
|
1188
|
+
if (id === VIRTUAL_ID) return RESOLVED_ID;
|
|
1189
|
+
},
|
|
1190
|
+
load(id) {
|
|
1191
|
+
if (id === RESOLVED_ID) return `export const config = ${JSON.stringify(config)};\n`;
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
//#endregion
|
|
1197
|
+
//#region src/integration.ts
|
|
1198
|
+
/**
|
|
1199
|
+
* The Nimbus Astro integration.
|
|
1200
|
+
*
|
|
1201
|
+
* Responsibilities:
|
|
1202
|
+
* - Validate the user-supplied config (throws on invalid input).
|
|
1203
|
+
* - Bridge `nimbusConfig.site` → Astro's top-level `site` so the
|
|
1204
|
+
* sitemap integration and `Astro.site` read from one source.
|
|
1205
|
+
* - Register `@astrojs/mdx` and `@astrojs/sitemap`.
|
|
1206
|
+
* - Install the Sätteri markdown processor — handles heading slugs +
|
|
1207
|
+
* ships with built-in Shiki dual-theme highlighting (configured via
|
|
1208
|
+
* Astro's `markdown.shikiConfig`).
|
|
1209
|
+
* - Build-time MDX PascalCase tag validation against the user's
|
|
1210
|
+
* `src/components.ts` registry plus per-file imports. Catches the
|
|
1211
|
+
* silent-failure case where MDX renders an unknown PascalCase tag
|
|
1212
|
+
* as literal text on the deployed site. Opt out via
|
|
1213
|
+
* `validateMdx: false`.
|
|
1214
|
+
* - Expose validated config via `virtual:nimbus/config`.
|
|
1215
|
+
* - Inject TypeScript types for the virtual module so consumers get
|
|
1216
|
+
* intellisense without manual ambient declarations.
|
|
1217
|
+
*
|
|
1218
|
+
* Not framework territory (the user's `content.config.ts` owns these):
|
|
1219
|
+
* - Content collection registration. The user imports
|
|
1220
|
+
* `docsCollection()` / `partialsCollection()` from
|
|
1221
|
+
* `nimbus-docs/content` and registers them themselves.
|
|
1222
|
+
* - MDX globals injection. The user passes `components={components}`
|
|
1223
|
+
* when rendering `<Content />`.
|
|
1224
|
+
*
|
|
1225
|
+
* Planned (not shipped):
|
|
1226
|
+
* - `/llms.txt` and `/robots.txt` route injection.
|
|
1227
|
+
*/
|
|
1228
|
+
function nimbus(rawConfig, options = {}) {
|
|
1229
|
+
const config = validateNimbusConfig(rawConfig);
|
|
1230
|
+
return {
|
|
1231
|
+
name: "nimbus-docs",
|
|
1232
|
+
hooks: {
|
|
1233
|
+
"astro:config:setup": async (params) => {
|
|
1234
|
+
const { updateConfig, config: astroConfig, logger } = params;
|
|
1235
|
+
const integrationsToAdd = [];
|
|
1236
|
+
if (options.validateMdx !== false) {
|
|
1237
|
+
const validateOpts = typeof options.validateMdx === "object" ? options.validateMdx : {};
|
|
1238
|
+
const projectRoot = fileURLToPath(astroConfig.root);
|
|
1239
|
+
const componentsPath = path.isAbsolute(validateOpts.componentsPath ?? "") ? validateOpts.componentsPath : path.join(projectRoot, validateOpts.componentsPath ?? "src/components.ts");
|
|
1240
|
+
const globals = await parseComponentsRegistry(componentsPath);
|
|
1241
|
+
if (globals === null) logger.warn(`MDX validation disabled: \`${path.relative(projectRoot, componentsPath)}\` is missing or does not export a parseable \`components\` object. Create the file with \`export const components = { /* ... */ };\` or set \`validateMdx: false\` to silence this warning.`);
|
|
1242
|
+
else {
|
|
1243
|
+
const contentDirs = (validateOpts.contentDirs ?? ["src/content"]).map((d) => path.isAbsolute(d) ? d : path.join(projectRoot, d));
|
|
1244
|
+
const failures = await validateMdxContent({
|
|
1245
|
+
globals,
|
|
1246
|
+
contentDirs,
|
|
1247
|
+
skip: validateOpts.skip,
|
|
1248
|
+
projectRoot
|
|
1249
|
+
});
|
|
1250
|
+
if (failures.length > 0) throw new Error(formatFailures(failures, globals.length));
|
|
1251
|
+
logger.info(`MDX validation passed — ${globals.length} global component${globals.length === 1 ? "" : "s"} registered, ${contentDirs.length} content dir${contentDirs.length === 1 ? "" : "s"} scanned.`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
integrationsToAdd.push(mdx(options.mdx ?? {}));
|
|
1255
|
+
if (options.sitemap !== false && Boolean(config.site)) integrationsToAdd.push(sitemap());
|
|
1256
|
+
updateConfig({
|
|
1257
|
+
...config.site ? { site: config.site } : {},
|
|
1258
|
+
integrations: integrationsToAdd,
|
|
1259
|
+
markdown: {
|
|
1260
|
+
processor: satteri(),
|
|
1261
|
+
shikiConfig: {
|
|
1262
|
+
themes: {
|
|
1263
|
+
light: "github-light",
|
|
1264
|
+
dark: "github-dark"
|
|
1265
|
+
},
|
|
1266
|
+
defaultColor: false,
|
|
1267
|
+
transformers: defaultCodeTransformers()
|
|
1268
|
+
}
|
|
1269
|
+
},
|
|
1270
|
+
vite: { plugins: [virtualConfigPlugin(config)] }
|
|
1271
|
+
});
|
|
1272
|
+
},
|
|
1273
|
+
"astro:config:done": ({ injectTypes }) => {
|
|
1274
|
+
injectTypes({
|
|
1275
|
+
filename: "virtual-config.d.ts",
|
|
1276
|
+
content: [
|
|
1277
|
+
"declare module \"virtual:nimbus/config\" {",
|
|
1278
|
+
" import type { NimbusConfig } from \"nimbus-docs/types\";",
|
|
1279
|
+
" export const config: NimbusConfig;",
|
|
1280
|
+
"}",
|
|
1281
|
+
""
|
|
1282
|
+
].join("\n")
|
|
1283
|
+
});
|
|
1284
|
+
},
|
|
1285
|
+
"astro:build:done": async ({ dir }) => {
|
|
1286
|
+
if (config.search === false || config.search?.provider === "custom") return;
|
|
1287
|
+
await runPagefind(fileURLToPath(dir));
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
function runPagefind(siteDir) {
|
|
1293
|
+
const bin = process.platform === "win32" ? "pagefind.cmd" : "pagefind";
|
|
1294
|
+
return new Promise((resolve) => {
|
|
1295
|
+
execFile(bin, ["--site", siteDir], (error, stdout, stderr) => {
|
|
1296
|
+
if (stdout) process.stdout.write(stdout);
|
|
1297
|
+
if (stderr) process.stderr.write(stderr);
|
|
1298
|
+
if (error) console.warn(`[nimbus-docs] Pagefind did not run. Install pagefind as a devDependency or set search: false in your Nimbus config.\n${error.message}`);
|
|
1299
|
+
resolve();
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/index.ts
|
|
1306
|
+
/**
|
|
1307
|
+
* Main entry for `nimbus-docs`.
|
|
1308
|
+
*
|
|
1309
|
+
* Exports the Astro integration (default), config helper, and the four
|
|
1310
|
+
* data helpers (sidebar, prev/next, breadcrumbs, TOC). Phase 6 will
|
|
1311
|
+
* add page composition helpers (`getDocsStaticPaths`, `getDocsPageProps`).
|
|
1312
|
+
*
|
|
1313
|
+
* Helpers read the user's config from `virtual:nimbus/config` (provided
|
|
1314
|
+
* by our Vite plugin) and content entries from `astro:content`. Both
|
|
1315
|
+
* are external in tsdown and resolved at the consumer's build time.
|
|
1316
|
+
*/
|
|
1317
|
+
/** Primary collection name — kept in sync with `_internal/content.ts`. */
|
|
1318
|
+
const PRIMARY_COLLECTION = "docs";
|
|
1319
|
+
/**
|
|
1320
|
+
* Define a typed Nimbus config. Returns the config unchanged but inferred.
|
|
1321
|
+
*/
|
|
1322
|
+
function defineConfig(config) {
|
|
1323
|
+
return config;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Build the sidebar tree for the given current path, scoped to the
|
|
1327
|
+
* top-level section containing that page.
|
|
1328
|
+
*
|
|
1329
|
+
* Reads `sidebar` from the user's nimbus.config. If `sidebar.items` is set,
|
|
1330
|
+
* resolves config-driven sidebar. Otherwise auto-generates from filesystem
|
|
1331
|
+
* (i.e. the `docs` collection's entry IDs).
|
|
1332
|
+
*
|
|
1333
|
+
* The returned tree is always scoped: only the current section's children
|
|
1334
|
+
* are returned. To enumerate every top-level section (for header tabs or
|
|
1335
|
+
* a section switcher), use `getSidebarSections`.
|
|
1336
|
+
*
|
|
1337
|
+
* @param currentSlug - The current page's URL path (e.g. "/getting-started").
|
|
1338
|
+
* Used to set `isCurrent` on matching links and to pick
|
|
1339
|
+
* which top-level section to surface.
|
|
1340
|
+
*/
|
|
1341
|
+
async function getSidebar(currentSlug) {
|
|
1342
|
+
return scopeToCurrentSection(await buildFullSidebarTree(currentSlug), currentSlug);
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Derive one section per top-level group in the sidebar — used by
|
|
1346
|
+
* `Header.astro` to render the section tab strip (and by any other
|
|
1347
|
+
* cross-section navigation).
|
|
1348
|
+
*
|
|
1349
|
+
* Reads the un-scoped tree so every section is visible, then collapses
|
|
1350
|
+
* each top-level group to `{ label, href, isActive }`.
|
|
1351
|
+
*/
|
|
1352
|
+
async function getSidebarSections(currentSlug) {
|
|
1353
|
+
return deriveSidebarSections(await buildFullSidebarTree(currentSlug));
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Internal: build the un-scoped sidebar tree. Shared by `getSidebar` and
|
|
1357
|
+
* `getSidebarSections`.
|
|
1358
|
+
*/
|
|
1359
|
+
async function buildFullSidebarTree(currentSlug) {
|
|
1360
|
+
const runtimeConfig = await loadNimbusConfig();
|
|
1361
|
+
return buildSidebarTree(await getVisibleEntriesByCollection([PRIMARY_COLLECTION, ...collectSidebarCollectionRefs(runtimeConfig.sidebar?.items).filter((c) => c !== PRIMARY_COLLECTION)]), PRIMARY_COLLECTION, currentSlug, runtimeConfig.sidebar);
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Resolve prev/next links for the current page.
|
|
1365
|
+
*
|
|
1366
|
+
* Walks the flattened sidebar; returns the surrounding entries. Honors
|
|
1367
|
+
* `prev`/`next` frontmatter overrides if provided.
|
|
1368
|
+
*/
|
|
1369
|
+
async function getPrevNext(currentSlug, options) {
|
|
1370
|
+
return getPrevNext$1(currentSlug, options?.sidebarTree ?? await getSidebar(currentSlug), options?.overrides);
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Build breadcrumb trail from "/" to the current page.
|
|
1374
|
+
*
|
|
1375
|
+
* Phase 5: simple URL-segment derivation. Later phases may enrich with
|
|
1376
|
+
* sidebar-aware labels.
|
|
1377
|
+
*/
|
|
1378
|
+
async function getBreadcrumbs(currentSlug, options) {
|
|
1379
|
+
return getBreadcrumbs$1(currentSlug, options?.homeLabel ?? "Home");
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Build an edit URL for a content entry using `config.editPattern`.
|
|
1383
|
+
*
|
|
1384
|
+
* `{path}` is replaced with the entry's source path when Astro provides it,
|
|
1385
|
+
* falling back to the default docs collection path convention.
|
|
1386
|
+
*/
|
|
1387
|
+
async function getEditUrl(entry) {
|
|
1388
|
+
const runtimeConfig = await loadNimbusConfig();
|
|
1389
|
+
if (!runtimeConfig.editPattern) return void 0;
|
|
1390
|
+
const path = entry.filePath ?? `src/content/docs/${entry.id}.mdx`;
|
|
1391
|
+
return runtimeConfig.editPattern.replace("{path}", path);
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Resolve a content entry's `lastUpdated` date from `git log`.
|
|
1395
|
+
*
|
|
1396
|
+
* Reads the author date (`%aI`) of the most recent commit that touched
|
|
1397
|
+
* the entry's source file. Author date is stable across rebases — the
|
|
1398
|
+
* value reflects when the content was actually changed, not when the
|
|
1399
|
+
* commit happened to land in this branch.
|
|
1400
|
+
*
|
|
1401
|
+
* Returns `undefined` when git can't answer (no `.git`, shallow clone,
|
|
1402
|
+
* file untracked, command not on PATH, etc.) so the caller can chain a
|
|
1403
|
+
* fallback:
|
|
1404
|
+
*
|
|
1405
|
+
* const lastUpdated = entry.data.lastUpdated ?? await getLastUpdated(entry);
|
|
1406
|
+
*
|
|
1407
|
+
* Frontmatter always wins. Per-process cached so repeated calls for
|
|
1408
|
+
* the same entry don't re-spawn `git`.
|
|
1409
|
+
*
|
|
1410
|
+
* Production note: most CI/CD systems do shallow clones by default
|
|
1411
|
+
* (Vercel, Cloudflare Pages, GitHub Actions checkout@v4) — set
|
|
1412
|
+
* `fetch-depth: 0` to make full history available, otherwise git
|
|
1413
|
+
* returns nothing and the helper falls back to frontmatter or nothing.
|
|
1414
|
+
*/
|
|
1415
|
+
async function getLastUpdated(entry) {
|
|
1416
|
+
return getLastUpdatedFromGit(entry.filePath ?? `src/content/docs/${entry.id}.mdx`);
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Filter heading list to the configured min/max heading levels.
|
|
1420
|
+
*
|
|
1421
|
+
* @param headings - Raw `headings` from Astro's `render(entry)` return value.
|
|
1422
|
+
* @param options - Override min/max heading levels. Defaults: min=2, max=3.
|
|
1423
|
+
*/
|
|
1424
|
+
function getTOC(headings, options) {
|
|
1425
|
+
return getHeadings(headings, options);
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* `getStaticPaths` implementation for a docs catch-all route.
|
|
1429
|
+
*
|
|
1430
|
+
* Returns one path per visible entry in the `docs` collection. Drafts are
|
|
1431
|
+
* filtered in production. Each path passes `{ entry }` as props so the
|
|
1432
|
+
* page component can access it via `getDocsPageProps(Astro)`.
|
|
1433
|
+
*
|
|
1434
|
+
* Usage:
|
|
1435
|
+
*
|
|
1436
|
+
* // src/pages/[...slug].astro
|
|
1437
|
+
* export const prerender = true;
|
|
1438
|
+
* export const getStaticPaths = getDocsStaticPaths;
|
|
1439
|
+
*
|
|
1440
|
+
* The entry's `id` is used verbatim as the slug. So `docs/index.mdx` →
|
|
1441
|
+
* `/index`, `docs/guides/setup.mdx` → `/guides/setup`. If you want a docs
|
|
1442
|
+
* entry at the root URL, name it appropriately and decide whether to use
|
|
1443
|
+
* a static `pages/index.astro` or let the catch-all handle root.
|
|
1444
|
+
*/
|
|
1445
|
+
const getDocsStaticPaths = async () => {
|
|
1446
|
+
return (await getVisibleEntries(["docs"])).map((entry) => ({
|
|
1447
|
+
params: { slug: entry.id },
|
|
1448
|
+
props: { entry }
|
|
1449
|
+
}));
|
|
1450
|
+
};
|
|
1451
|
+
/**
|
|
1452
|
+
* Read the current entry from `Astro.props`, render it, and return the
|
|
1453
|
+
* pieces a docs page needs: the typed entry, the renderable `<Content />`
|
|
1454
|
+
* component, and the headings list (for TOC generation).
|
|
1455
|
+
*
|
|
1456
|
+
* Pass the page's `Astro` global. Throws if `Astro.props.entry` is missing,
|
|
1457
|
+
* which indicates the page didn't wire `getDocsStaticPaths` (or a custom
|
|
1458
|
+
* equivalent) correctly.
|
|
1459
|
+
*
|
|
1460
|
+
* Usage:
|
|
1461
|
+
*
|
|
1462
|
+
* const { entry, Content, headings } = await getDocsPageProps(Astro);
|
|
1463
|
+
*/
|
|
1464
|
+
async function getDocsPageProps(astro) {
|
|
1465
|
+
const entry = astro.props.entry;
|
|
1466
|
+
if (!entry) throw new Error("getDocsPageProps(): expected `entry` in Astro.props. Ensure your route uses `getStaticPaths = getDocsStaticPaths` (or passes an entry via custom getStaticPaths).");
|
|
1467
|
+
const { render } = await import("astro:content");
|
|
1468
|
+
const { Content, headings } = await render(entry);
|
|
1469
|
+
return {
|
|
1470
|
+
entry,
|
|
1471
|
+
Content,
|
|
1472
|
+
headings
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
//#endregion
|
|
1477
|
+
export { nimbus as default, defaultCodeTransformers, defineConfig, getBreadcrumbs, getDocsPageProps, getDocsStaticPaths, getEditUrl, getLastUpdated, getPrevNext, getSidebar, getSidebarSections, getTOC, getVisibleEntries, renderEntryAsMarkdown, sidebarHash };
|
|
1478
|
+
//# sourceMappingURL=index.js.map
|