nimbus-docs 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,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
@@ -419,6 +419,13 @@ function resolveConfigItems(configItems, entriesByCollection, primaryCollection,
419
419
  badge: item.badge,
420
420
  children
421
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
+ }
422
429
  result.push(group);
423
430
  }
424
431
  }
@@ -459,6 +466,70 @@ function scopeToCurrentSection(items, currentPath) {
459
466
  }
460
467
  return items;
461
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
+ }
462
533
  function hasActivePage(item, currentPath) {
463
534
  if (item.type === "link") return item.isCurrent === true;
464
535
  if (item.type === "external") return false;
@@ -581,7 +652,8 @@ function processHideChildren(items, entries) {
581
652
  continue;
582
653
  }
583
654
  if (item._indexId && item.indexHref) {
584
- if (entryById.get(item._indexId)?.data.sidebar?.hideChildren) {
655
+ const entry = entryById.get(item._indexId);
656
+ if (entry?.data.sidebar?.hideChildren || entry?.data.hideChildren) {
585
657
  const replacement = item.indexIsExternal ? {
586
658
  type: "external",
587
659
  label: item.label,
@@ -871,7 +943,68 @@ function renderEntryAsMarkdown(entry, options = {}) {
871
943
 
872
944
  //#endregion
873
945
  //#region src/_internal/navigation.ts
874
- 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") {
875
1008
  const parts = slug.split("/").filter(Boolean);
876
1009
  const crumbs = [{
877
1010
  label: homeLabel,
@@ -1028,30 +1161,48 @@ function transformAdmonitions(source, options = {}) {
1028
1161
  };
1029
1162
  const { frontmatter, body, bodyOffset: _ } = splitFrontmatter(source);
1030
1163
  const { stashed, restore } = stashCodeBlocks(body);
1031
- return frontmatter + restore(stashed.replace(ADMONITION_PATTERN, (match, rawType, rawTitle, rawContent) => {
1164
+ return frontmatter + restore(stashed.replace(ADMONITION_PATTERN, (match, rawIndent, rawType, rawTitle, rawContent) => {
1032
1165
  const aside = typeMap[String(rawType).toLowerCase()];
1033
1166
  if (!aside) return match;
1167
+ const indent = typeof rawIndent === "string" ? rawIndent : "";
1034
1168
  const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
1035
- const content = String(rawContent).trim();
1036
- 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`;
1037
1170
  }));
1038
1171
  }
1039
1172
  /**
1040
1173
  * Match `:::type[optional title] body :::` with non-greedy body.
1041
1174
  *
1042
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.
1043
1182
  * - `:::` literal opener
1044
1183
  * - `([a-zA-Z]+)` type token (captured, case-insensitive lookup at use site)
1045
1184
  * - `(?:\[(...)\])?` optional bracketed title; brackets stripped from capture
1046
- * - `\s+|\n` at least one whitespace before content (avoids matching
1185
+ * - `\n|[ \t]+` at least one whitespace before content (avoids matching
1047
1186
  * `:::foo:::` directly)
1048
1187
  * - `([\s\S]*?)` non-greedy body, may span newlines
1049
- * - `\n?\s*:::` closer, possibly with leading whitespace
1188
+ * - `\n?[ \t]*:::[ \t]*$` closer, possibly indented, at end of its line
1050
1189
  *
1051
1190
  * Non-greedy body + global flag means adjacent admonitions don't merge
1052
1191
  * (the engine finds the *nearest* `:::` closer for each opener).
1053
1192
  */
1054
- 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
+ }
1055
1206
  function splitFrontmatter(source) {
1056
1207
  const match = source.match(/^---\n[\s\S]*?\n---\n?/);
1057
1208
  if (!match) return {
@@ -1711,6 +1862,118 @@ function parseTitle(meta) {
1711
1862
  return (meta.match(/\btitle="([^"]+)"/) ?? meta.match(/\btitle='([^']+)'/))?.[1];
1712
1863
  }
1713
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
+ /**
1714
1977
  * The canonical Shiki transformer chain for Nimbus. Returns a fresh
1715
1978
  * array each call so callers don't accidentally mutate a shared list.
1716
1979
  *
@@ -1727,11 +1990,57 @@ function defaultCodeTransformers() {
1727
1990
  transformerNotationFocus(),
1728
1991
  transformerNotationErrorLevel(),
1729
1992
  transformerNotationWordHighlight(),
1730
- transformerMetaHighlight(),
1731
- transformerMetaWordHighlight(),
1993
+ nimbusMetaTransformer(),
1732
1994
  titleAndLangTransformer()
1733
1995
  ];
1734
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
+ }
1735
2044
  function titleAndLangTransformer() {
1736
2045
  return {
1737
2046
  name: "nimbus:title-and-lang",
@@ -1741,6 +2050,8 @@ function titleAndLangTransformer() {
1741
2050
  const title = parseTitle(meta);
1742
2051
  preNode.properties = preNode.properties ?? {};
1743
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"];
1744
2055
  const children = [];
1745
2056
  if (title) children.push({
1746
2057
  type: "element",
@@ -1765,13 +2076,16 @@ function titleAndLangTransformer() {
1765
2076
  }]
1766
2077
  });
1767
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;
1768
2085
  return {
1769
2086
  type: "element",
1770
2087
  tagName: "figure",
1771
- properties: {
1772
- class: title ? "nb-code-figure nb-code-figure-titled" : "nb-code-figure",
1773
- "data-nb-lang": lang
1774
- },
2088
+ properties: figureProps,
1775
2089
  children
1776
2090
  };
1777
2091
  }
@@ -3869,7 +4183,10 @@ function nimbus(rawConfig, options = {}) {
3869
4183
  ...config.site ? { site: config.site } : {},
3870
4184
  integrations: integrationsToAdd,
3871
4185
  markdown: {
3872
- processor: options.markdown?.processor ?? satteri(),
4186
+ processor: options.markdown?.processor ?? satteri({
4187
+ hastPlugins: options.markdown?.hastPlugins ?? [],
4188
+ mdastPlugins: options.markdown?.mdastPlugins ?? []
4189
+ }),
3873
4190
  shikiConfig: {
3874
4191
  themes: {
3875
4192
  light: "github-light",
@@ -4201,8 +4518,19 @@ async function getIndexedTopLevel() {
4201
4518
  */
4202
4519
  async function getSidebar(currentSlug, options) {
4203
4520
  const config = await loadNimbusConfig();
4204
- const tree = await buildFullSidebarTree(currentSlug, options?.collection);
4205
- return config.sidebar?.scope === "section" ? scopeToCurrentSection(tree, currentSlug) : tree;
4521
+ const fullTree = await buildFullSidebarTree(currentSlug, options?.collection);
4522
+ let tree = config.sidebar?.scope === "section" ? scopeToCurrentSection(fullTree, currentSlug) : fullTree;
4523
+ const boundaries = config.sidebar?.isolate?.boundaries;
4524
+ if (boundaries && boundaries.length > 0) tree = isolateToBoundary(tree, currentSlug, boundaries);
4525
+ if (options?.transform) {
4526
+ const ctx = deriveTransformCtx(fullTree, currentSlug);
4527
+ tree = await options.transform({
4528
+ tree,
4529
+ currentSlug,
4530
+ ...ctx
4531
+ });
4532
+ }
4533
+ return tree;
4206
4534
  }
4207
4535
  /**
4208
4536
  * Derive one section per top-level group in the sidebar — used by
@@ -4293,13 +4621,74 @@ async function getPrevNext(currentSlug, options) {
4293
4621
  return getPrevNext$1(currentSlug, tree, options?.overrides, validInternalLinks);
4294
4622
  }
4295
4623
  /**
4296
- * Build breadcrumb trail from "/" to the current page.
4297
- *
4298
- * Simple URL-segment derivation. A later iteration may enrich this with
4299
- * sidebar-aware labels.
4624
+ * Build the breadcrumb trail from the active node's ancestry in the nav
4625
+ * tree. Labels come from nav nodes, hrefs from each node's landing — so a
4626
+ * section crumb links to its real landing page and segments with no node
4627
+ * never appear. Index-less folders render as non-interactive crumbs.
4628
+ *
4629
+ * - `collection` — the page's Astro collection; pass `entry.collection` so
4630
+ * versioned pages get version-prefixed hrefs.
4631
+ * - `root` — the leading crumb (default `{ label: "Home", href: "/" }`).
4632
+ * - `resolveLabel` — override a crumb label, or return `null` to drop it.
4633
+ *
4634
+ * Falls back to URL-segment derivation when the page has no node in the
4635
+ * tree, so a stray page still gets a root-anchored trail.
4300
4636
  */
4301
4637
  async function getBreadcrumbs(currentSlug, options) {
4302
- return getBreadcrumbs$1(currentSlug, options?.homeLabel ?? "Home");
4638
+ const path = findActivePath(await buildFullSidebarTree(currentSlug, options?.collection), currentSlug);
4639
+ if (path.length > 0) return assembleBreadcrumbs(options?.root ?? {
4640
+ label: "Home",
4641
+ href: "/"
4642
+ }, path, await Promise.all(path.map((node) => Promise.resolve(options?.resolveLabel?.({
4643
+ node,
4644
+ slug: currentSlug
4645
+ })))));
4646
+ return breadcrumbsFromUrl(currentSlug, options?.root?.label ?? "Home");
4647
+ }
4648
+ /**
4649
+ * Resolve a section's display title(s) for the current page, decoupled so
4650
+ * the rail header and the breadcrumb can differ.
4651
+ *
4652
+ * Derives `sectionSlug` (seg0) and `module` (seg1) from the slug and passes
4653
+ * them to a caller-supplied resolver. The resolver is an argument rather
4654
+ * than config because config is JSON-serialized and cannot carry functions.
4655
+ * `indexEntryId` is currently always `undefined`.
4656
+ */
4657
+ async function getSectionTitle(currentSlug, resolve) {
4658
+ const segs = currentSlug.split("/").filter(Boolean);
4659
+ const sectionSlug = segs[0];
4660
+ if (!sectionSlug) return void 0;
4661
+ return resolve({
4662
+ sectionSlug,
4663
+ module: segs[1],
4664
+ indexEntryId: void 0
4665
+ });
4666
+ }
4667
+ /**
4668
+ * Navigation (breadcrumbs, sidebar active-state, optional prev/next) for a
4669
+ * data-driven route with no content entry of its own — e.g. a catalog page
4670
+ * under `src/pages/[...].astro`.
4671
+ *
4672
+ * Builds the breadcrumb trail to `section` (a real nav node) and appends
4673
+ * `trail` (the leaf). The sidebar is built with `section` as the active
4674
+ * path, so the section node highlights even though the leaf is not in the
4675
+ * tree — the leaf is never injected, keeping the tree and prev/next clean.
4676
+ */
4677
+ async function getRouteNavigation(options) {
4678
+ const { section, trail = [], prevNext = false, collection, resolveLabel } = options;
4679
+ const sidebar = await getSidebar(section, { collection });
4680
+ const breadcrumbs = composeRouteBreadcrumbs(await getBreadcrumbs(section, {
4681
+ collection,
4682
+ resolveLabel
4683
+ }), trail);
4684
+ let pn;
4685
+ if (prevNext) pn = await getPrevNext(section, { sidebarTree: sidebar });
4686
+ return {
4687
+ breadcrumbs,
4688
+ sidebar,
4689
+ activeHref: section,
4690
+ prevNext: pn
4691
+ };
4303
4692
  }
4304
4693
  /**
4305
4694
  * Build an edit URL for a content entry using `config.editPattern`.
@@ -4651,5 +5040,5 @@ async function getVersionStatus(collectionId) {
4651
5040
  }
4652
5041
 
4653
5042
  //#endregion
4654
- 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 };
5043
+ 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 };
4655
5044
  //# sourceMappingURL=index.js.map