@fern-api/fdr-sdk 1.2.39-cc5ce5c5b6 → 1.2.39-dc3805e7f9

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 (94) 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 +1 -6
  6. package/dist/js/converters/index.js.map +1 -1
  7. package/dist/js/converters/index.mjs +1 -6
  8. package/dist/js/converters/index.mjs.map +1 -1
  9. package/dist/js/index.js +2 -6
  10. package/dist/js/index.js.map +1 -1
  11. package/dist/js/index.mjs +2 -6
  12. package/dist/js/index.mjs.map +1 -1
  13. package/dist/js/navigation/index.js +1 -6
  14. package/dist/js/navigation/index.js.map +1 -1
  15. package/dist/js/navigation/index.mjs +1 -6
  16. package/dist/js/navigation/index.mjs.map +1 -1
  17. package/dist/js/navigation/ledger-root-builder.js +228 -58
  18. package/dist/js/navigation/ledger-root-builder.js.map +1 -1
  19. package/dist/js/navigation/ledger-root-builder.mjs +228 -58
  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__/ledger-root-builder.apiReferenceChildren.test.d.ts +2 -0
  26. package/dist/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.d.ts.map +1 -0
  27. package/dist/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.js +85 -0
  28. package/dist/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.js.map +1 -0
  29. package/dist/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.d.ts +2 -0
  30. package/dist/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.d.ts.map +1 -0
  31. package/dist/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.js +85 -0
  32. package/dist/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.js.map +1 -0
  33. package/dist/navigation/__test__/ledger-root-builder.landingPage.test.d.ts +2 -0
  34. package/dist/navigation/__test__/ledger-root-builder.landingPage.test.d.ts.map +1 -0
  35. package/dist/navigation/__test__/ledger-root-builder.landingPage.test.js +82 -0
  36. package/dist/navigation/__test__/ledger-root-builder.landingPage.test.js.map +1 -0
  37. package/dist/navigation/__test__/ledger-root-builder.metadataPreservation.test.d.ts +2 -0
  38. package/dist/navigation/__test__/ledger-root-builder.metadataPreservation.test.d.ts.map +1 -0
  39. package/dist/navigation/__test__/ledger-root-builder.metadataPreservation.test.js +1031 -0
  40. package/dist/navigation/__test__/ledger-root-builder.metadataPreservation.test.js.map +1 -0
  41. package/dist/navigation/__test__/ledger-root-builder.rootChildOrder.test.js +22 -9
  42. package/dist/navigation/__test__/ledger-root-builder.rootChildOrder.test.js.map +1 -1
  43. package/dist/navigation/__test__/ledger-root-builder.rootSlug.test.d.ts +2 -0
  44. package/dist/navigation/__test__/ledger-root-builder.rootSlug.test.d.ts.map +1 -0
  45. package/dist/navigation/__test__/ledger-root-builder.rootSlug.test.js +47 -0
  46. package/dist/navigation/__test__/ledger-root-builder.rootSlug.test.js.map +1 -0
  47. package/dist/navigation/__test__/ledger-root-builder.skipSlugTabs.test.d.ts +2 -0
  48. package/dist/navigation/__test__/ledger-root-builder.skipSlugTabs.test.d.ts.map +1 -0
  49. package/dist/navigation/__test__/ledger-root-builder.skipSlugTabs.test.js +284 -0
  50. package/dist/navigation/__test__/ledger-root-builder.skipSlugTabs.test.js.map +1 -0
  51. package/dist/navigation/__test__/ledger-root-builder.variantSharedSegments.test.d.ts +2 -0
  52. package/dist/navigation/__test__/ledger-root-builder.variantSharedSegments.test.d.ts.map +1 -0
  53. package/dist/navigation/__test__/ledger-root-builder.variantSharedSegments.test.js +262 -0
  54. package/dist/navigation/__test__/ledger-root-builder.variantSharedSegments.test.js.map +1 -0
  55. package/dist/navigation/ledger-root-builder.d.ts +12 -0
  56. package/dist/navigation/ledger-root-builder.d.ts.map +1 -1
  57. package/dist/navigation/ledger-root-builder.js +298 -69
  58. package/dist/navigation/ledger-root-builder.js.map +1 -1
  59. package/dist/navigation/utils/findNode.d.ts.map +1 -1
  60. package/dist/navigation/utils/findNode.js +0 -8
  61. package/dist/navigation/utils/findNode.js.map +1 -1
  62. package/dist/navigation/utils/followRedirect.d.ts.map +1 -1
  63. package/dist/navigation/utils/followRedirect.js +2 -0
  64. package/dist/navigation/utils/followRedirect.js.map +1 -1
  65. package/dist/orpc-client/docs-ledger/contract.d.ts +50 -0
  66. package/dist/orpc-client/docs-ledger/contract.d.ts.map +1 -1
  67. package/dist/orpc-client/docs-ledger/contract.js +1 -0
  68. package/dist/orpc-client/docs-ledger/contract.js.map +1 -1
  69. package/dist/orpc-client/docs-ledger/ledger-manifest.d.ts +12 -0
  70. package/dist/orpc-client/docs-ledger/ledger-manifest.d.ts.map +1 -1
  71. package/dist/tsconfig.tsbuildinfo +1 -1
  72. package/dist/types/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.d.ts +2 -0
  73. package/dist/types/navigation/__test__/ledger-root-builder.apiReferenceChildren.test.d.ts.map +1 -0
  74. package/dist/types/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.d.ts +2 -0
  75. package/dist/types/navigation/__test__/ledger-root-builder.collapsedDeterminism.test.d.ts.map +1 -0
  76. package/dist/types/navigation/__test__/ledger-root-builder.landingPage.test.d.ts +2 -0
  77. package/dist/types/navigation/__test__/ledger-root-builder.landingPage.test.d.ts.map +1 -0
  78. package/dist/types/navigation/__test__/ledger-root-builder.metadataPreservation.test.d.ts +2 -0
  79. package/dist/types/navigation/__test__/ledger-root-builder.metadataPreservation.test.d.ts.map +1 -0
  80. package/dist/types/navigation/__test__/ledger-root-builder.rootSlug.test.d.ts +2 -0
  81. package/dist/types/navigation/__test__/ledger-root-builder.rootSlug.test.d.ts.map +1 -0
  82. package/dist/types/navigation/__test__/ledger-root-builder.skipSlugTabs.test.d.ts +2 -0
  83. package/dist/types/navigation/__test__/ledger-root-builder.skipSlugTabs.test.d.ts.map +1 -0
  84. package/dist/types/navigation/__test__/ledger-root-builder.variantSharedSegments.test.d.ts +2 -0
  85. package/dist/types/navigation/__test__/ledger-root-builder.variantSharedSegments.test.d.ts.map +1 -0
  86. package/dist/types/navigation/ledger-root-builder.d.ts +12 -0
  87. package/dist/types/navigation/ledger-root-builder.d.ts.map +1 -1
  88. package/dist/types/navigation/utils/findNode.d.ts.map +1 -1
  89. package/dist/types/navigation/utils/followRedirect.d.ts.map +1 -1
  90. package/dist/types/orpc-client/docs-ledger/contract.d.ts +50 -0
  91. package/dist/types/orpc-client/docs-ledger/contract.d.ts.map +1 -1
  92. package/dist/types/orpc-client/docs-ledger/ledger-manifest.d.ts +12 -0
  93. package/dist/types/orpc-client/docs-ledger/ledger-manifest.d.ts.map +1 -1
  94. package/package.json +1 -1
