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/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