nimbus-docs 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { o as validateLintOptions, r as suggest, t as IMPLEMENTED_CODES } from "./rules-DDDvKkyJ.js";
1
+ import { o as validateLintOptions, r as suggest, t as IMPLEMENTED_CODES } from "./rules-CzB-afEb.js";
2
2
  import { i as toRouteKey, n as isAbsoluteUrl, r as toBrowserHref, t as withStrictKeys } from "./strict-keys-fbKKxxKL.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { execFile } from "node:child_process";
@@ -11,7 +11,7 @@ import { satteri } from "@astrojs/markdown-satteri";
11
11
  import sitemap from "@astrojs/sitemap";
12
12
  import fs$1, { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
13
13
  import { z } from "astro/zod";
14
- import { transformerMetaHighlight, transformerMetaWordHighlight, transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from "@shikijs/transformers";
14
+ import { transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight } from "@shikijs/transformers";
15
15
  import { createHash } from "node:crypto";
16
16
 
17
17
  //#region src/_internal/runtime-config.ts
@@ -118,28 +118,16 @@ function slug(value, maintainCase) {
118
118
  * Mirror of Astro's content-layer slug normalization, used by every
119
119
  * framework URL builder that derives a URL from an `entry.id`.
120
120
  *
121
- * Astro's `glob` content loader (used by Nimbus's `docsCollection` /
122
- * `partialsCollection` factories) runs each path segment through
121
+ * Astro's `glob` content loader runs each path segment through
123
122
  * `github-slugger.slug()` and strips a trailing `/index`. That output is
124
- * what `entry.id` becomes inside `getCollection`, and it's what `params.slug`
125
- * substitutes into `[...slug].astro` routes. Anything that constructs a
126
- * URL from the raw filesystem path *must* apply the same normalization or
127
- * the URL won't match what Astro actually serves.
128
- *
129
- * Why mirror instead of asking Astro: framework URL builders run at page
130
- * render time (sidebar hrefs) and during the integration's
131
- * `astro:config:setup` (sitemap, llms.txt). At neither point do we have a
132
- * clean way to read Astro's resolved routes. The honest architectural fix
133
- * is to refactor those builders to consume the route manifest at
134
- * `astro:routes:resolved` (sitemap/llms) or via a build-emitted lookup
135
- * table (sidebar). Until that work lands, this helper keeps the URLs
136
- * correct.
137
- *
138
- * Mirror caveat: this matches `github-slugger.slug()` and the trailing-
139
- * /index strip — the documented public-library behaviors Astro inherits
140
- * — not Astro's private routing internals. If a user supplies a custom
141
- * `generateId` to the content loader, or a `data.slug` override in
142
- * frontmatter, this helper doesn't see it. Both are uncommon.
123
+ * what `entry.id` becomes and what `params.slug` substitutes into
124
+ * `[...slug].astro` routes, so anything building a URL from the raw
125
+ * filesystem path must apply the same normalization to match what Astro
126
+ * serves.
127
+ *
128
+ * Caveat: this matches `github-slugger.slug()` and the trailing-/index
129
+ * strip, not a custom loader `generateId` or a `data.slug` frontmatter
130
+ * override neither of which this helper sees.
143
131
  */
144
132
  /** Canonicalize one entry id the way Astro's content layer does. */
145
133
  function canonicalSlug(entryId) {
@@ -431,6 +419,13 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
431
419
  badge: item.badge,
432
420
  children
433
421
  };
422
+ const landing = item.landing;
423
+ const segment = item.segment;
424
+ if (segment !== void 0) group.segment = segment;
425
+ if (landing !== void 0) {
426
+ group.indexHref = toBrowserHref(landing);
427
+ group.indexIsCurrent = toRouteKey(currentPath) === toRouteKey(landing) || void 0;
428
+ }
434
429
  result.push(group);
435
430
  }
436
431
  }
@@ -471,6 +466,70 @@ function scopeToCurrentSection(items, currentPath) {
471
466
  }
472
467
  return items;
473
468
  }