@@ -330,6 +330,63 @@ function apiLeafNodeFromNavRouteInternal(ctx, route, segmentApiDefinitionId) {
330
330
  }
331
331
  return undefined;
332
332
  }
333
+ function apiLeafPositionKey(node) {
334
+ switch (node.type) {
335
+ case "endpoint":
336
+ return `endpoint:${node.endpointId}`;
337
+ case "webhook":
338
+ return `webhook:${node.webhookId}`;
339
+ case "webSocket":
340
+ return `webSocket:${node.webSocketId}`;
341
+ case "grpc":
342
+ return `grpc:${node.grpcId}`;
343
+ case "graphql":
344
+ return `graphql:${node.graphqlOperationId}`;
345
+ }
346
+ }
347
+ function apiChildPositionKey(node) {
348
+ switch (node.type) {
349
+ case "endpoint":
350
+ case "webhook":
351
+ case "webSocket":
352
+ case "grpc":
353
+ case "graphql":
354
+ return apiLeafPositionKey(node);
355
+ default:
356
+ return undefined;
357
+ }
358
+ }
359
+ function positionedApiLeafChildren(ctx, nav, apiDefinitionId) {
360
+ const rawLeafNodes = [];
361
+ const leafPositions = new Map();
362
+ const nonApiLeafNodes = [];
363
+ for (let i = 0; i < nav.length; i++) {
364
+ const route = nav[i];
365
+ const pos = route.displaySortOrder ?? i;
366
+ const apiLeaf = apiLeafNodeFromNavRouteInternal(ctx, route, apiDefinitionId);
367
+ if (apiLeaf != null) {
368
+ rawLeafNodes.push(apiLeaf);
369
+ const key = apiLeafPositionKey(apiLeaf);
370
+ leafPositions.set(key, Math.min(leafPositions.get(key) ?? pos, pos));
371
+ continue;
372
+ }
373
+ const node = artifactToNode(ctx, route);
374
+ if (node?.type === "page" || node?.type === "link") {
375
+ nonApiLeafNodes.push({ node, pos });
376
+ }
377
+ }
378
+ const apiLeafNodes = mergeEndpointPairs(ctx, rawLeafNodes, nav);
379
+ const positionedApiLeafNodes = apiLeafNodes.map((node, index) => {
380
+ if (node.type === "endpointPair") {
381
+ const streamPos = leafPositions.get(apiLeafPositionKey(node.stream)) ?? index;
382
+ const nonStreamPos = leafPositions.get(apiLeafPositionKey(node.nonStream)) ?? index;
383
+ return { node, pos: Math.min(streamPos, nonStreamPos) };
384
+ }
385
+ const key = apiChildPositionKey(node);
386
+ return { node, pos: key != null ? (leafPositions.get(key) ?? index) : index };
387
+ });
388
+ return [...positionedApiLeafNodes, ...nonApiLeafNodes];
389
+ }
333
390
  /**
334
391
  * Collect every endpoint/webhook navigation node from a single API segment's
335
392
  * nav routes. The root builder now populates apiReference children directly,
@@ -524,14 +581,10 @@ function sectionNode(ctx, scoped, sectionChildren, scopePrefix) {
524
581
  return {
525
582
  type: "section",
526
583
  ...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),
584
+ collapsed: metaCollapsed(seg.metadata) ?? defaults.collapsed,
532
585
  overviewPageId: overviewPageId != null ? FernNavigation.PageId(overviewPageId) : undefined,
533
586
  noindex: metaBool(overview?.metadata, "noindex") ?? metaBool(seg.metadata, "noindex"),
534
- collapsible: metaBool(seg.metadata, "collapsible") ?? (metaCollapsed(seg.metadata) != null ? true : undefined),
587
+ collapsible: metaBool(seg.metadata, "collapsible") ?? (metaCollapsed(seg.metadata) === true ? true : undefined),
535
588
  collapsedByDefault: metaBool(seg.metadata, "collapsedByDefault") ?? (metaCollapsed(seg.metadata) === true ? true : undefined),
536
589
  availability: metaStr(seg.metadata, "availability"),
537
590
  pointsTo: metaPointsTo != null ? pointsToSlug(metaPointsTo) : undefined,
@@ -569,11 +622,10 @@ function directChildApiPackages(parentSection, scopeSegs) {
569
622
  function buildApiChildren(ctx, scoped, scopeSegs, apiDefinitionId, scopePrefix) {
570
623
  const { seg, nav } = scoped;
571
624
  // 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);
625
+ // API references can contain normal docs pages and links in addition to
626
+ // endpoint leaves, so include markdown/link artifacts here instead of only
627
+ // API leaf types.
628
+ const leafNodes = positionedApiLeafChildren(ctx, nav, apiDefinitionId);
577
629
  // Build nested ApiPackageNodes from direct child apiPackage segments.
578
630
  const childPackages = directChildApiPackages(seg.section, scopeSegs);
579
631
  const packageNodes = childPackages.map((child) => ({
@@ -583,18 +635,12 @@ function buildApiChildren(ctx, scoped, scopeSegs, apiDefinitionId, scopePrefix)
583
635
  // Interleave by position when childIndex metadata is available
584
636
  const hasChildIndex = packageNodes.some((p) => p.childIndex != null);
585
637
  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
- }
638
+ return [...leafNodes, ...packageNodes.map((p) => ({ node: p.node, pos: Number.MAX_SAFE_INTEGER }))]
639
+ .sort((a, b) => a.pos - b.pos)
640
+ .map((p) => p.node);
593
641
  }
594
642
  const positioned = [];
595
- for (let i = 0; i < leafNodes.length; i++) {
596
- positioned.push({ node: leafNodes[i], pos: leafPositions[i] ?? i });
597
- }
643
+ positioned.push(...leafNodes);
598
644
  for (const p of packageNodes) {
599
645
  positioned.push({ node: p.node, pos: p.childIndex ?? Number.MAX_SAFE_INTEGER });
600
646
  }
@@ -803,7 +849,7 @@ function changelogNode(ctx, scoped, scopePrefix) {
803
849
  pageId: FernNavigation.PageId(metaStr(r.metadata, "pageId") ?? ""),
804
850
  noindex: metaBool(r.metadata, "noindex"),
805
851
  date: dateStr ?? "",
806
- tags: undefined
852
+ tags: metaStrArr(r.metadata, "tags")
807
853
  };
808
854
  let months = byYear.get(year);
809
855
  if (months == null) {
@@ -930,6 +976,54 @@ function buildSectionTree(ctx, scopeSegs, parentPath, scopePrefix, changelogScop
930
976
  });
931
977
  }
932
978
  // ── build a sidebarRoot for a single scope ─────────────────────────────────
979
+ /**
980
+ * When multiple root-level sections share the same section path (common for
981
+ * versioned docs where all tab-level sections receive the version slug as
982
+ * their path), we need to scope buildSectionTree so each section only adopts
983
+ * the child segments that physically belong to it.
984
+ *
985
+ * Ownership is determined by sortOrder ranges: a section owns segments whose
986
+ * sortOrder falls between its own and the next sibling section's sortOrder.
987
+ * Returns a map only for sections that have duplicates; unique sections won't
988
+ * appear (and should pass unfiltered scopeSegs to buildSectionTree).
989
+ */
990
+ function computeSiblingSortBoundaries(ordered) {
991
+ const result = new Map();
992
+ // Collect root-level sections grouped by their section path.
993
+ const sectionPaths = new Set(ordered.map((s) => s.seg.section));
994
+ const rootSections = [];
995
+ for (const s of ordered) {
996
+ const type = metaStr(s.seg.metadata, "type");
997
+ const sec = s.seg.section;
998
+ if (type !== "section" || sec === "") {
999
+ continue;
1000
+ }
1001
+ const parent = parentSectionPath(sec);
1002
+ const isRootLevel = parent === "" || (!sectionPaths.has(parent) && nearestExistingAncestor(sec, sectionPaths) === "");
1003
+ if (isRootLevel) {
1004
+ rootSections.push(s);
1005
+ }
1006
+ }
1007
+ // Group by section path — only paths with >1 section need boundaries.
1008
+ const byPath = new Map();
1009
+ for (const s of rootSections) {
1010
+ const group = byPath.get(s.seg.section) ?? [];
1011
+ group.push(s);
1012
+ byPath.set(s.seg.section, group);
1013
+ }
1014
+ for (const [, siblings] of byPath) {
1015
+ if (siblings.length <= 1) {
1016
+ continue;
1017
+ }
1018
+ // Siblings are already in sortOrder (from `ordered`).
1019
+ for (let i = 0; i < siblings.length; i++) {
1020
+ const lo = siblings[i].seg.sortOrder;
1021
+ const hi = i + 1 < siblings.length ? siblings[i + 1].seg.sortOrder : Number.MAX_SAFE_INTEGER;
1022
+ result.set(siblings[i], { lo, hi });
1023
+ }
1024
+ }
1025
+ return result;
1026
+ }
933
1027
  /**
934
1028
  * Assemble the children of a scope's sidebar root in cross-segment order.
935
1029
  * `tabRoot` segments contribute their page/link artifacts inline at their
@@ -969,12 +1063,23 @@ function buildSidebarRootChildren(ctx, scopeSegs, scopePrefix, changelogScopePre
969
1063
  //
970
1064
  // Without a root section, depth-0 sections are emitted directly.
971
1065
  const hasRootSection = ordered.some((s) => metaStr(s.seg.metadata, "type") === "section" && s.seg.section === "");
1066
+ // ── Sibling section ownership ────────────────────────────────────────
1067
+ //
1068
+ // Multiple root-level sections can share the same section path (e.g.
1069
+ // three sections all with path equal to the version slug). When this
1070
+ // happens, buildSectionTree would incorrectly give ALL child subsections
1071
+ // to each sibling. We resolve ownership via sortOrder: a section owns
1072
+ // segments whose sortOrder falls between its own and the next sibling's.
1073
+ const siblingBoundaries = computeSiblingSortBoundaries(ordered);
972
1074
  for (const s of ordered) {
973
1075
  const type = metaStr(s.seg.metadata, "type");
974
1076
  const sec = s.seg.section;
975
1077
  if (hasOwningApiReference(s, scopeSegs)) {
976
1078
  continue;
977
1079
  }
1080
+ if (type === "tabRoot" && metaBool(s.seg.metadata, "landingPage") === true) {
1081
+ continue;
1082
+ }
978
1083
  // (A) Loose artifacts: tabRoot or non-section segments at section path "".
979
1084
  if (type === "tabRoot" || (sec === "" && type !== "section")) {
980
1085
  for (const r of s.nav) {
@@ -1008,9 +1113,25 @@ function buildSidebarRootChildren(ctx, scopeSegs, scopePrefix, changelogScopePre
1008
1113
  node = changelogNode(ctx, s, changelogScopePrefix ?? scopePrefix);
1009
1114
  }
1010
1115
  else {
1011
- node = sectionNode(ctx, s, buildSectionTree(ctx, scopeSegs, sec, scopePrefix, changelogScopePrefix), scopePrefix);
1116
+ // Scope child segments to this section's sortOrder range when
1117
+ // sibling sections share the same path.
1118
+ const bounds = siblingBoundaries.get(s);
1119
+ const ownedSegs = bounds != null
1120
+ ? scopeSegs.filter((seg) => seg.seg.sortOrder >= bounds.lo && seg.seg.sortOrder < bounds.hi)
1121
+ : scopeSegs;
1122
+ node = sectionNode(ctx, s, buildSectionTree(ctx, ownedSegs, sec, scopePrefix, changelogScopePrefix), scopePrefix);
1012
1123
  }
1013
- annotated.push({ node, pos: metaNum(s.seg.metadata, "childIndex"), isLeaf: false });
1124
+ // Shared/merged sections (paths not rooted in scopePrefix) must not
1125
+ // carry childIndex — their source-version positions would sort them
1126
+ // ahead of native sections. Only applies when sibling sections exist
1127
+ // (multiple sections sharing same path), indicating a versioned site
1128
+ // where shared sections were merged from a sibling version.
1129
+ const isSharedSection = siblingBoundaries.size > 0 && scopePrefix !== "" && sec !== "" && !sec.startsWith(scopePrefix);
1130
+ annotated.push({
1131
+ node,
1132
+ pos: isSharedSection ? undefined : metaNum(s.seg.metadata, "childIndex"),
1133
+ isLeaf: false
1134
+ });
1014
1135
  }
1015
1136
  }
1016
1137
  // Interleave by V1 position only when BOTH leaves and segments carry one.
@@ -1094,7 +1215,7 @@ function buildSidebarRoot(ctx, scopeSegs, scopePrefix, changelogScopePrefix) {
1094
1215
  * Build a full (un-pruned) `RootNode` from the decomposed ledger model.
1095
1216
  */
