@fern-api/fdr-sdk 1.2.39-4ae10c3282 → 1.2.39-4e8e68c5b4

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.
Files changed (102) hide show
  1. package/dist/js/client/FdrClient.js +1 -0
  2. package/dist/js/client/FdrClient.js.map +1 -1
  3. package/dist/js/client/FdrClient.mjs +1 -0
  4. package/dist/js/client/FdrClient.mjs.map +1 -1
  5. package/dist/js/converters/index.js +4 -9
  6. package/dist/js/converters/index.js.map +1 -1
  7. package/dist/js/converters/index.mjs +4 -9
  8. package/dist/js/converters/index.mjs.map +1 -1
  9. package/dist/js/index.js +5 -9
  10. package/dist/js/index.js.map +1 -1
  11. package/dist/js/index.mjs +5 -9
  12. package/dist/js/index.mjs.map +1 -1
  13. package/dist/js/navigation/index.js +4 -9
  14. package/dist/js/navigation/index.js.map +1 -1
  15. package/dist/js/navigation/index.mjs +4 -9
  16. package/dist/js/navigation/index.mjs.map +1 -1
  17. package/dist/js/navigation/ledger-root-builder.js +241 -63
  18. package/dist/js/navigation/ledger-root-builder.js.map +1 -1
  19. package/dist/js/navigation/ledger-root-builder.mjs +241 -63
  20. package/dist/js/navigation/ledger-root-builder.mjs.map +1 -1
  21. package/dist/js/orpc-client.js +1 -0
  22. package/dist/js/orpc-client.js.map +1 -1
  23. package/dist/js/orpc-client.mjs +1 -0
  24. package/dist/js/orpc-client.mjs.map +1 -1
  25. package/dist/navigation/__test__/findNode.authedRedirect.test.d.ts +2 -0
  26. package/dist/navigation/__test__/findNode.authedRedirect.test.d.ts.map +1 -0
  27. package/dist/navigation/__test__/findNode.authedRedirect.test.js +184 -0
  28. package/dist/navigation/__test__/findNode.authedRedirect.test.js.map +1 -0
  29. package/dist/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.d.ts +2 -0
  30. package/dist/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.d.ts.map +1 -0
  31. package/dist/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.js +85 -0
  32. package/dist/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.js.map +1 -0
  33. package/dist/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.d.ts +2 -0
  34. package/dist/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.d.ts.map +1 -0
  35. package/dist/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.js +85 -0
  36. package/dist/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.js.map +1 -0
  37. package/dist/navigation/__test__/ledger-root-builder.landingPage.test.d.ts +2 -0
  38. package/dist/navigation/__test__/ledger-root-builder.landingPage.test.d.ts.map +1 -0
  39. package/dist/navigation/__test__/ledger-root-builder.landingPage.test.js +82 -0
  40. package/dist/navigation/__test__/ledger-root-builder.landingPage.test.js.map +1 -0
  41. package/dist/navigation/__test__/ledger-root-builder.metadataPreservation.test.d.ts +2 -0
  42. package/dist/navigation/__test__/ledger-root-builder.metadataPreservation.test.d.ts.map +1 -0
  43. package/dist/navigation/__test__/ledger-root-builder.metadataPreservation.test.js +1031 -0
  44. package/dist/navigation/__test__/ledger-root-builder.metadataPreservation.test.js.map +1 -0
  45. package/dist/navigation/__test__/ledger-root-builder.rootChildOrder.test.js +22 -9
  46. package/dist/navigation/__test__/ledger-root-builder.rootChildOrder.test.js.map +1 -1
  47. package/dist/navigation/__test__/ledger-root-builder.rootSlug.test.d.ts +2 -0
  48. package/dist/navigation/__test__/ledger-root-builder.rootSlug.test.d.ts.map +1 -0
  49. package/dist/navigation/__test__/ledger-root-builder.rootSlug.test.js +47 -0
  50. package/dist/navigation/__test__/ledger-root-builder.rootSlug.test.js.map +1 -0
  51. package/dist/navigation/__test__/ledger-root-builder.skipSlugApiReference.test.d.ts +2 -0
  52. package/dist/navigation/__test__/ledger-root-builder.skipSlugApiReference.test.d.ts.map +1 -0
  53. package/dist/navigation/__test__/ledger-root-builder.skipSlugApiReference.test.js +210 -0
  54. package/dist/navigation/__test__/ledger-root-builder.skipSlugApiReference.test.js.map +1 -0
  55. package/dist/navigation/__test__/ledger-root-builder.variantSharedSegments.test.d.ts +2 -0
  56. package/dist/navigation/__test__/ledger-root-builder.variantSharedSegments.test.d.ts.map +1 -0
  57. package/dist/navigation/__test__/ledger-root-builder.variantSharedSegments.test.js +262 -0
  58. package/dist/navigation/__test__/ledger-root-builder.variantSharedSegments.test.js.map +1 -0
  59. package/dist/navigation/ledger-root-builder.d.ts +17 -1
  60. package/dist/navigation/ledger-root-builder.d.ts.map +1 -1
  61. package/dist/navigation/ledger-root-builder.js +318 -80
  62. package/dist/navigation/ledger-root-builder.js.map +1 -1
  63. package/dist/navigation/utils/findNode.d.ts +1 -0
  64. package/dist/navigation/utils/findNode.d.ts.map +1 -1
  65. package/dist/navigation/utils/findNode.js +3 -11
  66. package/dist/navigation/utils/findNode.js.map +1 -1
  67. package/dist/navigation/utils/followRedirect.d.ts.map +1 -1
  68. package/dist/navigation/utils/followRedirect.js +2 -0
  69. package/dist/navigation/utils/followRedirect.js.map +1 -1
  70. package/dist/orpc-client/docs-ledger/contract.d.ts +50 -0
  71. package/dist/orpc-client/docs-ledger/contract.d.ts.map +1 -1
  72. package/dist/orpc-client/docs-ledger/contract.js +1 -0
  73. package/dist/orpc-client/docs-ledger/contract.js.map +1 -1
  74. package/dist/orpc-client/docs-ledger/ledger-manifest.d.ts +12 -0
  75. package/dist/orpc-client/docs-ledger/ledger-manifest.d.ts.map +1 -1
  76. package/dist/tsconfig.tsbuildinfo +1 -1
  77. package/dist/types/navigation/__test__/findNode.authedRedirect.test.d.ts +2 -0
  78. package/dist/types/navigation/__test__/findNode.authedRedirect.test.d.ts.map +1 -0
  79. package/dist/types/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.d.ts +2 -0
  80. package/dist/types/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.d.ts.map +1 -0
  81. package/dist/types/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.d.ts +2 -0
  82. package/dist/types/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.d.ts.map +1 -0
  83. package/dist/types/navigation/__test__/ledger-root-builder.landingPage.test.d.ts +2 -0
  84. package/dist/types/navigation/__test__/ledger-root-builder.landingPage.test.d.ts.map +1 -0
  85. package/dist/types/navigation/__test__/ledger-root-builder.metadataPreservation.test.d.ts +2 -0
  86. package/dist/types/navigation/__test__/ledger-root-builder.metadataPreservation.test.d.ts.map +1 -0
  87. package/dist/types/navigation/__test__/ledger-root-builder.rootSlug.test.d.ts +2 -0
  88. package/dist/types/navigation/__test__/ledger-root-builder.rootSlug.test.d.ts.map +1 -0
  89. package/dist/types/navigation/__test__/ledger-root-builder.skipSlugApiReference.test.d.ts +2 -0
  90. package/dist/types/navigation/__test__/ledger-root-builder.skipSlugApiReference.test.d.ts.map +1 -0
  91. package/dist/types/navigation/__test__/ledger-root-builder.variantSharedSegments.test.d.ts +2 -0
  92. package/dist/types/navigation/__test__/ledger-root-builder.variantSharedSegments.test.d.ts.map +1 -0
  93. package/dist/types/navigation/ledger-root-builder.d.ts +17 -1
  94. package/dist/types/navigation/ledger-root-builder.d.ts.map +1 -1
  95. package/dist/types/navigation/utils/findNode.d.ts +1 -0
  96. package/dist/types/navigation/utils/findNode.d.ts.map +1 -1
  97. package/dist/types/navigation/utils/followRedirect.d.ts.map +1 -1
  98. package/dist/types/orpc-client/docs-ledger/contract.d.ts +50 -0
  99. package/dist/types/orpc-client/docs-ledger/contract.d.ts.map +1 -1
  100. package/dist/types/orpc-client/docs-ledger/ledger-manifest.d.ts +12 -0
  101. package/dist/types/orpc-client/docs-ledger/ledger-manifest.d.ts.map +1 -1
  102. package/package.json +1 -1