469
+ /**
470
+ * Return the ancestor chain from the top of the tree to the node that owns
471
+ * `currentPath` (inclusive). Matches by route key, so a chain can be
472
+ * resolved for any path (e.g. a catalog route's section) regardless of the
473
+ * page the tree was built for. A group that only *contains* the match is
474
+ * included with no href (a non-interactive crumb). Returns `[]` on no match.
475
+ */
476
+ function findActivePath(items, currentPath) {
477
+ const key = toRouteKey(currentPath);
478
+ function search(nodes) {
479
+ for (const item of nodes) if (item.type === "link") {
480
+ if (toRouteKey(item.href) === key) return [item];
481
+ } else if (item.type === "group") {
482
+ if (item.indexHref && !item.indexIsExternal && toRouteKey(item.indexHref) === key) return [item];
483
+ const childPath = search(item.children);
484
+ if (childPath) return [item, ...childPath];
485
+ }
486
+ return null;
487
+ }
488
+ return search(items) ?? [];
489
+ }
490
+ /**
491
+ * Descend a section-scoped tree to the sub-tree under the current path's
492
+ * boundary. A glob like `"guides/*"` (`*` = one segment) sets the prefix
493
+ * depth; the rail is replaced by the children of the shallowest group
494
+ * fully contained under that prefix. Matching is by descendant href, so it
495
+ * works for index-less section folders. Returns the input unchanged on no
496
+ * match.
497
+ */
498
+ function isolateToBoundary(items, currentPath, boundaries) {
499
+ const segs = toRouteKey(currentPath).split("/").filter(Boolean);
500
+ for (const glob of boundaries) {
501
+ const globSegs = glob.split("/").filter(Boolean);
502
+ if (segs.length < globSegs.length || globSegs.length === 0) continue;
503
+ if (!globSegs.every((g, i) => g === "*" || g === segs[i])) continue;
504
+ const group = findBoundaryGroup(items, toBrowserHref("/" + segs.slice(0, globSegs.length).join("/")));
505
+ if (group) return group.children;
506
+ }
507
+ return items;
508
+ }
509
+ /** Shallowest group whose every flattened descendant href is under `prefix`. */
510
+ function findBoundaryGroup(items, prefix) {
511
+ for (const item of items) {
512
+ if (item.type !== "group") continue;
513
+ const flat = flattenSidebar([item]);
514
+ if (flat.length > 0 && flat.every((l) => l.href.startsWith(prefix))) return item;
515
+ const nested = findBoundaryGroup(item.children, prefix);
516
+ if (nested) return nested;
517
+ }
518
+ }
519
+ /**
520
+ * Context for the `getSidebar` transform: `sectionSlug` (seg0), `module`
521
+ * (seg1), and `indexEntryId` — the landing entry id of the first group on
522
+ * the active path, or `undefined` for an index-less section.
523
+ */
524
+ function deriveTransformCtx(fullTree, currentSlug) {
525
+ const segs = currentSlug.split("/").filter(Boolean);
526
+ const sectionGroup = findActivePath(fullTree, currentSlug).find((n) => n.type === "group");
527
+ return {
528
+ sectionSlug: segs[0] ?? "",
529
+ module: segs[1],
530
+ indexEntryId: sectionGroup?._indexId
531
+ };
532
+ }
474
533
  function hasActivePage(item, currentPath) {
475
534
  if (item.type === "link") return item.isCurrent === true;
476
535
  if (item.type === "external") return false;
@@ -489,12 +548,9 @@ function hasActivePage(item, currentPath) {
489
548
  * URL prefix* — e.g. `Components` mounted at `/components/` —
490
549
  * rather than a sub-directory of the primary docs collection.
491
550
  *
492
- * Why this matters: under the previous unconditional behavior, every
493
- * top-level group in the sidebar (including `wip/`, `lab/`, and other
494
- * docs-collection subdirectories) was promoted to a header tab. The
495
- * header rail is meant for "other collections" navigation, not for
496
- * sub-sections of the default collection — those belong in the
497
- * sidebar's own collapsible tree.
551
+ * Sub-directories of the primary collection (`wip/`, `lab/`, etc.) are
552
+ * deliberately excluded the header rail is for cross-collection
553
+ * navigation; sub-sections belong in the sidebar's own tree.
498
554
  *
499
555
  * Caller must pass the *un-scoped* tree (the result of
500
556
  * `buildSidebarTree`, not `getSidebar`); otherwise only the current
@@ -557,16 +613,13 @@ function applyDefaultCollapsed(items) {
557
613
  }
558
614
  }
559
615
  /**
560
- * @deprecated Effective no-op under structural separation. Pre-2026
561
- * Nimbus rendered the group's index as the first child link and used
562
- * this to rename that link to "Overview". The index is now exposed via
563
- * `SidebarGroupItem.indexHref` (the group label IS the link), so there's
564
- * no first-child index to rename. The function is kept so older configs
565
- * that set `sidebar.overviewLabel` don't blow up; future major can drop it.
566
- *
567
- * Renamed only when the first link IS the group's index (matched via the
568
- * `sortKeyByItem` WeakMap) — under structural separation that condition
569
- * never holds, so this silently returns the input unchanged.
616
+ * Relabel a section's landing link to the `overviewLabel` string (default
617
+ * "Overview"). Applies to a `directory:` autogenerate's leading landing
618
+ * link wherever it surfaces (tracked via `directoryIndexLinks`), and to a
619
+ * group whose first child link IS the group's own index (matched via the
620
+ * `sortKeyByItem` WeakMap). Config groups expose their index as the group
621
+ * label itself (`SidebarGroupItem.indexHref`), so those aren't relabelled
622
+ * here — there's no separate child link to rename.
570
623
  */
571
624
  function applyOverviewLabel(items, label) {
572
625
  for (const item of items) if (item.type === "link" && directoryIndexLinks.has(item)) item.label = label;
@@ -599,7 +652,8 @@ function processHideChildren(items, entries) {
599
652
  continue;
600
653
  }
601
654
  if (item._indexId && item.indexHref) {
602
- if (entryById.get(item._indexId)?.data.sidebar?.hideChildren) {
655
+ const entry = entryById.get(item._indexId);
656
+ if (entry?.data.sidebar?.hideChildren || entry?.data.hideChildren) {
603
657
  const replacement = item.indexIsExternal ? {
604
658
  type: "external",
605
659
  label: item.label,
@@ -889,7 +943,68 @@ function renderEntryAsMarkdown(entry, options = {}) {
889
943
 
890
944
  //#endregion
891
945
  //#region src/_internal/navigation.ts
892
- function getBreadcrumbs$1(slug, homeLabel = "Home") {
946
+ /**
947
+ * The in-site href a node links to, or `undefined` for a non-interactive
948
+ * crumb (index-less groups and off-site landings).
949
+ */
950
+ function nodeHref(node) {
951
+ if (node.type === "link") return node.href;
952
+ if (node.type === "external") return void 0;
953
+ return node.indexIsExternal ? void 0 : node.indexHref;
954
+ }
955
+ /**
956
+ * Build the trail from the root crumb and per-node labels. `labels[i]`
957
+ * pairs with `path[i]`: `null` drops the crumb, `undefined` keeps the
958
+ * node label. Deduplicated by href (first wins); hrefless crumbs are
959
+ * never merged.
960
+ */
961
+ function assembleBreadcrumbs(root, path, labels) {
962
+ const crumbs = [{
963
+ label: root.label,
964
+ href: root.href
965
+ }];
966
+ path.forEach((node, i) => {
967
+ const override = labels[i];
968
+ if (override === null) return;
969
+ const label = override ?? node.label;
970
+ const href = nodeHref(node);
971
+ crumbs.push(href ? {
972
+ label,
973
+ href
974
+ } : { label });
975
+ });
976
+ const seen = /* @__PURE__ */ new Set();
977
+ return crumbs.filter((c) => {
978
+ if (c.href === void 0) return true;
979
+ if (seen.has(c.href)) return false;
980
+ seen.add(c.href);
981
+ return true;
982
+ });
983
+ }
984
+ /**
985
+ * Append `trail` items to a section's ancestry crumbs (a leaf with no
986
+ * `href` is the current page) and deduplicate by href, so a trail crumb
987
+ * that repeats an ancestry crumb's URL collapses to one (first wins). The
988
+ * pure core of `getRouteNavigation`.
989
+ */
990
+ function composeRouteBreadcrumbs(sectionCrumbs, trail) {
991
+ const combined = [...sectionCrumbs, ...trail.map((t) => t.href ? {
992
+ label: t.label,
993
+ href: t.href
994
+ } : { label: t.label })];
995
+ const seen = /* @__PURE__ */ new Set();
996
+ return combined.filter((c) => {
997
+ if (c.href === void 0) return true;
998
+ if (seen.has(c.href)) return false;
999
+ seen.add(c.href);
1000
+ return true;
1001
+ });
1002
+ }
1003
+ /**
1004
+ * URL-segment fallback for pages with no node in the tree, so a stray
1005
+ * page still gets a root-anchored trail.
1006
+ */
1007
+ function breadcrumbsFromUrl(slug, homeLabel = "Home") {
893
1008
  const parts = slug.split("/").filter(Boolean);
894
1009
  const crumbs = [{
895
1010
  label: homeLabel,
@@ -1025,7 +1140,7 @@ async function getLastUpdatedFromGit(filePath) {
1025
1140
 
1026
1141
  //#endregion
1027
1142
  //#region src/_internal/admonition-transform.ts
1028
- /** Built-in MyST / Docusaurus / CF admonition types and their Aside mapping. */
1143
+ /** Built-in MyST / Docusaurus admonition types and their Aside mapping. */
1029
1144
  const BUILTIN_TYPES = {
1030
1145
  note: "note",
1031
1146
  info: "note",
@@ -1046,30 +1161,48 @@ function transformAdmonitions(source, options = {}) {
1046
1161
  };
1047
1162
  const { frontmatter, body, bodyOffset: _ } = splitFrontmatter(source);
1048
1163
  const { stashed, restore } = stashCodeBlocks(body);
1049
- return frontmatter + restore(stashed.replace(ADMONITION_PATTERN, (match, rawType, rawTitle, rawContent) => {
1164
+ return frontmatter + restore(stashed.replace(ADMONITION_PATTERN, (match, rawIndent, rawType, rawTitle, rawContent) => {
1050
1165
  const aside = typeMap[String(rawType).toLowerCase()];
1051
1166
  if (!aside) return match;
1167
+ const indent = typeof rawIndent === "string" ? rawIndent : "";
1052
1168
  const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
1053
- const content = String(rawContent).trim();
1054
- return `\n\n<Aside type="${aside}"${title ? ` title=${JSON.stringify(title)}` : ""}>\n\n${content}\n\n</Aside>\n\n`;
1169
+ return `\n\n${indent}<Aside type="${aside}"${title ? ` title=${JSON.stringify(title)}` : ""}>\n\n${reindentBody(String(rawContent), indent)}\n\n${indent}</Aside>\n\n`;
1055
1170
  }));
1056
1171
  }
1057
1172
  /**
1058
1173
  * Match `:::type[optional title] body :::` with non-greedy body.
1059
1174
  *
1060
1175
  * Components:
1176
+ * - `^([ \t]*)` leading indentation of the opener line (captured) so the
1177
+ * emitted `<Aside>` can be re-indented to the same depth —
1178
+ * load-bearing for directives nested inside indented JSX
1179
+ * (e.g. `:::note` inside a `<TabItem>`). The `m` flag makes
1180
+ * `^`/`$` match line boundaries. Line-anchoring also stops
1181
+ * a stray mid-line `:::` from being treated as an opener.
1061
1182
  * - `:::` literal opener
1062
1183
  * - `([a-zA-Z]+)` type token (captured, case-insensitive lookup at use site)
1063
1184
  * - `(?:\[(...)\])?` optional bracketed title; brackets stripped from capture
1064
- * - `\s+|\n` at least one whitespace before content (avoids matching
1185
+ * - `\n|[ \t]+` at least one whitespace before content (avoids matching
1065
1186
  * `:::foo:::` directly)
1066
1187
  * - `([\s\S]*?)` non-greedy body, may span newlines
1067
- * - `\n?\s*:::` closer, possibly with leading whitespace
1188
+ * - `\n?[ \t]*:::[ \t]*$` closer, possibly indented, at end of its line
1068
1189
  *
1069
1190
  * Non-greedy body + global flag means adjacent admonitions don't merge
1070
1191
  * (the engine finds the *nearest* `:::` closer for each opener).
1071
1192
  */
1072
- const ADMONITION_PATTERN = /:::([a-zA-Z]+)(?:\[([^\]]*)\])?[ \t]*(?:\n|[ \t]+)([\s\S]*?)\n?[ \t]*:::/g;
1193
+ const ADMONITION_PATTERN = /^([ \t]*):::([a-zA-Z]+)(?:\[([^\]]*)\])?[ \t]*(?:\n|[ \t]+)([\s\S]*?)\n?[ \t]*:::[ \t]*$/gm;
1194
+ /**
1195
+ * Dedent the captured admonition body to a common baseline (preserving
1196
+ * relative structure like nested lists / JSX), then re-prefix every
1197
+ * non-blank line with the directive's own indentation so the emitted
1198
+ * `<Aside>…</Aside>` block sits at the same depth as the directive.
1199
+ */
1200
+ function reindentBody(content, indent) {
1201
+ const lines = content.replace(/^\n+/, "").replace(/\n+$/, "").split("\n");
1202
+ const widths = lines.filter((l) => l.trim() !== "").map((l) => l.match(/^[ \t]*/)?.[0].length ?? 0);
1203
+ const common = widths.length ? Math.min(...widths) : 0;
1204
+ return lines.map((l) => l.trim() === "" ? "" : indent + l.slice(common)).join("\n");
1205
+ }
1073
1206
  function splitFrontmatter(source) {
1074
1207
  const match = source.match(/^---\n[\s\S]*?\n---\n?/);
1075
1208
  if (!match) return {
@@ -1729,6 +1862,118 @@ function parseTitle(meta) {
1729
1862
  return (meta.match(/\btitle="([^"]+)"/) ?? meta.match(/\btitle='([^']+)'/))?.[1];
1730
1863
  }
1731
1864
  /**
1865
+ * Expand a (possibly space-padded) line-range spec like `5-16, 21-40` or
1866
+ * `1,3-5` into an explicit list of 1-based line numbers. Tolerates spaces
1867
+ * anywhere — `{5-16, 21-40}` expands to the same set as `{5-16,21-40}`.
1868
+ */
1869
+ function expandRanges(spec) {
1870
+ const out = [];
1871
+ for (const partRaw of spec.split(",")) {
1872
+ const part = partRaw.trim();
1873
+ if (!part) continue;
1874
+ const range = part.match(/^(\d+)\s*-\s*(\d+)$/);
1875
+ if (range) {
1876
+ const a = Number.parseInt(range[1], 10);
1877
+ const b = Number.parseInt(range[2], 10);
1878
+ const lo = Math.min(a, b);
1879
+ const hi = Math.max(a, b);
1880
+ for (let i = lo; i <= hi; i++) out.push(i);
1881
+ } else if (/^\d+$/.test(part)) out.push(Number.parseInt(part, 10));
1882
+ }
1883
+ return out;
1884
+ }
1885
+ /**
1886
+ * Parse an EC-style fence meta string into {@link NimbusMeta}.
1887
+ *
1888
+ * Order matters — each step consumes (blanks out) what it matched so later,
1889
+ * looser patterns can't re-grab it:
1890
+ * 1. `frame="…"` (quoted)
1891
+ * 2. `title="…"` (quoted; value resolved separately)
1892
+ * 3. `ins="…"` / `del="…"` (quoted tokens)
1893
+ * 4. `ins={…}` / `del={…}` / `collapse={…}` (brace ranges)
1894
+ * 5. standalone `"…"`/`'…'` (search words — what's left of the quotes)
1895
+ * 6. `wrap` (bare keyword)
1896
+ * 7. bare `{…}` (plain line-highlight — only what survives)
1897
+ */
1898
+ function parseNimbusMeta(raw) {
1899
+ const meta = {
1900
+ highlightLines: /* @__PURE__ */ new Set(),
1901
+ insLines: /* @__PURE__ */ new Set(),
1902
+ delLines: /* @__PURE__ */ new Set(),
1903
+ insTokens: [],
1904
+ delTokens: [],
1905
+ searchWords: [],
1906
+ collapseLines: /* @__PURE__ */ new Set(),
1907
+ wrap: false,
1908
+ frame: void 0
1909
+ };
1910
+ if (!raw) return meta;
1911
+ let s = raw;
1912
+ s = s.replace(/\bframe=(?:"([^"]*)"|'([^']*)')/g, (_m, d, sg) => {
1913
+ const v = d ?? sg;
1914
+ if (v) meta.frame = v;
1915
+ return " ";
1916
+ });
1917
+ s = s.replace(/\btitle=(?:"([^"]*)"|'([^']*)')/g, () => " ");
1918
+ s = s.replace(/\bins=(?:"([^"]*)"|'([^']*)')/g, (_m, d, sg) => {
1919
+ const v = d ?? sg;
1920
+ if (v) meta.insTokens.push(v);
1921
+ return " ";
1922
+ });
1923
+ s = s.replace(/\bdel=(?:"([^"]*)"|'([^']*)')/g, (_m, d, sg) => {
1924
+ const v = d ?? sg;
1925
+ if (v) meta.delTokens.push(v);
1926
+ return " ";
1927
+ });
1928
+ s = s.replace(/\bins=\{([^}]*)\}/g, (_m, spec) => {
1929
+ for (const n of expandRanges(spec)) meta.insLines.add(n);
1930
+ return " ";
1931
+ });
1932
+ s = s.replace(/\bdel=\{([^}]*)\}/g, (_m, spec) => {
1933
+ for (const n of expandRanges(spec)) meta.delLines.add(n);
1934
+ return " ";
1935
+ });
1936
+ s = s.replace(/\bcollapse=\{([^}]*)\}/g, (_m, spec) => {
1937
+ for (const n of expandRanges(spec)) meta.collapseLines.add(n);
1938
+ return " ";
1939
+ });
1940
+ for (const m of s.matchAll(/"([^"]*)"|'([^']*)'/g)) {
1941
+ const v = m[1] ?? m[2];
1942
+ if (v) meta.searchWords.push(v);
1943
+ }
1944
+ s = s.replace(/"[^"]*"|'[^']*'/g, " ");
1945
+ s = s.replace(/\bwrap\b/g, () => {
1946
+ meta.wrap = true;
1947
+ return " ";
1948
+ });
1949
+ s = s.replace(/\{([^}]*)\}/g, (_m, spec) => {
1950
+ for (const n of expandRanges(spec)) meta.highlightLines.add(n);
1951
+ return " ";
1952
+ });
1953
+ return meta;
1954
+ }
1955
+ /** Collect the plain-text content of a hast line node (concatenates spans). */
1956
+ function lineText(node) {
1957
+ if (node.type === "text") return node.value ?? "";
1958
+ let out = "";
1959
+ if ("children" in node && node.children) for (const child of node.children) out += lineText(child);
1960
+ return out;
1961
+ }
1962
+ /** Find every (non-overlapping) start index of `substr` in `str`. */
1963
+ function findAllSubstringIndexes(str, substr) {
1964
+ const out = [];
1965
+ if (!substr) return out;
1966
+ let cursor = 0;
1967
+ for (;;) {
1968
+ const index = str.indexOf(substr, cursor);
1969
+ if (index === -1) break;
1970
+ out.push(index);
1971
+ cursor = index + substr.length;
1972
+ }
1973
+ return out;
1974
+ }
1975
+ const META_SYMBOL = Symbol("nimbus-meta");
1976
+ /**
1732
1977
  * The canonical Shiki transformer chain for Nimbus. Returns a fresh
1733
1978
  * array each call so callers don't accidentally mutate a shared list.
1734
1979
  *
@@ -1745,11 +1990,57 @@ function defaultCodeTransformers() {
1745
1990
  transformerNotationFocus(),
1746
1991
  transformerNotationErrorLevel(),
1747
1992
  transformerNotationWordHighlight(),
1748
- transformerMetaHighlight(),
1749
- transformerMetaWordHighlight(),
1993
+ nimbusMetaTransformer(),
1750
1994
  titleAndLangTransformer()
1751
1995
  ];
1752
1996
  }
1997
+ /**
1998
+ * Nimbus-owned fence-meta transformer. Owns bare-brace highlight
1999
+ * (space-tolerant), `ins=`/`del=` (brace + quoted-string forms),
2000
+ * quoted-search word highlight, `wrap`, `collapse` (neutral), and a
2001
+ * `frame=` hook. Replaces the stock meta-highlight + meta-word-highlight
2002
+ * transformers, which double-fired and hijacked braces.
2003
+ */
2004
+ function nimbusMetaTransformer() {
2005
+ function getMeta(ctx) {
2006
+ const carrier = ctx.meta ?? {};
2007
+ if (!carrier[META_SYMBOL]) carrier[META_SYMBOL] = parseNimbusMeta(ctx.options.meta?.__raw);
2008
+ return carrier[META_SYMBOL];
2009
+ }
2010
+ return {
2011
+ name: "nimbus:meta",
2012
+ preprocess(code, options) {
2013
+ if (!this.options.meta?.__raw) return;
2014
+ const meta = getMeta(this);
2015
+ if (meta.searchWords.length === 0) return;
2016
+ options.decorations ||= [];
2017
+ for (const word of meta.searchWords) for (const index of findAllSubstringIndexes(code, word)) options.decorations.push({
2018
+ start: index,
2019
+ end: index + word.length,
2020
+ properties: { class: "highlighted-word" }
2021
+ });
2022
+ },
2023
+ line(node, lineNumber) {
2024
+ if (!this.options.meta?.__raw) return;
2025
+ const meta = getMeta(this);
2026
+ if (meta.highlightLines.has(lineNumber)) this.addClassToHast(node, "highlighted");
2027
+ if (meta.insLines.has(lineNumber)) this.addClassToHast(node, "diff add");
2028
+ if (meta.delLines.has(lineNumber)) this.addClassToHast(node, "diff remove");
2029
+ if (meta.insTokens.length || meta.delTokens.length) {
2030
+ const text = lineText(node);
2031
+ if (meta.insTokens.some((t) => text.includes(t))) this.addClassToHast(node, "diff add");
2032
+ if (meta.delTokens.some((t) => text.includes(t))) this.addClassToHast(node, "diff remove");
2033
+ }
2034
+ },
2035
+ pre(preNode) {
2036
+ if (!this.options.meta?.__raw) return;
2037
+ const meta = getMeta(this);
2038
+ preNode.properties = preNode.properties ?? {};
2039
+ if (meta.wrap) preNode.properties["data-nb-wrap"] = "";
2040
+ if (meta.frame) preNode.properties["data-nb-frame"] = meta.frame;
2041
+ }
2042
+ };
2043
+ }
1753
2044
  function titleAndLangTransformer() {
1754
2045
  return {
1755
2046
  name: "nimbus:title-and-lang",
@@ -1759,6 +2050,8 @@ function titleAndLangTransformer() {
1759
2050
  const title = parseTitle(meta);
1760
2051
  preNode.properties = preNode.properties ?? {};
1761
2052
  preNode.properties["data-nb-lang"] = lang;
2053
+ const wrap = preNode.properties["data-nb-wrap"] !== void 0;
2054
+ const frame = preNode.properties["data-nb-frame"];
1762
2055
  const children = [];
1763
2056
  if (title) children.push({
1764
2057
  type: "element",
@@ -1783,13 +2076,16 @@ function titleAndLangTransformer() {
1783
2076
  }]
1784
2077
  });
1785
2078
  children.push(preNode);
2079
+ const figureProps = {
2080
+ class: title ? "nb-code-figure nb-code-figure-titled" : "nb-code-figure",
2081
+ "data-nb-lang": lang
2082
+ };
2083
+ if (wrap) figureProps["data-nb-wrap"] = "";
2084
+ if (typeof frame === "string") figureProps["data-nb-frame"] = frame;
1786
2085
  return {
1787
2086
  type: "element",
1788
2087
  tagName: "figure",
1789
- properties: {
1790
- class: title ? "nb-code-figure nb-code-figure-titled" : "nb-code-figure",
1791
- "data-nb-lang": lang
1792
- },
2088
+ properties: figureProps,
1793
2089
  children
1794
2090
  };
1795
2091
  }
@@ -1856,9 +2152,9 @@ async function validateMdxContent(options) {
1856
2152
  * Format a list of failures into a single multi-line error message
1857
2153
  * suitable for `throw new Error(...)`.
1858
2154
  */
1859
- function formatFailures(failures, globalsCount) {
2155
+ function formatFailures(failures) {
1860
2156
  const lines = failures.map((f) => {
1861
- 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.`;
2157
+ const fix = f.hint ? `Did you mean <${f.hint} />?` : `Register it in src/components.ts, or add an explicit \`import\` at the top of this file.`;
1862
2158
  return ` ${f.filePath}:${f.line}:${f.column} <${f.tag} /> → ${fix}`;
1863
2159
  });
1864
2160
  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.";
@@ -2086,7 +2382,7 @@ function validateNimbusConfig(input) {
2086
2382
  const tail = received === null ? "" : `\n received: ${received}`;
2087
2383
  return ` - ${display}: ${issue.message}${tail}`;
2088
2384
  }).join("\n");
2089
- throw new Error(`Invalid nimbus.config — fix these issues:\n${issues}\n\nSee https://nimbus-docs.dev/config for the full config schema.`);
2385
+ throw new Error(`Invalid nimbus.config — fix these issues:\n${issues}\n\nSee https://nimbus-docs.com/config for the full config schema.`);
2090
2386
  }
2091
2387
  /**
2092
2388
  * Resolve the value at `path` inside the raw input and format it for an
@@ -2134,18 +2430,11 @@ function virtualConfigPlugin(config, extras) {
2134
2430
  * inside `.md` / `.mdx` files. Output feeds `shikiConfig.langs` so Shiki
2135
2431
  * eager-loads every grammar at startup instead of lazy-loading on first use.
2136
2432
  *
2137
- * Why this matters: Shiki's lazy load assumes every MDX file gets processed
2138
- * during a build. Layer 2 (incremental builds' Vite MDX-skip plugin) breaks
2139
- * that assumption cached MDX files never enter the markdown pipeline, so
2140
- * languages that only appear in cached files would never trigger a grammar
2141
- * load, and any non-cached file using those languages would silently render
2142
- * without highlighting.
2143
- *
2144
- * Eager loading also gives non-incremental users a small predictability win:
2145
- * the highlighter behaves the same regardless of which file is processed
2146
- * first.
2147
- *
2148
- * Cost: ~1s on a 7k-file bench. Acceptable.
2433
+ * Needed because incremental builds skip cached MDX files: those never
2434
+ * enter the markdown pipeline, so a language appearing only in cached
2435
+ * files would never trigger Shiki's lazy grammar load, and a non-cached
2436
+ * file using it would render without highlighting. Eager loading also
2437
+ * keeps highlighting independent of which file is processed first.
2149
2438
  */
2150
2439
  const FENCE_RE = /^[ \t]*```([a-zA-Z][a-zA-Z0-9_+\-]*)/gm;
2151
2440
  async function* walkMdx(dir) {
@@ -2200,10 +2489,10 @@ async function scanCodeBlockLanguages(projectRoot, langAlias = {}) {
2200
2489
  * pages/<aa>/<full-hash>.html — cached HTML body for a page, sharded
2201
2490
  * by the first 2 hex chars of the hash
2202
2491
  *
2203
- * Phase 2 MVP — atomic per-file writes. v2 adds a manifest-level
2204
- * `namespace` field for PR-vs-main isolation; resolution lives in
2205
- * `namespace.ts`. Framework/Node version is folded into `globalHash` via
2206
- * `computeGlobalHash` already, so it doesn't need a separate field.
2492
+ * Atomic per-file writes. A manifest-level `namespace` field provides
2493
+ * PR-vs-main isolation; resolution lives in `namespace.ts`. Framework/Node
2494
+ * version is folded into `globalHash` via `computeGlobalHash` already, so
2495
+ * it doesn't need a separate field.
2207
2496
  */
2208
2497
  const SCHEMA_VERSION = 2;
2209
2498
  var Cache = class {
@@ -2272,14 +2561,13 @@ var Cache = class {
2272
2561
  /**
2273
2562
  * Snapshot a *bounded subset* of `dist/_astro/` into the cache.
2274
2563
  *
2275
- * Naive snapshot was unbounded: every warm build accumulated new
2276
- * bundle hashes (vite produces different hashes when the module graph
2277
- * differs between builds) and we kept everything forever. Caller passes
2278
- * the set of asset rel-paths that some cached HTML actually references —
2564
+ * Bounded so the cache doesn't grow forever: Vite emits new bundle
2565
+ * hashes whenever the module graph differs between builds. The caller
2566
+ * passes the asset rel-paths that some cached HTML actually references;
2279
2567
  * anything outside that set gets dropped.
2280
2568
  *
2281
2569
  * `referencedRelPaths` should be the union of every `/_astro/...` URL
2282
- * extracted from cached HTML — see `parseReferencedAssets` in index.ts.
2570
+ * extracted from cached HTML — see `collectReferencedAssets` in index.ts.
2283
2571
  */
2284
2572
  async snapshotAssets(distAstroDir, referencedRelPaths) {
2285
2573
  const target = resolve(this.root, "assets");
@@ -2448,10 +2736,9 @@ async function writeAtomic(path, data) {
2448
2736
  * - pageHash: sha256(page bytes + globalHash). Determines whether a
2449
2737
  * given page's cached HTML is still valid.
2450
2738
  *
2451
- * Phase 2 MVP — deliberately no partial tracking, no data-collection tracking,
2452
- * no component-graph tracking. Phase 3 wires the partial registry into the
2453
- * page hash; that work depends on `validate-mdx-content.ts` being extended
2454
- * to capture `<Render file="…">` references.
2739
+ * Current scope deliberately omits data-collection tracking and
2740
+ * component-graph tracking. Partial-dependency tracking folds the partial
2741
+ * registry into the page hash (see `partial-refs.ts`).
2455
2742
  */
2456
2743
  const TRACKED_DIRS = ["src", "public"];
2457
2744
  const TRACKED_FILES = [
@@ -2514,7 +2801,7 @@ async function walk$1(dir, root) {
2514
2801
  * - Node major version (minor diffs occasionally affect bundling)
2515
2802
  * - Platform + arch (some asset emission is platform-sensitive)
2516
2803
  *
2517
- * Including provenance closes BUG-002 / BUG-003: a framework upgrade
2804
+ * Including provenance closes a class of staleness bug: a framework upgrade
2518
2805
  * (or Node bump, or OS change) silently changed rendered output but the
2519
2806
  * old global hash matched, so warm builds served stale entries from a
2520
2807
  * different version of the world.
@@ -2583,7 +2870,7 @@ async function readDepVersion(projectRoot, dep) {
2583
2870
  }
2584
2871
  }
2585
2872
  /**
2586
- * Phase 3 — per-page hash with transitive partial dependencies folded in.
2873
+ * Per-page hash with transitive partial dependencies folded in.
2587
2874
  *
2588
2875
  * Same shape as `computePageHash` but additionally absorbs the bytes of
2589
2876
  * every partial the page transitively embeds. Sorted by path so two
@@ -2667,14 +2954,14 @@ async function resolveCacheNamespace(projectRoot) {
2667
2954
  //#endregion
2668
2955
  //#region src/_internal/incremental/partial-refs.ts
2669
2956
  /**
2670
- * Phase 3 — partial dependency tracking.
2957
+ * Partial dependency tracking.
2671
2958
  *
2672
2959
  * Walks MDX content to find `<Render file="…" />` and `<Render slug="…" />`
2673
2960
  * references, then builds a per-page transitive closure: "pathname X
2674
2961
  * embeds partials A, B, C — where A in turn embeds D, and B in turn
2675
2962
  * embeds E and F." Folding all of those partials' bytes into the page's
2676
- * hash gives us the property the spec promises: edit one partial, exactly
2677
- * the pages that transitively embed it re-render.
2963
+ * hash gives us the property we want: edit one partial, exactly the pages
2964
+ * that transitively embed it re-render.
2678
2965
  *
2679
2966
  * Scope (v1):
2680
2967
  * - Only string-literal `file` / `slug` props get captured. Dynamic
@@ -2683,16 +2970,17 @@ async function resolveCacheNamespace(projectRoot) {
2683
2970
  * v1 limitation; the `partialResolver` hook (deferred) gives sites
2684
2971
  * an escape valve.
2685
2972
  * - Default resolver: `<Render file="topic/slug" />` resolves to
2686
- * `src/content/partials/topic/slug.mdx`. Matches the bench, apps/www,
2687
- * and mvvmm's PR shape for cloudflare-docs (their resolver also
2688
- * prepends a `product` prop — that needs a custom resolver).
2973
+ * `src/content/partials/topic/slug.mdx`. Sites with a multi-prop
2974
+ * convention (e.g. a resolver that prepends a `product` prop) need a
2975
+ * custom resolver.
2689
2976
  * - Cycles in the partial graph are handled (visited set).
2690
2977
  */
2691
2978
  /**
2692
2979
  * Check `candidate` is a normalised path under `rootWithSep`. Cheap
2693
2980
  * defense against `../` traversal escaping the partials root. We use a
2694
2981
  * trailing-sep marker on root to avoid false-matching `partialsRoot` with
2695
- * `partialsRoot-evil/` style siblings.
2982
+ * sibling directories that share its name as a prefix (e.g.
2983
+ * `partialsRoot-shared/`).
2696
2984
  */
2697
2985
  function isInside(candidate, rootWithSep) {
2698
2986
  return candidate.startsWith(rootWithSep) || candidate === rootWithSep.slice(0, -1);
@@ -2715,7 +3003,7 @@ const ATTR_RE = /([a-zA-Z][a-zA-Z0-9_]*)\s*=\s*["']([^"']*)["']/g;
2715
3003
  * extensions or use plain Markdown for partials.
2716
3004
  *
2717
3005
  * `partialsBase` lets callers point the resolver at a non-default partials
2718
- * collection base (closes BUG-040). Default: `src/content/partials`.
3006
+ * collection base. Default: `src/content/partials`.
2719
3007
  */
2720
3008
  function makeDefaultPartialResolver(projectRoot, partialsBase = "src/content/partials") {
2721
3009
  const partialsRoot = resolve(projectRoot, partialsBase);
@@ -2884,7 +3172,7 @@ async function partialsDirExists(projectRoot, partialsBase = "src/content/partia
2884
3172
  //#endregion
2885
3173
  //#region src/_internal/incremental/index.ts
2886
3174
  /**
2887
- * Incremental builds — Phase 2 MVP.
3175
+ * Incremental builds.
2888
3176
  *
2889
3177
  * Wires the cache layer into Astro's prerenderer. On warm build, pages whose
2890
3178
  * source bytes (and the global hash) haven't changed since the last build
@@ -2893,16 +3181,14 @@ async function partialsDirExists(projectRoot, partialsBase = "src/content/partia
2893
3181
  *
2894
3182
  * Astro sees every route in `getStaticPaths` either way — cache hits flow
2895
3183
  * through `astro:build:generated`, adapter writers, route-headers accounting
2896
- * exactly like fresh renders. This is the spec's design, *not* mvvmm's
2897
- * `getStaticPaths`-filtering approach, because the latter hides cached
2898
- * routes from downstream hooks.
3184
+ * exactly like fresh renders. This is by design rather than filtering
3185
+ * cached routes out of `getStaticPaths`, which would hide them from
3186
+ * downstream hooks.
2899
3187
  *
2900
- * Anti-goals for this MVP (deferred to Phase 3+):
2901
- * - Partial-dependency tracking. Edit a partial → still full rebuild today.
3188
+ * Out of scope for now:
2902
3189
  * - Data-collection scoping.
2903
3190
  * - Component-graph tracking. Any tracked-file change → full rebuild.
2904
- * - Provenance / namespacing / trust boundary. Hardening track.
2905
- * - `nimbus build --explain` and structured build reports. Console log only.
3191
+ * - `nimbus build --explain` and structured build reports.
2906
3192
  */
2907
3193
  /**
2908
3194
  * Normalise a request URL to its canonical pathname (no trailing slash,
@@ -2921,7 +3207,7 @@ function canonicalisePathname(input) {
2921
3207
  }
2922
3208
  /**
2923
3209
  * Build a map from pathname → MDX file bytes by walking the docs collection
2924
- * directory. Phase 2 MVP only handles the primary `docs` collection.
3210
+ * directory. Only the primary `docs` collection is handled.
2925
3211
  *
2926
3212
  * Pathname derivation: `src/content/docs/<entry.id>.mdx` → `/<entry.id>`,
2927
3213
  * mirroring `getDocsStaticPaths` which uses `entry.id` verbatim as slug.
@@ -3019,9 +3305,9 @@ async function collectDocsPages(projectRoot, docsBase = "src/content/docs") {
3019
3305
  * Computes per-page hashes, reads prior manifest, determines which pages
3020
3306
  * are cache-hits.
3021
3307
  *
3022
- * Phase 3 — the page hash includes the bytes of every partial the page
3023
- * transitively embeds, so editing a partial invalidates exactly the pages
3024
- * that reference it (directly or transitively) and nothing else.
3308
+ * The page hash includes the bytes of every partial the page transitively
3309
+ * embeds, so editing a partial invalidates exactly the pages that reference
3310
+ * it (directly or transitively) and nothing else.
3025
3311
  */
3026
3312
  async function setupIncrementalContext(projectRoot, cacheDir, logger, partialResolver) {
3027
3313
  const cache = new Cache(cacheDir ? resolve(cacheDir, "nimbus") : resolve(projectRoot, ".nimbus/cache"));
@@ -3081,7 +3367,7 @@ async function setupIncrementalContext(projectRoot, cacheDir, logger, partialRes
3081
3367
  /**
3082
3368
  * Wrap an Astro prerenderer with the cache.
3083
3369
  *
3084
- * Strategy (mvvmm-style — chosen empirically over the "wrap Response" approach
3370
+ * Strategy (chosen empirically over the "wrap Response" approach
3085
3371
  * because Astro's per-route work outside `render` is the actual dominant cost,
3086
3372
  * not MDX→HTML conversion):
3087
3373
  *
@@ -3094,12 +3380,12 @@ async function setupIncrementalContext(projectRoot, cacheDir, logger, partialRes
3094
3380
  * `dist/<pathname>/index.html` for the filtered cached routes — Astro
3095
3381
  * never wrote them, so we do.
3096
3382
  *
3097
- * Trade-off vs. the spec's "wrap Response in render" design: downstream
3383
+ * Trade-off vs. the "wrap Response in render" design: downstream
3098
3384
  * Astro hooks (`astro:build:generated`, adapter writers, route accounting)
3099
3385
  * don't see cached routes. For Cloudflare adapter sites or anything that
3100
3386
  * depends on every route being visible to those hooks, this matters.
3101
3387
  * For static SSG sites where the rendered HTML *is* the output, it's fine.
3102
- * Documented as a limitation in Phase 5 user-facing notes.
3388
+ * Documented as a known limitation.
3103
3389
  */
3104
3390
  function wrapPrerenderer(defaultPrerenderer, ctx) {
3105
3391
  return {
@@ -3176,7 +3462,7 @@ async function restoreCachedPagesToDist(ctx, outDir) {
3176
3462
  * (so the snapshot is the union of fresh + previously-cached assets the
3177
3463
  * cached HTML still references).
3178
3464
  *
3179
- * BUG-007 fix: bounded to assets actually referenced by cached HTML. We
3465
+ * Bounded to assets actually referenced by cached HTML. We
3180
3466
  * walk every cached page's bytes, regex-extract `/_astro/...` URLs,
3181
3467
  * dedupe — and only persist those. Without this the snapshot grew
3182
3468
  * unboundedly because vite produces new bundle hashes on every warm
@@ -3193,7 +3479,7 @@ const ASSET_REF_RE = /\/_astro\/([^"')\s>]+)/g;
3193
3479
  * Strip query string and hash from an extracted asset path. Without
3194
3480
  * this, `/_astro/foo.js?v=1` would record `foo.js?v=1` as the file
3195
3481
  * name — the snapshot would skip it because no such file exists in
3196
- * `_astro/`, leaving the warm build with a broken reference (BUG-108).
3482
+ * `_astro/`, leaving the warm build with a broken reference.
3197
3483
  */
3198
3484
  function normaliseAssetRef(raw) {
3199
3485
  if (!raw) return null;
@@ -3213,10 +3499,9 @@ function normaliseAssetRef(raw) {
3213
3499
  *
3214
3500
  * The single regex matches `/_astro/...` anywhere in the HTML —
3215
3501
  * straightforward for `href="..."`, `src="..."`, `url(...)` in inline
3216
- * styles, and individual `srcset` URLs alike. (BUG-107 was about the
3217
- * earlier regex anchoring on a quote/paren prefix and missing the
3218
- * second+nth URL inside a `srcset` value; the unanchored form here
3219
- * catches them all.)
3502
+ * styles, and individual `srcset` URLs alike. (An earlier regex
3503
+ * anchored on a quote/paren prefix and missed the second+nth URL
3504
+ * inside a `srcset` value; the unanchored form here catches them all.)
3220
3505
  *
3221
3506
  * We scan the dist output rather than the in-memory cache because dist
3222
3507
  * is the source of truth for what's currently referenced — after the
@@ -3335,8 +3620,8 @@ function mdxSkipPlugin(ctx) {
3335
3620
  * - sitemap-0.xml carries all entries (we don't split until >45k urls)
3336
3621
  * - sitemap-index.xml lists sitemap-0.xml only
3337
3622
  *
3338
- * v1 scope no custom `serialize` yet (deferred; cloudflare-docs case),
3339
- * no `lastmod`, `changefreq`, `priority`, no image/video sitemaps. Matches
3623
+ * Scope: an optional `serialize` hook per URL, but no `lastmod`,
3624
+ * `changefreq`, `priority`, and no image/video sitemaps. Matches
3340
3625
  * `@astrojs/sitemap` *default* output for sites that don't override.
3341
3626
  */
3342
3627
  const URLSET_XMLNS = "xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:news=\"http://www.google.com/schemas/sitemap-news/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\" xmlns:image=\"http://www.google.com/schemas/sitemap-image/1.1\" xmlns:video=\"http://www.google.com/schemas/sitemap-video/1.1\"";
@@ -3487,8 +3772,7 @@ function extractFrontmatter(source) {
3487
3772
  const rest = source.slice(afterFirstMarker + 1);
3488
3773
  const closingMatch = rest.match(/(^|\n)---\s*(\n|$)/);
3489
3774
  if (!closingMatch || closingMatch.index === void 0) return null;
3490
- const endIndex = closingMatch[1] === "\n" ? closingMatch.index : closingMatch.index;
3491
- return rest.slice(0, endIndex);
3775
+ return rest.slice(0, closingMatch.index);
3492
3776
  }
3493
3777
  /**
3494
3778
  * Find a top-level boolean field in YAML frontmatter. Returns the
@@ -3519,9 +3803,8 @@ function parseBoolField(yaml, field) {
3519
3803
  * - `string[]` for either array form
3520
3804
  * - `undefined` if absent
3521
3805
  *
3522
- * The multiline form is the canonical YAML list syntax; the scanner
3523
- * previously accepted only scalar and inline-array, which silently
3524
- * dropped valid block lists at build time. The schema validates the
3806
+ * All three forms are accepted: scalar, inline array, and the multiline
3807
+ * block list (canonical YAML list syntax). The schema validates the
3525
3808
  * post-parse shape; the scanner has to match it.
3526
3809
  */
3527
3810
  function parsePreviousSlugField(yaml) {
@@ -3824,7 +4107,7 @@ function nimbus(rawConfig, options = {}) {
3824
4107
  skip: validateOpts.skip,
3825
4108
  projectRoot
3826
4109
  });
3827
- if (failures.length > 0) throw new Error(formatFailures(failures, globals.length));
4110
+ if (failures.length > 0) throw new Error(formatFailures(failures));
3828
4111
  logger.info(`MDX validation passed — ${globals.length} global component${globals.length === 1 ? "" : "s"} registered, ${contentDirs.length} content dir${contentDirs.length === 1 ? "" : "s"} scanned.`);
3829
4112
  }
3830
4113
  }
@@ -4075,9 +4358,9 @@ function runPagefind(siteDir) {
4075
4358
  /**
4076
4359
  * Main entry for `nimbus-docs`.
4077
4360
  *
4078
- * Exports the Astro integration (default), config helper, and the four
4079
- * data helpers (sidebar, prev/next, breadcrumbs, TOC). Phase 6 will
4080
- * add page composition helpers (`getDocsStaticPaths`, `getDocsPageProps`).
4361
+ * Exports the Astro integration (default), config helper, the data helpers
4362
+ * (sidebar, prev/next, breadcrumbs, TOC), and the page composition helpers
4363
+ * (`getDocsStaticPaths`, `getDocsPageProps`).
4081
4364
  *
4082
4365
  * Helpers read the user's config from `virtual:nimbus/config` (provided
4083
4366
  * by our Vite plugin) and content entries from `astro:content`. Both
@@ -4232,8 +4515,19 @@ async function getIndexedTopLevel() {
4232
4515
  */
4233
4516
  async function getSidebar(currentSlug, options) {
4234
4517
  const config = await loadNimbusConfig();
4235
- const tree = await buildFullSidebarTree(currentSlug, options?.collection);
4236
- return config.sidebar?.scope === "section" ? scopeToCurrentSection(tree, currentSlug) : tree;
4518
+ const fullTree = await buildFullSidebarTree(currentSlug, options?.collection);
4519
+ let tree = config.sidebar?.scope === "section" ? scopeToCurrentSection(fullTree, currentSlug) : fullTree;
4520
+ const boundaries = config.sidebar?.isolate?.boundaries;
4521
+ if (boundaries && boundaries.length > 0) tree = isolateToBoundary(tree, currentSlug, boundaries);
4522
+ if (options?.transform) {
4523
+ const ctx = deriveTransformCtx(fullTree, currentSlug);
4524
+ tree = await options.transform({
4525
+ tree,
4526
+ currentSlug,
4527
+ ...ctx
4528
+ });
4529
+ }
4530
+ return tree;
4237
4531
  }
4238
4532
  /**
4239
4533
  * Derive one section per top-level group in the sidebar — used by
@@ -4324,13 +4618,74 @@ async function getPrevNext(currentSlug, options) {
4324
4618
  return getPrevNext$1(currentSlug, tree, options?.overrides, validInternalLinks);
4325
4619
  }
4326
4620
  /**
4327
- * Build breadcrumb trail from "/" to the current page.
4328
- *
4329
- * Phase 5: simple URL-segment derivation. Later phases may enrich with
4330
- * sidebar-aware labels.
4621
+ * Build the breadcrumb trail from the active node's ancestry in the nav
4622
+ * tree. Labels come from nav nodes, hrefs from each node's landing — so a
4623
+ * section crumb links to its real landing page and segments with no node
4624
+ * never appear. Index-less folders render as non-interactive crumbs.
4625
+ *
4626
+ * - `collection` — the page's Astro collection; pass `entry.collection` so
4627
+ * versioned pages get version-prefixed hrefs.
4628
+ * - `root` — the leading crumb (default `{ label: "Home", href: "/" }`).
4629
+ * - `resolveLabel` — override a crumb label, or return `null` to drop it.
4630
+ *
4631
+ * Falls back to URL-segment derivation when the page has no node in the
4632
+ * tree, so a stray page still gets a root-anchored trail.
4331
4633
  */
4332
4634
  async function getBreadcrumbs(currentSlug, options) {
4333
- return getBreadcrumbs$1(currentSlug, options?.homeLabel ?? "Home");
4635
+ const path = findActivePath(await buildFullSidebarTree(currentSlug, options?.collection), currentSlug);
4636
+ if (path.length > 0) return assembleBreadcrumbs(options?.root ?? {
4637
+ label: "Home",
4638
+ href: "/"
4639
+ }, path, await Promise.all(path.map((node) => Promise.resolve(options?.resolveLabel?.({
4640
+ node,
4641
+ slug: currentSlug
4642
+ })))));
4643
+ return breadcrumbsFromUrl(currentSlug, options?.root?.label ?? "Home");
4644
+ }
4645
+ /**
4646
+ * Resolve a section's display title(s) for the current page, decoupled so
4647
+ * the rail header and the breadcrumb can differ.
4648
+ *
4649
+ * Derives `sectionSlug` (seg0) and `module` (seg1) from the slug and passes
4650
+ * them to a caller-supplied resolver. The resolver is an argument rather
4651
+ * than config because config is JSON-serialized and cannot carry functions.
4652
+ * `indexEntryId` is currently always `undefined`.
4653
+ */
4654
+ async function getSectionTitle(currentSlug, resolve) {
4655
+ const segs = currentSlug.split("/").filter(Boolean);
4656
+ const sectionSlug = segs[0];
4657
+ if (!sectionSlug) return void 0;
4658
+ return resolve({
4659
+ sectionSlug,
4660
+ module: segs[1],
4661
+ indexEntryId: void 0
4662
+ });
4663
+ }
4664
+ /**
4665
+ * Navigation (breadcrumbs, sidebar active-state, optional prev/next) for a
4666
+ * data-driven route with no content entry of its own — e.g. a catalog page
4667
+ * under `src/pages/[...].astro`.
4668
+ *
4669
+ * Builds the breadcrumb trail to `section` (a real nav node) and appends
4670
+ * `trail` (the leaf). The sidebar is built with `section` as the active
4671
+ * path, so the section node highlights even though the leaf is not in the
4672
+ * tree — the leaf is never injected, keeping the tree and prev/next clean.
4673
+ */
4674
+ async function getRouteNavigation(options) {
4675
+ const { section, trail = [], prevNext = false, collection, resolveLabel } = options;
4676
+ const sidebar = await getSidebar(section, { collection });
4677
+ const breadcrumbs = composeRouteBreadcrumbs(await getBreadcrumbs(section, {
4678
+ collection,
4679
+ resolveLabel
4680
+ }), trail);
4681
+ let pn;
4682
+ if (prevNext) pn = await getPrevNext(section, { sidebarTree: sidebar });
4683
+ return {
4684
+ breadcrumbs,
4685
+ sidebar,
4686
+ activeHref: section,
4687
+ prevNext: pn
4688
+ };
4334
4689
  }
4335
4690
  /**
4336
4691
  * Build an edit URL for a content entry using `config.editPattern`.
@@ -4682,5 +5037,5 @@ async function getVersionStatus(collectionId) {
4682
5037
  }
4683
5038
 
4684
5039
  //#endregion
4685
- export { nimbus as default, defaultCodeTransformers, defineConfig, getBreadcrumbs, getCanonicalUrl, getCollectionLlmsUrl, getCollectionPageProps, getCollectionStaticPaths, getCurrentVersion, getDocsPageProps, getDocsStaticPaths, getEditUrl, getIndexedEntries, getIndexedTopLevel, getLastUpdated, getPrevNext, getSidebar, getSidebarSections, getTOC, getVersionAlternates, getVersionLandingUrl, getVersionStatus, getVersions, getVisibleEntries, renderEntryAsMarkdown, sidebarHash };
5040
+ export { nimbus as default, defaultCodeTransformers, defineConfig, getBreadcrumbs, getCanonicalUrl, getCollectionLlmsUrl, getCollectionPageProps, getCollectionStaticPaths, getCurrentVersion, getDocsPageProps, getDocsStaticPaths, getEditUrl, getIndexedEntries, getIndexedTopLevel, getLastUpdated, getPrevNext, getRouteNavigation, getSectionTitle, getSidebar, getSidebarSections, getTOC, getVersionAlternates, getVersionLandingUrl, getVersionStatus, getVersions, getVisibleEntries, renderEntryAsMarkdown, sidebarHash };
4686
5041
  //# sourceMappingURL=index.js.map