1096
1217
  export function buildFullRootFromSegments(options) {
1097
- const { basePath, title, segments, navBySegment } = options;
1218
+ const { basePath, rootSlug: sourceRootSlug, title, segments, navBySegment, skipSharedSectionMerge } = options;
1098
1219
  // Per-call state — never shared across concurrent builds.
1099
1220
  const ctx = createBuildContext(basePath);
1100
1221
  const scoped = segments
@@ -1105,7 +1226,7 @@ export function buildFullRootFromSegments(options) {
1105
1226
  return t !== "files" && t !== "redirects";
1106
1227
  })
1107
1228
  .map((seg) => ({ seg, nav: navBySegment.get(seg.segmentHash) ?? [] }));
1108
- const rootSlug = asSlug(basePath);
1229
+ const rootSlug = asSlug(sourceRootSlug ?? basePath);
1109
1230
  // Group by scope identity.
1110
1231
  const scopesByKey = new Map();
1111
1232
  for (const s of scoped) {
@@ -1119,10 +1240,10 @@ export function buildFullRootFromSegments(options) {
1119
1240
  const tabs = uniqueDetails(scoped, "tab");
1120
1241
  let child;
1121
1242
  if (products.length > 0) {
1122
- child = buildProductGroup(ctx, products, scoped, rootSlug);
1243
+ child = buildProductGroup(ctx, products, scoped, rootSlug, skipSharedSectionMerge);
1123
1244
  }
1124
1245
  else if (versions.length > 0) {
1125
- child = buildVersioned(ctx, versions, scoped, rootSlug, undefined);
1246
+ child = buildVersioned(ctx, versions, scoped, rootSlug, undefined, skipSharedSectionMerge);
1126
1247
  }
1127
1248
  else {
1128
1249
  child = buildUnversioned(ctx, scoped, rootSlug, tabs.length > 0);
@@ -1183,6 +1304,10 @@ export function buildFullRootFromSegments(options) {
1183
1304
  * back to `detail.id` so that distinct skip-slug tabs/products aren't
1184
1305
  * collapsed into a single entry.
1185
1306
  *
1307
+ * Tabs always key by `d.id` because skip-slug tabs inherit their
1308
+ * parent's slug (e.g. the version slug "v-2"), so multiple distinct
1309
+ * tabs share the same slug — keying by slug would collapse them.
1310
+ *
1186
1311
  * Among duplicates at the same key we prefer the one whose metadata
1187
1312
  * carries a `pointsTo` value (richer / more recent publish).
1188
1313
  */
@@ -1194,7 +1319,9 @@ function uniqueDetails(scoped, dim) {
1194
1319
  continue;
1195
1320
  }
1196
1321
  const slug = detailSlug(d);
1197
- const key = slug !== "" ? slug : d.id;
1322
+ // Tabs always key by id: skip-slug tabs share the parent slug
1323
+ // (e.g. "v-2") and would otherwise collapse into one entry.
1324
+ const key = dim === "tab" ? d.id : slug !== "" ? slug : d.id;
1198
1325
  const existing = byKey.get(key);
1199
1326
  if (existing == null) {
1200
1327
  byKey.set(key, d);
@@ -1271,22 +1398,36 @@ function buildSidebarRootWithVariants(ctx, scopeSegs, scopePrefix, changelogScop
1271
1398
  if (variants.length <= 1) {
1272
1399
  return buildSidebarRoot(ctx, scopeSegs, scopePrefix, changelogScopePrefix);
1273
1400
  }
1401
+ const sharedSegs = scopeSegs.filter((s) => s.seg.variant == null);
1274
1402
  const variantedNode = buildVariantedNode(ctx, variants, scopeSegs, scopePrefix);
1403
+ const sharedChildren = sharedSegs.length > 0 ? buildSidebarRootChildren(ctx, sharedSegs, scopePrefix, changelogScopePrefix) : [];
1275
1404
  const first = scopeSegs[0];
1276
1405
  return {
1277
1406
  type: "sidebarRoot",
1278
1407
  id: ctx.nodeId(`sidebar:${first != null ? scopeKey(first.seg) : "root"}`),
1279
1408
  collapsed: undefined,
1280
- children: [variantedNode]
1409
+ children: [...sharedChildren, variantedNode]
1281
1410
  };
1282
1411
  }
1283
1412
  function buildTabbedOrSidebar(ctx, scopeSegs, tabsPresent, scopePrefix) {
1284
1413
  if (!tabsPresent) {
1285
- // When no tabs exist but variants do, emit a VariantedNode directly
1286
- // so each variant gets its own sidebar scope.
1414
+ // When no tabs exist but variants do, wrap variant-scoped segments
1415
+ // in a VariantedNode and include non-variant segments alongside it.
1287
1416
  const variants = uniqueDetails(scopeSegs, "variant");
1288
1417
  if (variants.length > 1) {
1289
- return buildVariantedNode(ctx, variants, scopeSegs, scopePrefix);
1418
+ const sharedSegs = scopeSegs.filter((s) => s.seg.variant == null);
1419
+ const variantedNode = buildVariantedNode(ctx, variants, scopeSegs, scopePrefix);
1420
+ if (sharedSegs.length > 0) {
1421
+ const sharedChildren = buildSidebarRootChildren(ctx, sharedSegs, scopePrefix);
1422
+ const first = scopeSegs[0];
1423
+ return {
1424
+ type: "sidebarRoot",
1425
+ id: ctx.nodeId(`sidebar:${first != null ? scopeKey(first.seg) : "root"}`),
1426
+ collapsed: undefined,
1427
+ children: [...sharedChildren, variantedNode]
1428
+ };
1429
+ }
1430
+ return variantedNode;
1290
1431
  }
1291
1432
  return buildSidebarRoot(ctx, scopeSegs, scopePrefix);
1292
1433
  }
@@ -1394,16 +1535,101 @@ function buildUnversioned(ctx, scopeSegs, scopePrefix, tabsPresent) {
1394
1535
  type: "unversioned",
1395
1536
  id: ctx.nodeId(`unversioned:${first?.seg.product?.id ?? "root"}`),
1396
1537
  collapsed: undefined,
1397
- landingPage: undefined,
1538
+ landingPage: buildLandingPage(ctx, scopeSegs),
1398
1539
  // The enclosing scope prefix (product slug, or rootSlug at the top
1399
1540
  // level) roots structural sections so they share the scope's slug
1400
1541
  // namespace.
1401
1542
  child: buildTabbedOrSidebar(ctx, scopeSegs, tabsPresent, scopePrefix)
1402
1543
  };
1403
1544
  }
1404
- function buildVersioned(ctx, versions, scoped, rootSlug, productPrefix) {
1545
+ /**
1546
+ * Merge shared (tab-level) sections from sibling versions into the default
1547
+ * version's segment list. A section is considered "shared" when its section
1548
+ * path does NOT start with any version slug — indicating it was authored at
1549
+ * the tab level and the publish transform assigned it to only one version.
1550
+ */
1551
+ function mergeSharedSections(defaultSegs, allScoped, defaultVersion, versionSlugs) {
1552
+ // Build a map of the default version's tabs by displayName for
1553
+ // cross-version matching. Tab slugs can't be used because the "default"
1554
+ // tab's slug equals the version slug (e.g. "2026-02-09" vs "2025-04-21"
1555
+ // for the same logical "Documentation" tab across versions).
1556
+ const defaultTabsByName = new Map();
1557
+ for (const s of defaultSegs) {
1558
+ if (s.seg.tab != null) {
1559
+ const tabName = s.seg.tab.displayName;
1560
+ if (!defaultTabsByName.has(tabName)) {
1561
+ defaultTabsByName.set(tabName, s.seg.tab);
1562
+ }
1563
+ }
1564
+ }
1565
+ // Collect section paths already present in the default version.
1566
+ const existingSections = new Set(defaultSegs.filter((s) => metaStr(s.seg.metadata, "type") === "section").map((s) => s.seg.section));
1567
+ // Merged sections must sort AFTER all native default segments so they
1568
+ // appear in a trailing sidebarGroup (after non-collapsible sections like
1569
+ // "Getting Started", "Dev Tools", "Capabilities") matching v2 ordering.
1570
+ const maxSortOrder = defaultSegs.reduce((max, s) => Math.max(max, s.seg.sortOrder), 0);
1571
+ let nextSortOrder = maxSortOrder + 1;
1572
+ const merged = [...defaultSegs];
1573
+ for (const s of allScoped) {
1574
+ if (s.seg.version?.id === defaultVersion.id) {
1575
+ continue;
1576
+ }
1577
+ const type = metaStr(s.seg.metadata, "type");
1578
+ if (type !== "section") {
1579
+ continue;
1580
+ }
1581
+ const sectionPath = s.seg.section;
1582
+ // Skip version-specific sections (path starts with a version slug).
1583
+ if (versionSlugs.some((vs) => sectionPath === vs || sectionPath.startsWith(vs + "/"))) {
1584
+ continue;
1585
+ }
1586
+ // Skip if the default version already has this section.
1587
+ if (existingSections.has(sectionPath)) {
1588
+ continue;
1589
+ }
1590
+ // Match the source tab to the default version's corresponding tab
1591
+ // by displayName (tab slugs differ across versions for the default tab).
1592
+ const sourceTabName = s.seg.tab?.displayName ?? "";
1593
+ const targetTab = defaultTabsByName.get(sourceTabName);
1594
+ if (targetTab == null) {
1595
+ continue;
1596
+ }
1597
+ // Re-map the segment's tab and version to the default version's.
1598
+ // Strip childIndex from metadata so merged sections don't sort before
1599
+ // native sections in the annotated interleave step, and assign a
1600
+ // sortOrder after all native segments to place them in the trailing
1601
+ // sidebarGroup.
1602
+ const metadata = s.seg.metadata != null ? { ...s.seg.metadata, childIndex: undefined } : s.seg.metadata;
1603
+ merged.push({
1604
+ seg: {
1605
+ ...s.seg,
1606
+ tab: targetTab,
1607
+ version: defaultVersion,
1608
+ sortOrder: nextSortOrder++,
1609
+ metadata
1610
+ },
1611
+ nav: s.nav
1612
+ });
1613
+ existingSections.add(sectionPath);
1614
+ }
1615
+ return merged;
1616
+ }
1617
+ function buildVersioned(ctx, versions, scoped, rootSlug, productPrefix, skipSharedSectionMerge) {
1618
+ // Collect version slugs for detecting version-prefixed section paths.
1619
+ const versionSlugs = versions.map((v) => detailSlug(v)).filter((s) => s !== "");
1405
1620
  const children = versions.map((version) => {
1406
- const versionSegs = scoped.filter((s) => s.seg.version?.id === version.id);
1621
+ let versionSegs = scoped.filter((s) => s.seg.version?.id === version.id);
1622
+ // Merge shared tab-level sections from sibling versions into the
1623
+ // default version. The publish transform may assign tab-level content
1624
+ // (sections without a version-prefixed path) to a single version;
1625
+ // v2's monolith root includes them in every version's sidebar.
1626
+ // Skipped for scoped roots: the scoped root only loads nav for a
1627
+ // single route's scope, so merged sections carry the source version's
1628
+ // fullPaths into the default tree, creating duplicate slugs that
1629
+ // orphan the real version's pages in NodeCollector.
1630
+ if (detailIsDefault(version) && !skipSharedSectionMerge) {
1631
+ versionSegs = mergeSharedSections(versionSegs, scoped, version, versionSlugs);
1632
+ }
1407
1633
  const tabsPresent = uniqueDetails(versionSegs, "tab").length > 0;
1408
1634
  const prefix = productPrefix != null ? productPrefix : rootSlug;
1409
1635
  // The version node always carries its FULL versioned slug
@@ -1440,7 +1666,7 @@ function buildVersioned(ctx, versions, scoped, rootSlug, productPrefix) {
1440
1666
  default: detailIsDefault(version),
1441
1667
  versionId: FernNavigation.VersionId(version.id),
1442
1668
  availability: undefined,
1443
- landingPage: buildVersionLandingPage(ctx, versionSegs, asSlug(slug)),
1669
+ landingPage: buildLandingPage(ctx, versionSegs),
1444
1670
  announcement: undefined,
1445
1671
  pointsTo: metaStr(version.metadata, "pointsTo") != null
1446
1672
  ? pointsToSlug(metaStr(version.metadata, "pointsTo"))
@@ -1546,38 +1772,41 @@ function crossVersionDedupKeys(node, parents) {
1546
1772
  keys.push([...parentTitles, node.title].join("###"));
1547
1773
  return keys;
1548
1774
  }
1549
- /**
1550
- * Build a LandingPageNode from tab-less segments (segments with no tab_id).
1551
- * In V1, these are version-level landing pages that sit outside any tab.
1552
- */
1553
- function buildVersionLandingPage(ctx, versionSegs, versionSlug) {
1554
- const tablessSegs = versionSegs.filter((s) => s.seg.tab == null);
1555
- if (tablessSegs.length === 0) {
1775
+ function routeToLandingPage(ctx, route) {
1776
+ if (route == null || route.type !== "markdown" || route.fullPath == null) {
1556
1777
  return undefined;
1557
1778
  }
1558
- for (const s of tablessSegs) {
1559
- for (const r of s.nav) {
1560
- if (r.type === "markdown" && r.fullPath != null) {
1561
- const md = r.metadata;
1562
- const pageId = metaStr(md, "pageId") ?? "";
1563
- const slug = ctx.contentSlug(r.fullPath);
1564
- return {
1565
- type: "landingPage",
1566
- ...withMetadataDefaults({
1567
- id: ctx.nodeId(`landing:${slug}`),
1568
- title: metaStr(md, "title") ?? "",
1569
- slug,
1570
- icon: metaStr(md, "icon"),
1571
- hidden: r.hidden,
1572
- viewers: toViewers(metaStrArr(md, "viewers"))
1573
- }),
1574
- pageId: FernNavigation.PageId(pageId),
1575
- noindex: metaBool(md, "noindex")
1576
- };
1577
- }
1578
- }
1779
+ const md = route.metadata;
1780
+ const pageId = metaStr(md, "pageId");
1781
+ if (pageId == null) {
1782
+ return undefined;
1579
1783
  }
1580
- return undefined;
1784
+ const slug = ctx.contentSlug(route.fullPath);
1785
+ return {
1786
+ type: "landingPage",
1787
+ ...withMetadataDefaults({
1788
+ id: ctx.nodeId(`landing:${slug}`),
1789
+ title: metaStr(md, "title") ?? "",
1790
+ slug,
1791
+ icon: metaStr(md, "icon"),
1792
+ hidden: route.hidden,
1793
+ authed: metaBool(md, "authed"),
1794
+ viewers: toViewers(metaStrArr(md, "viewers")),
1795
+ orphaned: metaBool(md, "orphaned"),
1796
+ featureFlags: metaFeatureFlags(md)
1797
+ }),
1798
+ pageId: FernNavigation.PageId(pageId),
1799
+ noindex: metaBool(md, "noindex")
1800
+ };
1801
+ }
1802
+ /**
1803
+ * Build a LandingPageNode from the explicit landing-page segment emitted by
1804
+ * the publish transform. This avoids confusing ordinary loose tabRoot pages
1805
+ * with root/product/version landing pages.
1806
+ */
1807
+ function buildLandingPage(ctx, scopeSegs) {
1808
+ const landingSegment = scopeSegs.find((s) => metaStr(s.seg.metadata, "type") === "tabRoot" && metaBool(s.seg.metadata, "landingPage") === true);
1809
+ return routeToLandingPage(ctx, landingSegment?.nav.find((route) => route.type === "markdown"));
1581
1810
  }
1582
1811
  /**
1583
1812
  * After default-version canonical slugs are marked, propagate them across
@@ -1623,7 +1852,7 @@ function markCrossVersionCanonicalSlugs(versionNodes) {
1623
1852
  });
1624
1853
  }
1625
1854
  }
1626
- function buildProductGroup(ctx, products, scoped, rootSlug) {
1855
+ function buildProductGroup(ctx, products, scoped, rootSlug, skipSharedSectionMerge) {
1627
1856
  const children = products.map((product) => {
1628
1857
  const productSegs = scoped.filter((s) => s.seg.product?.id === product.id);
1629
1858
  const versions = uniqueDetails(productSegs, "version");
@@ -1632,7 +1861,7 @@ function buildProductGroup(ctx, products, scoped, rootSlug) {
1632
1861
  // ProductChild is VersionedNode | UnversionedNode — a version-less
1633
1862
  // product wraps its sidebar/tabs in an UnversionedNode.
1634
1863
  const productChild = versions.length > 0
1635
- ? buildVersioned(ctx, versions, productSegs, rootSlug, productPrefix)
1864
+ ? buildVersioned(ctx, versions, productSegs, rootSlug, productPrefix, skipSharedSectionMerge)
1636
1865
  : buildUnversioned(ctx, productSegs, asSlug(productPrefix), tabsPresent);
1637
1866
  const node = {
1638
1867
  type: "product",
@@ -1667,7 +1896,7 @@ function buildProductGroup(ctx, products, scoped, rootSlug) {
1667
1896
  type: "productgroup",
1668
1897
  id: ctx.nodeId("productgroup"),
1669
1898
  collapsed: undefined,
1670
- landingPage: undefined,
1899
+ landingPage: buildLandingPage(ctx, scoped.filter((s) => s.seg.product == null)),
1671
1900
  children
1672
1901
  };
1673
1902
  }