@@ -1,4 +1,12 @@
1
1
  import * as FernNavigation from "./index.js";
2
+ const STRUCTURAL_SEGMENT_TYPES = new Set([
3
+ "section",
4
+ "apiReference",
5
+ "apiPackage",
6
+ "changelog"
7
+ ]);
8
+ const API_NODE_SEGMENT_TYPES = new Set(["apiReference", "apiPackage"]);
9
+ const INFRA_SEGMENT_TYPES = new Set(["files", "redirects"]);
2
10
  /** @internal Exported for testing only. */
3
11
  export function createBuildContext(basePath) {
4
12
  // Tracks how many times each id key has been handed out, so collisions get
@@ -109,10 +117,7 @@ function toViewers(viewers) {
109
117
  * cross-variant prev/next links to disclose restricted content.
110
118
  */
111
119
  function mergeVariantViewers(seg) {
112
- const segViewers = seg.metadata.type === "section" ||
113
- seg.metadata.type === "apiReference" ||
114
- seg.metadata.type === "apiPackage" ||
115
- seg.metadata.type === "changelog"
120
+ const segViewers = STRUCTURAL_SEGMENT_TYPES.has(seg.metadata.type)
116
121
  ? seg.metadata.viewers
117
122
  : undefined;
118
123
  const variantViewers = seg.variant?.viewers;
@@ -330,6 +335,63 @@ function apiLeafNodeFromNavRouteInternal(ctx, route, segmentApiDefinitionId) {
330
335
  }
331
336
  return undefined;
332
337
  }
338
+ function apiLeafPositionKey(node) {
339
+ switch (node.type) {
340
+ case "endpoint":
341
+ return `endpoint:${node.endpointId}`;
342
+ case "webhook":
343
+ return `webhook:${node.webhookId}`;
344
+ case "webSocket":
345
+ return `webSocket:${node.webSocketId}`;
346
+ case "grpc":
347
+ return `grpc:${node.grpcId}`;
348
+ case "graphql":
349
+ return `graphql:${node.graphqlOperationId}`;
350
+ }
351
+ }
352
+ function apiChildPositionKey(node) {
353
+ switch (node.type) {
354
+ case "endpoint":
355
+ case "webhook":
356
+ case "webSocket":
357
+ case "grpc":
358
+ case "graphql":
359
+ return apiLeafPositionKey(node);
360
+ default:
361
+ return undefined;
362
+ }
363
+ }
364
+ function positionedApiLeafChildren(ctx, nav, apiDefinitionId) {
365
+ const rawLeafNodes = [];
366
+ const leafPositions = new Map();
367
+ const nonApiLeafNodes = [];
368
+ for (let i = 0; i < nav.length; i++) {
369
+ const route = nav[i];
370
+ const pos = route.displaySortOrder ?? i;
371
+ const apiLeaf = apiLeafNodeFromNavRouteInternal(ctx, route, apiDefinitionId);
372
+ if (apiLeaf != null) {
373
+ rawLeafNodes.push(apiLeaf);
374
+ const key = apiLeafPositionKey(apiLeaf);
375
+ leafPositions.set(key, Math.min(leafPositions.get(key) ?? pos, pos));
376
+ continue;
377
+ }
378
+ const node = artifactToNode(ctx, route);
379
+ if (node?.type === "page" || node?.type === "link") {
380
+ nonApiLeafNodes.push({ node, pos });
381
+ }
382
+ }
383
+ const apiLeafNodes = mergeEndpointPairs(ctx, rawLeafNodes, nav);
384
+ const positionedApiLeafNodes = apiLeafNodes.map((node, index) => {
385
+ if (node.type === "endpointPair") {
386
+ const streamPos = leafPositions.get(apiLeafPositionKey(node.stream)) ?? index;
387
+ const nonStreamPos = leafPositions.get(apiLeafPositionKey(node.nonStream)) ?? index;
388
+ return { node, pos: Math.min(streamPos, nonStreamPos) };
389
+ }
390
+ const key = apiChildPositionKey(node);
391
+ return { node, pos: key != null ? (leafPositions.get(key) ?? index) : index };
392
+ });
393
+ return [...positionedApiLeafNodes, ...nonApiLeafNodes];
394
+ }
333
395
  /**
334
396
  * Collect every endpoint/webhook navigation node from a single API segment's
335
397
  * nav routes. The root builder now populates apiReference children directly,
@@ -344,7 +406,11 @@ export function collectApiLeafNodesFromNav(routes, basePath, segmentApiDefinitio
344
406
  .map((route) => apiLeafNodeFromNavRouteInternal(ctx, route, segmentApiDefinitionId))
345
407
  .filter((node) => node != null);
346
408
  }
347
- /** Read the apiDefinitionId off a segment's metadata, or undefined. */
409
+ /**
410
+ * Read the apiDefinitionId off a segment's metadata, or undefined.
411
+ * The matched types (apiReference, apiPackage, changelog) are the
412
+ * subset of {@link StructuralSegmentType} that carry an apiDefinitionId.
413
+ */
348
414
  export function segmentApiDefinitionId(metadata) {
349
415
  switch (metadata.type) {
350
416
  case "apiReference":
@@ -524,14 +590,10 @@ function sectionNode(ctx, scoped, sectionChildren, scopePrefix) {
524
590
  return {
525
591
  type: "section",
526
592
  ...defaults,
527
- // V2 convention: collapsed:false in docs.yml maps to "open-by-default"
528
- // so getInitiallyOpenByDefaultNodes picks it up and the UI starts open.
529
- collapsed: metaCollapsed(seg.metadata) === false
530
- ? "open-by-default"
531
- : (metaCollapsed(seg.metadata) ?? defaults.collapsed),
593
+ collapsed: metaCollapsed(seg.metadata) ?? defaults.collapsed,
532
594
  overviewPageId: overviewPageId != null ? FernNavigation.PageId(overviewPageId) : undefined,
533
595
  noindex: metaBool(overview?.metadata, "noindex") ?? metaBool(seg.metadata, "noindex"),
534
- collapsible: metaBool(seg.metadata, "collapsible") ?? (metaCollapsed(seg.metadata) != null ? true : undefined),
596
+ collapsible: metaBool(seg.metadata, "collapsible") ?? (metaCollapsed(seg.metadata) === true ? true : undefined),
535
597
  collapsedByDefault: metaBool(seg.metadata, "collapsedByDefault") ?? (metaCollapsed(seg.metadata) === true ? true : undefined),
536
598
  availability: metaStr(seg.metadata, "availability"),
537
599
  pointsTo: metaPointsTo != null ? pointsToSlug(metaPointsTo) : undefined,
@@ -569,11 +631,10 @@ function directChildApiPackages(parentSection, scopeSegs) {
569
631
  function buildApiChildren(ctx, scoped, scopeSegs, apiDefinitionId, scopePrefix) {
570
632
  const { seg, nav } = scoped;
571
633
  // Build leaf nodes from this segment's own nav routes (not descendants).
572
- const rawLeafNodes = nav
573
- .map((route) => apiLeafNodeFromNavRouteInternal(ctx, route, apiDefinitionId))
574
- .filter((node) => node != null);
575
- // Merge stream/non-stream endpoint pairs into EndpointPairNodes.
576
- const leafNodes = mergeEndpointPairs(ctx, rawLeafNodes, nav);
634
+ // API references can contain normal docs pages and links in addition to
635
+ // endpoint leaves, so include markdown/link artifacts here instead of only
636
+ // API leaf types.
637
+ const leafNodes = positionedApiLeafChildren(ctx, nav, apiDefinitionId);
577
638
  // Build nested ApiPackageNodes from direct child apiPackage segments.
578
639
  const childPackages = directChildApiPackages(seg.section, scopeSegs);
579
640
  const packageNodes = childPackages.map((child) => ({
@@ -583,18 +644,12 @@ function buildApiChildren(ctx, scoped, scopeSegs, apiDefinitionId, scopePrefix)
583
644
  // Interleave by position when childIndex metadata is available
584
645
  const hasChildIndex = packageNodes.some((p) => p.childIndex != null);
585
646
  if (!hasChildIndex) {
586
- return [...leafNodes, ...packageNodes.map((p) => p.node)];
587
- }
588
- const leafPositions = [];
589
- for (const route of nav) {
590
- if (route.displaySortOrder != null) {
591
- leafPositions.push(route.displaySortOrder);
592
- }
647
+ return [...leafNodes, ...packageNodes.map((p) => ({ node: p.node, pos: Number.MAX_SAFE_INTEGER }))]
648
+ .sort((a, b) => a.pos - b.pos)
649
+ .map((p) => p.node);
593
650
  }
594
651
  const positioned = [];
595
- for (let i = 0; i < leafNodes.length; i++) {
596
- positioned.push({ node: leafNodes[i], pos: leafPositions[i] ?? i });
597
- }
652
+ positioned.push(...leafNodes);
598
653
  for (const p of packageNodes) {
599
654
  positioned.push({ node: p.node, pos: p.childIndex ?? Number.MAX_SAFE_INTEGER });
600
655
  }
@@ -803,7 +858,7 @@ function changelogNode(ctx, scoped, scopePrefix) {
803
858
  pageId: FernNavigation.PageId(metaStr(r.metadata, "pageId") ?? ""),
804
859
  noindex: metaBool(r.metadata, "noindex"),
805
860
  date: dateStr ?? "",
806
- tags: undefined
861
+ tags: metaStrArr(r.metadata, "tags")
807
862
  };
808
863
  let months = byYear.get(year);
809
864
  if (months == null) {
@@ -916,7 +971,7 @@ function buildSectionTree(ctx, scopeSegs, parentPath, scopePrefix, changelogScop
916
971
  const type = metaStr(s.seg.metadata, "type");
917
972
  const childIndex = metaNum(s.seg.metadata, "childIndex");
918
973
  let node;
919
- if (type === "apiReference" || type === "apiPackage") {
974
+ if (API_NODE_SEGMENT_TYPES.has(type ?? "")) {
920
975
  node = apiReferenceNode(ctx, s, scopeSegs, scopePrefix);
921
976
  }
922
977
  else if (type === "changelog") {
@@ -930,6 +985,54 @@ function buildSectionTree(ctx, scopeSegs, parentPath, scopePrefix, changelogScop
930
985
  });
931
986
  }
932
987
  // ── build a sidebarRoot for a single scope ─────────────────────────────────
988
+ /**
989
+ * When multiple root-level sections share the same section path (common for
990
+ * versioned docs where all tab-level sections receive the version slug as
991
+ * their path), we need to scope buildSectionTree so each section only adopts
992
+ * the child segments that physically belong to it.
993
+ *
994
+ * Ownership is determined by sortOrder ranges: a section owns segments whose
995
+ * sortOrder falls between its own and the next sibling section's sortOrder.
996
+ * Returns a map only for sections that have duplicates; unique sections won't
997
+ * appear (and should pass unfiltered scopeSegs to buildSectionTree).
998
+ */
999
+ function computeSiblingSortBoundaries(ordered) {
1000
+ const result = new Map();
1001
+ // Collect root-level sections grouped by their section path.
1002
+ const sectionPaths = new Set(ordered.map((s) => s.seg.section));
1003
+ const rootSections = [];
1004
+ for (const s of ordered) {
1005
+ const type = metaStr(s.seg.metadata, "type");
1006
+ const sec = s.seg.section;
1007
+ if (type !== "section" || sec === "") {
1008
+ continue;
1009
+ }
1010
+ const parent = parentSectionPath(sec);
1011
+ const isRootLevel = parent === "" || (!sectionPaths.has(parent) && nearestExistingAncestor(sec, sectionPaths) === "");
1012
+ if (isRootLevel) {
1013
+ rootSections.push(s);
1014
+ }
1015
+ }
1016
+ // Group by section path — only paths with >1 section need boundaries.
1017
+ const byPath = new Map();
1018
+ for (const s of rootSections) {
1019
+ const group = byPath.get(s.seg.section) ?? [];
1020
+ group.push(s);
1021
+ byPath.set(s.seg.section, group);
1022
+ }
1023
+ for (const [, siblings] of byPath) {
1024
+ if (siblings.length <= 1) {
1025
+ continue;
1026
+ }
1027
+ // Siblings are already in sortOrder (from `ordered`).
1028
+ for (let i = 0; i < siblings.length; i++) {
1029
+ const lo = siblings[i].seg.sortOrder;
1030
+ const hi = i + 1 < siblings.length ? siblings[i + 1].seg.sortOrder : Number.MAX_SAFE_INTEGER;
1031
+ result.set(siblings[i], { lo, hi });
1032
+ }
1033
+ }
1034
+ return result;
1035
+ }
933
1036
  /**
934
1037
  * Assemble the children of a scope's sidebar root in cross-segment order.
935
1038
  * `tabRoot` segments contribute their page/link artifacts inline at their
@@ -957,7 +1060,7 @@ function buildSidebarRootChildren(ctx, scopeSegs, scopePrefix, changelogScopePre
957
1060
  //
958
1061
  // hasRootSection = true
959
1062
  // │
960
- // ├─ (A) tabRoot / loose artifacts (sec=="", type!="section")
1063
+ // ├─ (A) tabRoot / loose artifacts (sec=="", !STRUCTURAL_SEGMENT_TYPES)
961
1064
  // │ → emit pages/links inline at their displaySortOrder
962
1065
  // │
963
1066
  // ├─ (B) the root section itself (sec=="", type=="section")
@@ -969,14 +1072,27 @@ function buildSidebarRootChildren(ctx, scopeSegs, scopePrefix, changelogScopePre
969
1072
  //
970
1073
  // Without a root section, depth-0 sections are emitted directly.
971
1074
  const hasRootSection = ordered.some((s) => metaStr(s.seg.metadata, "type") === "section" && s.seg.section === "");
1075
+ // ── Sibling section ownership ────────────────────────────────────────
1076
+ //
1077
+ // Multiple root-level sections can share the same section path (e.g.
1078
+ // three sections all with path equal to the version slug). When this
1079
+ // happens, buildSectionTree would incorrectly give ALL child subsections
1080
+ // to each sibling. We resolve ownership via sortOrder: a section owns
1081
+ // segments whose sortOrder falls between its own and the next sibling's.
1082
+ const siblingBoundaries = computeSiblingSortBoundaries(ordered);
972
1083
  for (const s of ordered) {
973
1084
  const type = metaStr(s.seg.metadata, "type");
974
1085
  const sec = s.seg.section;
975
1086
  if (hasOwningApiReference(s, scopeSegs)) {
976
1087
  continue;
977
1088
  }
978
- // (A) Loose artifacts: tabRoot or non-section segments at section path "".
979
- if (type === "tabRoot" || (sec === "" && type !== "section")) {
1089
+ if (type === "tabRoot" && metaBool(s.seg.metadata, "landingPage") === true) {
1090
+ continue;
1091
+ }
1092
+ // (A) Loose artifacts: tabRoot or non-structural segments at section path "".
1093
+ // Structural types need their own node builders in (B)/(C) even when
1094
+ // their section path is "" (which happens with skip-slug: true).
1095
+ if (type === "tabRoot" || (sec === "" && !STRUCTURAL_SEGMENT_TYPES.has(type ?? ""))) {
980
1096
  for (const r of s.nav) {
981
1097
  const node = artifactToNode(ctx, r);
982
1098
  if (node != null) {
@@ -996,21 +1112,41 @@ function buildSidebarRootChildren(ctx, scopeSegs, scopePrefix, changelogScopePre
996
1112
  // adopted by a root section (B).
997
1113
  const parent = parentSectionPath(sec);
998
1114
  const isRootLevel = parent === "" || (!sectionPaths.has(parent) && nearestExistingAncestor(sec, sectionPaths) === "");
999
- if (isRootLevel && hasRootSection) {
1115
+ // Skip depth-0 sections already adopted by the root section wrapper (B),
1116
+ // but NOT structural types with sec="" (skip-slug). buildSectionTree
1117
+ // filters out sec="" segments (line 1249), so they wouldn't be adopted
1118
+ // and must be emitted here directly.
1119
+ if (isRootLevel && hasRootSection && sec !== "") {
1000
1120
  continue;
1001
1121
  }
1002
1122
  if (isRootLevel) {
1003
1123
  let node;
1004
- if (type === "apiReference" || type === "apiPackage") {
1124
+ if (API_NODE_SEGMENT_TYPES.has(type ?? "")) {
1005
1125
  node = apiReferenceNode(ctx, s, scopeSegs, scopePrefix);
1006
1126
  }
1007
1127
  else if (type === "changelog") {
1008
1128
  node = changelogNode(ctx, s, changelogScopePrefix ?? scopePrefix);
1009
1129
  }
1010
1130
  else {
1011
- node = sectionNode(ctx, s, buildSectionTree(ctx, scopeSegs, sec, scopePrefix, changelogScopePrefix), scopePrefix);
1131
+ // Scope child segments to this section's sortOrder range when
1132
+ // sibling sections share the same path.
1133
+ const bounds = siblingBoundaries.get(s);
1134
+ const ownedSegs = bounds != null
1135
+ ? scopeSegs.filter((seg) => seg.seg.sortOrder >= bounds.lo && seg.seg.sortOrder < bounds.hi)
1136
+ : scopeSegs;
1137
+ node = sectionNode(ctx, s, buildSectionTree(ctx, ownedSegs, sec, scopePrefix, changelogScopePrefix), scopePrefix);
1012
1138
  }
1013
- annotated.push({ node, pos: metaNum(s.seg.metadata, "childIndex"), isLeaf: false });
1139
+ // Shared/merged sections (paths not rooted in scopePrefix) must not
1140
+ // carry childIndex — their source-version positions would sort them
1141
+ // ahead of native sections. Only applies when sibling sections exist
1142
+ // (multiple sections sharing same path), indicating a versioned site
1143
+ // where shared sections were merged from a sibling version.
1144
+ const isSharedSection = siblingBoundaries.size > 0 && scopePrefix !== "" && sec !== "" && !sec.startsWith(scopePrefix);
1145
+ annotated.push({
1146
+ node,
1147
+ pos: isSharedSection ? undefined : metaNum(s.seg.metadata, "childIndex"),
1148
+ isLeaf: false
1149
+ });
1014
1150
  }
1015
1151
  }
1016
1152
  // Interleave by V1 position only when BOTH leaves and segments carry one.
@@ -1094,7 +1230,7 @@ function buildSidebarRoot(ctx, scopeSegs, scopePrefix, changelogScopePrefix) {
1094
1230
  * Build a full (un-pruned) `RootNode` from the decomposed ledger model.
1095
1231
  */
1096
1232
  export function buildFullRootFromSegments(options) {
1097
- const { basePath, title, segments, navBySegment } = options;
1233
+ const { basePath, rootSlug: sourceRootSlug, title, segments, navBySegment, skipSharedSectionMerge } = options;
1098
1234
  // Per-call state — never shared across concurrent builds.
1099
1235
  const ctx = createBuildContext(basePath);
1100
1236
  const scoped = segments
@@ -1102,10 +1238,10 @@ export function buildFullRootFromSegments(options) {
1102
1238
  // but guard here too.
1103
1239
  .filter((seg) => {
1104
1240
  const t = metaStr(seg.metadata, "type");
1105
- return t !== "files" && t !== "redirects";
1241
+ return !INFRA_SEGMENT_TYPES.has(t ?? "");
1106
1242
  })
1107
1243
  .map((seg) => ({ seg, nav: navBySegment.get(seg.segmentHash) ?? [] }));
1108
- const rootSlug = asSlug(basePath);
1244
+ const rootSlug = asSlug(sourceRootSlug ?? basePath);
1109
1245
  // Group by scope identity.
1110
1246
  const scopesByKey = new Map();
1111
1247
  for (const s of scoped) {
@@ -1119,10 +1255,10 @@ export function buildFullRootFromSegments(options) {
1119
1255
  const tabs = uniqueDetails(scoped, "tab");
1120
1256
  let child;
1121
1257
  if (products.length > 0) {
1122
- child = buildProductGroup(ctx, products, scoped, rootSlug);
1258
+ child = buildProductGroup(ctx, products, scoped, rootSlug, skipSharedSectionMerge);
1123
1259
  }
1124
1260
  else if (versions.length > 0) {
1125
- child = buildVersioned(ctx, versions, scoped, rootSlug, undefined);
1261
+ child = buildVersioned(ctx, versions, scoped, rootSlug, undefined, skipSharedSectionMerge);
1126
1262
  }
1127
1263
  else {
1128
1264
  child = buildUnversioned(ctx, scoped, rootSlug, tabs.length > 0);
@@ -1277,22 +1413,36 @@ function buildSidebarRootWithVariants(ctx, scopeSegs, scopePrefix, changelogScop
1277
1413
  if (variants.length <= 1) {
1278
1414
  return buildSidebarRoot(ctx, scopeSegs, scopePrefix, changelogScopePrefix);
1279
1415
  }
1416
+ const sharedSegs = scopeSegs.filter((s) => s.seg.variant == null);
1280
1417
  const variantedNode = buildVariantedNode(ctx, variants, scopeSegs, scopePrefix);
1418
+ const sharedChildren = sharedSegs.length > 0 ? buildSidebarRootChildren(ctx, sharedSegs, scopePrefix, changelogScopePrefix) : [];
1281
1419
  const first = scopeSegs[0];
1282
1420
  return {
1283
1421
  type: "sidebarRoot",
1284
1422
  id: ctx.nodeId(`sidebar:${first != null ? scopeKey(first.seg) : "root"}`),
1285
1423
  collapsed: undefined,
1286
- children: [variantedNode]
1424
+ children: [...sharedChildren, variantedNode]
1287
1425
  };
1288
1426
  }
1289
1427
  function buildTabbedOrSidebar(ctx, scopeSegs, tabsPresent, scopePrefix) {
1290
1428
  if (!tabsPresent) {
1291
- // When no tabs exist but variants do, emit a VariantedNode directly
1292
- // so each variant gets its own sidebar scope.
1429
+ // When no tabs exist but variants do, wrap variant-scoped segments
1430
+ // in a VariantedNode and include non-variant segments alongside it.
1293
1431
  const variants = uniqueDetails(scopeSegs, "variant");
1294
1432
  if (variants.length > 1) {
1295
- return buildVariantedNode(ctx, variants, scopeSegs, scopePrefix);
1433
+ const sharedSegs = scopeSegs.filter((s) => s.seg.variant == null);
1434
+ const variantedNode = buildVariantedNode(ctx, variants, scopeSegs, scopePrefix);
1435
+ if (sharedSegs.length > 0) {
1436
+ const sharedChildren = buildSidebarRootChildren(ctx, sharedSegs, scopePrefix);
1437
+ const first = scopeSegs[0];
1438
+ return {
1439
+ type: "sidebarRoot",
1440
+ id: ctx.nodeId(`sidebar:${first != null ? scopeKey(first.seg) : "root"}`),
1441
+ collapsed: undefined,
1442
+ children: [...sharedChildren, variantedNode]
1443
+ };
1444
+ }
1445
+ return variantedNode;
1296
1446
  }
1297
1447
  return buildSidebarRoot(ctx, scopeSegs, scopePrefix);
1298
1448
  }
@@ -1400,16 +1550,101 @@ function buildUnversioned(ctx, scopeSegs, scopePrefix, tabsPresent) {
1400
1550
  type: "unversioned",
1401
1551
  id: ctx.nodeId(`unversioned:${first?.seg.product?.id ?? "root"}`),
1402
1552
  collapsed: undefined,
1403
- landingPage: undefined,
1553
+ landingPage: buildLandingPage(ctx, scopeSegs),
1404
1554
  // The enclosing scope prefix (product slug, or rootSlug at the top
1405
1555
  // level) roots structural sections so they share the scope's slug
1406
1556
  // namespace.
1407
1557
  child: buildTabbedOrSidebar(ctx, scopeSegs, tabsPresent, scopePrefix)
1408
1558
  };
1409
1559
  }
1410
- function buildVersioned(ctx, versions, scoped, rootSlug, productPrefix) {
1560
+ /**
1561
+ * Merge shared (tab-level) sections from sibling versions into the default
1562
+ * version's segment list. A section is considered "shared" when its section
1563
+ * path does NOT start with any version slug — indicating it was authored at
1564
+ * the tab level and the publish transform assigned it to only one version.
1565
+ */
1566
+ function mergeSharedSections(defaultSegs, allScoped, defaultVersion, versionSlugs) {
1567
+ // Build a map of the default version's tabs by displayName for
1568
+ // cross-version matching. Tab slugs can't be used because the "default"
1569
+ // tab's slug equals the version slug (e.g. "2026-02-09" vs "2025-04-21"
1570
+ // for the same logical "Documentation" tab across versions).
1571
+ const defaultTabsByName = new Map();
1572
+ for (const s of defaultSegs) {
1573
+ if (s.seg.tab != null) {
1574
+ const tabName = s.seg.tab.displayName;
1575
+ if (!defaultTabsByName.has(tabName)) {
1576
+ defaultTabsByName.set(tabName, s.seg.tab);
1577
+ }
1578
+ }
1579
+ }
1580
+ // Collect section paths already present in the default version.
1581
+ const existingSections = new Set(defaultSegs.filter((s) => metaStr(s.seg.metadata, "type") === "section").map((s) => s.seg.section));
1582
+ // Merged sections must sort AFTER all native default segments so they
1583
+ // appear in a trailing sidebarGroup (after non-collapsible sections like
1584
+ // "Getting Started", "Dev Tools", "Capabilities") matching v2 ordering.
1585
+ const maxSortOrder = defaultSegs.reduce((max, s) => Math.max(max, s.seg.sortOrder), 0);
1586
+ let nextSortOrder = maxSortOrder + 1;
1587
+ const merged = [...defaultSegs];
1588
+ for (const s of allScoped) {
1589
+ if (s.seg.version?.id === defaultVersion.id) {
1590
+ continue;
1591
+ }
1592
+ const type = metaStr(s.seg.metadata, "type");
1593
+ if (type !== "section") {
1594
+ continue;
1595
+ }
1596
+ const sectionPath = s.seg.section;
1597
+ // Skip version-specific sections (path starts with a version slug).
1598
+ if (versionSlugs.some((vs) => sectionPath === vs || sectionPath.startsWith(vs + "/"))) {
1599
+ continue;
1600
+ }
1601
+ // Skip if the default version already has this section.
1602
+ if (existingSections.has(sectionPath)) {
1603
+ continue;
1604
+ }
1605
+ // Match the source tab to the default version's corresponding tab
1606
+ // by displayName (tab slugs differ across versions for the default tab).
1607
+ const sourceTabName = s.seg.tab?.displayName ?? "";
1608
+ const targetTab = defaultTabsByName.get(sourceTabName);
1609
+ if (targetTab == null) {
1610
+ continue;
1611
+ }
1612
+ // Re-map the segment's tab and version to the default version's.
1613
+ // Strip childIndex from metadata so merged sections don't sort before
1614
+ // native sections in the annotated interleave step, and assign a
1615
+ // sortOrder after all native segments to place them in the trailing
1616
+ // sidebarGroup.
1617
+ const metadata = s.seg.metadata != null ? { ...s.seg.metadata, childIndex: undefined } : s.seg.metadata;
1618
+ merged.push({
1619
+ seg: {
1620
+ ...s.seg,
1621
+ tab: targetTab,
1622
+ version: defaultVersion,
1623
+ sortOrder: nextSortOrder++,
1624
+ metadata
1625
+ },
1626
+ nav: s.nav
1627
+ });
1628
+ existingSections.add(sectionPath);
1629
+ }
1630
+ return merged;
1631
+ }
1632
+ function buildVersioned(ctx, versions, scoped, rootSlug, productPrefix, skipSharedSectionMerge) {
1633
+ // Collect version slugs for detecting version-prefixed section paths.
1634
+ const versionSlugs = versions.map((v) => detailSlug(v)).filter((s) => s !== "");
1411
1635
  const children = versions.map((version) => {
1412
- const versionSegs = scoped.filter((s) => s.seg.version?.id === version.id);
1636
+ let versionSegs = scoped.filter((s) => s.seg.version?.id === version.id);
1637
+ // Merge shared tab-level sections from sibling versions into the
1638
+ // default version. The publish transform may assign tab-level content
1639
+ // (sections without a version-prefixed path) to a single version;
1640
+ // v2's monolith root includes them in every version's sidebar.
1641
+ // Skipped for scoped roots: the scoped root only loads nav for a
1642
+ // single route's scope, so merged sections carry the source version's
1643
+ // fullPaths into the default tree, creating duplicate slugs that
1644
+ // orphan the real version's pages in NodeCollector.
1645
+ if (detailIsDefault(version) && !skipSharedSectionMerge) {
1646
+ versionSegs = mergeSharedSections(versionSegs, scoped, version, versionSlugs);
1647
+ }
1413
1648
  const tabsPresent = uniqueDetails(versionSegs, "tab").length > 0;
1414
1649
  const prefix = productPrefix != null ? productPrefix : rootSlug;
1415
1650
  // The version node always carries its FULL versioned slug
@@ -1446,7 +1681,7 @@ function buildVersioned(ctx, versions, scoped, rootSlug, productPrefix) {
1446
1681
  default: detailIsDefault(version),
1447
1682
  versionId: FernNavigation.VersionId(version.id),
1448
1683
  availability: undefined,
1449
- landingPage: buildVersionLandingPage(ctx, versionSegs, asSlug(slug)),
1684
+ landingPage: buildLandingPage(ctx, versionSegs),
1450
1685
  announcement: undefined,
1451
1686
  pointsTo: metaStr(version.metadata, "pointsTo") != null
1452
1687
  ? pointsToSlug(metaStr(version.metadata, "pointsTo"))
@@ -1552,38 +1787,41 @@ function crossVersionDedupKeys(node, parents) {
1552
1787
  keys.push([...parentTitles, node.title].join("###"));
1553
1788
  return keys;
1554
1789
  }
1555
- /**
1556
- * Build a LandingPageNode from tab-less segments (segments with no tab_id).
1557
- * In V1, these are version-level landing pages that sit outside any tab.
1558
- */
1559
- function buildVersionLandingPage(ctx, versionSegs, versionSlug) {
1560
- const tablessSegs = versionSegs.filter((s) => s.seg.tab == null);
1561
- if (tablessSegs.length === 0) {
1790
+ function routeToLandingPage(ctx, route) {
1791
+ if (route == null || route.type !== "markdown" || route.fullPath == null) {
1562
1792
  return undefined;
1563
1793
  }
1564
- for (const s of tablessSegs) {
1565
- for (const r of s.nav) {
1566
- if (r.type === "markdown" && r.fullPath != null) {
1567
- const md = r.metadata;
1568
- const pageId = metaStr(md, "pageId") ?? "";
1569
- const slug = ctx.contentSlug(r.fullPath);
1570
- return {
1571
- type: "landingPage",
1572
- ...withMetadataDefaults({
1573
- id: ctx.nodeId(`landing:${slug}`),
1574
- title: metaStr(md, "title") ?? "",
1575
- slug,
1576
- icon: metaStr(md, "icon"),
1577
- hidden: r.hidden,
1578
- viewers: toViewers(metaStrArr(md, "viewers"))
1579
- }),
1580
- pageId: FernNavigation.PageId(pageId),
1581
- noindex: metaBool(md, "noindex")
1582
- };
1583
- }
1584
- }
1794
+ const md = route.metadata;
1795
+ const pageId = metaStr(md, "pageId");
1796
+ if (pageId == null) {
1797
+ return undefined;
1585
1798
  }
1586
- return undefined;
1799
+ const slug = ctx.contentSlug(route.fullPath);
1800
+ return {
1801
+ type: "landingPage",
1802
+ ...withMetadataDefaults({
1803
+ id: ctx.nodeId(`landing:${slug}`),
1804
+ title: metaStr(md, "title") ?? "",
1805
+ slug,
1806
+ icon: metaStr(md, "icon"),
1807
+ hidden: route.hidden,
1808
+ authed: metaBool(md, "authed"),
1809
+ viewers: toViewers(metaStrArr(md, "viewers")),
1810
+ orphaned: metaBool(md, "orphaned"),
1811
+ featureFlags: metaFeatureFlags(md)
1812
+ }),
1813
+ pageId: FernNavigation.PageId(pageId),
1814
+ noindex: metaBool(md, "noindex")
1815
+ };
1816
+ }
1817
+ /**
1818
+ * Build a LandingPageNode from the explicit landing-page segment emitted by
1819
+ * the publish transform. This avoids confusing ordinary loose tabRoot pages
1820
+ * with root/product/version landing pages.
1821
+ */
1822
+ function buildLandingPage(ctx, scopeSegs) {
1823
+ const landingSegment = scopeSegs.find((s) => metaStr(s.seg.metadata, "type") === "tabRoot" && metaBool(s.seg.metadata, "landingPage") === true);
1824
+ return routeToLandingPage(ctx, landingSegment?.nav.find((route) => route.type === "markdown"));
1587
1825
  }
1588
1826
  /**
1589
1827
  * After default-version canonical slugs are marked, propagate them across
@@ -1629,7 +1867,7 @@ function markCrossVersionCanonicalSlugs(versionNodes) {
1629
1867
  });
1630
1868
  }
1631
1869
  }
1632
- function buildProductGroup(ctx, products, scoped, rootSlug) {
1870
+ function buildProductGroup(ctx, products, scoped, rootSlug, skipSharedSectionMerge) {
1633
1871
  const children = products.map((product) => {
1634
1872
  const productSegs = scoped.filter((s) => s.seg.product?.id === product.id);
1635
1873
  const versions = uniqueDetails(productSegs, "version");
@@ -1638,7 +1876,7 @@ function buildProductGroup(ctx, products, scoped, rootSlug) {
1638
1876
  // ProductChild is VersionedNode | UnversionedNode — a version-less
1639
1877
  // product wraps its sidebar/tabs in an UnversionedNode.
1640
1878
  const productChild = versions.length > 0
1641
- ? buildVersioned(ctx, versions, productSegs, rootSlug, productPrefix)
1879
+ ? buildVersioned(ctx, versions, productSegs, rootSlug, productPrefix, skipSharedSectionMerge)
1642
1880
  : buildUnversioned(ctx, productSegs, asSlug(productPrefix), tabsPresent);
1643
1881
  const node = {
1644
1882
  type: "product",
@@ -1673,7 +1911,7 @@ function buildProductGroup(ctx, products, scoped, rootSlug) {
1673
1911
  type: "productgroup",
1674
1912
  id: ctx.nodeId("productgroup"),
1675
1913
  collapsed: undefined,
1676
- landingPage: undefined,
1914
+ landingPage: buildLandingPage(ctx, scoped.filter((s) => s.seg.product == null)),
1677
1915
  children
1678
1916
  };
1679
1917
  }