From 4683d3556e19390c7071f88889658059328b6e80 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 07:29:31 +0000 Subject: [PATCH 1/3] fix(cli): translate section and page titles inside variant layouts Translation overlays for tabs that use variants now support a per-variant `layout:` field whose section and page titles override the resolved navigation tree. This is needed for docs sites that use `tabs:` + `variants:` and want non-default locale sidebars to translate fully. - VariantOverlay gains an optional layout array of NavigationItemOverlay - parseVariantOverlays threads through the layout field - applyTranslatedNavigationOverlays handles the 'varianted' parent type and scopes each matched variant's layout as the navigation context for its children - matchSectionOverlay / matchPageOverlay match overlay slugs leniently: '/' and empty mean 'root', and absolute slugs are normalised to their last segment so they line up with the tree's resolved slug Co-Authored-By: will.kendall@buildwithfern.com --- .../fix-translate-variant-layouts.yml | 10 + .../src/docs-yml/parseDocsConfiguration.ts | 3 +- .../src/docs-yml/ParsedDocsConfiguration.ts | 6 + .../applyTranslatedNavigationOverlays.test.ts | 130 ++++++++++++ .../src/applyTranslatedNavigationOverlays.ts | 194 +++++++++++++----- 5 files changed, 296 insertions(+), 47 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml diff --git a/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml b/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml new file mode 100644 index 000000000000..d312dde37f42 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Support translating section and page titles inside variant layouts. + Translation overlays for `tab` navigation items can now provide a + per-variant `layout:` whose section/page titles override the resolved + navigation tree. Overlay slugs are also matched leniently so that + relative slugs (e.g. `smtp`) and absolute slugs (e.g. `/`) line up with + the tree's resolved last-segment slug. + type: fix diff --git a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts index 9762159a108b..2f5bd8688523 100644 --- a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts +++ b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts @@ -2500,7 +2500,8 @@ function parseVariantOverlays(variants: unknown[]): docsYml.VariantOverlay[] { result.push({ title: typeof obj.title === "string" ? obj.title : undefined, subtitle: typeof obj.subtitle === "string" ? obj.subtitle : undefined, - slug: typeof obj.slug === "string" ? obj.slug : undefined + slug: typeof obj.slug === "string" ? obj.slug : undefined, + layout: Array.isArray(obj.layout) ? parseNavigationItemOverlays(obj.layout) : undefined }); } return result; diff --git a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts index 172cc34c307b..094a0e230932 100644 --- a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts +++ b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts @@ -633,4 +633,10 @@ export interface VariantOverlay { title: string | undefined; subtitle: string | undefined; slug: string | undefined; + /** + * Per-variant navigation overlays. When a tab declares variants, each variant + * has its own layout in the source navigation; this field mirrors that shape + * so translations can override section and page titles inside each variant. + */ + layout: NavigationItemOverlay[] | undefined; } diff --git a/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts b/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts index 6b0efbd2cee8..7806ffc7ba60 100644 --- a/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts +++ b/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts @@ -556,6 +556,136 @@ describe("applyTranslatedNavigationOverlays", () => { const origTab = (origTabbed[0]?.children as Array>)[0]; expect(origTab?.title).toBe("Documentation"); }); + + it("translates a variant tab's title, variant titles, and per-variant section/page titles", () => { + // Tree shape mirrors what DocsDefinitionResolver produces for a tab + // with variants: tab → sidebarRoot → varianted → variant → children. + const root = { + type: "root", + child: { + type: "tabbed", + children: [ + { + type: "tab", + title: "Products", + slug: "products", + child: { + type: "sidebarRoot", + children: [ + { + type: "varianted", + children: [ + { + type: "variant", + title: "Home", + slug: "products", + children: [ + { + type: "page", + title: "Documentation", + slug: "products" + }, + { + type: "section", + title: "Platform Details", + slug: "products/platform-details", + children: [ + { + type: "page", + title: "Configure SMTP", + slug: "products/platform-details/smtp" + } + ] + } + ] + }, + { + type: "variant", + title: "W&B Models", + slug: "products/models", + children: [ + { + type: "page", + title: "W&B Models", + slug: "products/models" + }, + { + type: "page", + title: "Manage secrets", + slug: "products/models/secrets" + } + ] + } + ] + } + ] + } + } + ] + } + }; + + const overlay: docsYml.TranslationNavigationOverlay = { + ...emptyOverlay(), + tabs: { products: { displayName: "Produits", slug: undefined } }, + navigation: [ + { + type: "tab", + tabId: "products", + layout: undefined, + variants: [ + { + title: "Accueil", + subtitle: undefined, + slug: undefined, + layout: [ + { type: "page", title: "Documentation FR", slug: undefined }, + { + type: "section", + title: "Détails de la plateforme", + slug: "platform-details", + contents: [{ type: "page", title: "Configurer SMTP", slug: "smtp" }] + } + ] + }, + { + title: "W&B Modèles", + subtitle: undefined, + slug: "models", + layout: [ + { type: "page", title: "W&B Modèles", slug: undefined }, + { type: "page", title: "Gérer les secrets", slug: "secrets" } + ] + } + ] + } + ] + }; + + const result = applyTranslatedNavigationOverlays(asRoot(root), overlay) as unknown as Record; + const tabbed = (result.child as Record).children as Array>; + const tab = tabbed[0] as Record; + expect(tab.title).toBe("Produits"); + + const sidebarRoot = tab.child as Record; + const varianted = (sidebarRoot.children as Array>)[0] as Record; + const variants = varianted.children as Array>; + + const home = variants[0] as Record; + expect(home.title).toBe("Accueil"); + const homeChildren = home.children as Array>; + expect(homeChildren[0]?.title).toBe("Documentation FR"); + const platformSection = homeChildren[1] as Record; + expect(platformSection.title).toBe("Détails de la plateforme"); + const platformChildren = platformSection.children as Array>; + expect(platformChildren[0]?.title).toBe("Configurer SMTP"); + + const models = variants[1] as Record; + expect(models.title).toBe("W&B Modèles"); + const modelsChildren = models.children as Array>; + expect(modelsChildren[0]?.title).toBe("W&B Modèles"); + expect(modelsChildren[1]?.title).toBe("Gérer les secrets"); + }); }); describe("getTranslatedAnnouncement", () => { diff --git a/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts b/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts index 9f0a9cec58eb..4e473d6132c4 100644 --- a/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts +++ b/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts @@ -13,7 +13,11 @@ import { FernNavigation } from "@fern-api/fdr-sdk"; * - Products: matched positionally against the overlay's products array * - Versions: matched positionally against the overlay's versions array * - Tabs: matched by looking up the tab slug in the overlay's `tabs` map - * - Sections/Pages: matched positionally within the overlay's navigation items + * - Variants: matched by variant slug first, then positionally among + * overlays without an explicit slug; the matched overlay's `layout:` + * becomes the scoped navigation context for the variant's children + * - Sections/Pages: matched by slug first, then positionally within the + * currently scoped overlay's navigation items */ export function applyTranslatedNavigationOverlays( root: FernNavigation.V1.RootNode | undefined, @@ -131,13 +135,21 @@ function applyChildOverlays( if (childObj["type"] === "tab") { const positionalTabId = orderedTabIds[tabIndex]; tabIndex++; - const walked = walkAndApply(child, overlay) as Record; - return applyTabOverlayToNode(walked, overlay, positionalTabId); + return applyTabOverlayToNode(childObj, overlay, positionalTabId); } return walkAndApply(child, overlay); }); } + // Varianted children → match each variant with overlay.variants, then + // recurse with the per-variant layout scoped as the navigation overlay. + if (parentType === "varianted") { + const variantOverlays = collectVariantOverlaysFromTabLayout(overlay); + if (variantOverlays != null && variantOverlays.length > 0) { + return applyVariantOverlays(children, variantOverlays, overlay); + } + } + // SidebarRoot children → match sections/pages with overlay.navigation if (parentType === "sidebarRoot") { const navOverlays = collectFlatNavigationOverlays(overlay); @@ -298,27 +310,22 @@ function applyTabOverlayToNode( } } - // Handle tab variants if present - if (tabNavOverlay.variants != null && tabNavOverlay.variants.length > 0) { - const tabChild = node["child"] as Record | undefined; - if (tabChild != null && tabChild["type"] === "variants") { - const variantsChildren = tabChild["children"] as unknown[] | undefined; - if (variantsChildren != null) { - tabChild["children"] = applyVariantOverlays(variantsChildren, tabNavOverlay.variants); - } - } - } - - // Create a scoped overlay for this tab's children - if (tabNavOverlay.layout != null) { - const scopedOverlay: docsYml.TranslationNavigationOverlay = { - tabs: undefined, - products: undefined, - versions: undefined, - announcement: undefined, - navigation: tabNavOverlay.layout, - navbarLinks: undefined - }; + // Build a scoped overlay carrying both this tab's flat layout (for + // non-variant tabs) and its variants (for variant tabs). Variants + // are discovered downstream via the `varianted` parent-type branch. + const scopedOverlay: docsYml.TranslationNavigationOverlay = { + tabs: undefined, + products: undefined, + versions: undefined, + announcement: undefined, + navigation: + tabNavOverlay.layout ?? + (tabNavOverlay.variants != null && tabNavOverlay.variants.length > 0 + ? [{ type: "tab", tabId: tabNavOverlay.tabId, layout: undefined, variants: tabNavOverlay.variants }] + : undefined), + navbarLinks: undefined + }; + if (scopedOverlay.navigation != null) { return walkAndApply(node, scopedOverlay); } } @@ -326,6 +333,26 @@ function applyTabOverlayToNode( return walkAndApply(node, overlay); } +/** + * Collects the variant overlays for the current scoped overlay. Inside a + * tab's scoped overlay we surface variants via a synthetic Tab navigation + * entry (see `applyTabOverlayToNode`); this helper unwraps that entry so + * the `varianted` traversal can match variants positionally / by slug. + */ +function collectVariantOverlaysFromTabLayout( + overlay: docsYml.TranslationNavigationOverlay +): docsYml.VariantOverlay[] | undefined { + if (overlay.navigation == null) { + return undefined; + } + for (const item of overlay.navigation) { + if (item.type === "tab" && item.variants != null && item.variants.length > 0) { + return item.variants; + } + } + return undefined; +} + function findTabNavOverlay( tabSlug: string | undefined, overlay: docsYml.TranslationNavigationOverlay, @@ -471,17 +498,23 @@ function matchSectionOverlay( // First, try to match by slug for (const o of overlays) { - if (o.slug != null && o.slug === sectionSlug) { + if (o.slug != null && normalizeOverlaySlug(o.slug) === sectionSlug) { return o; } } - // Positional fallback: only use overlays that don't have a slug defined, - // to avoid incorrectly applying a slug-targeted overlay to the wrong sibling. + // Positional fallback: prefer overlays without a slug (those are meant to + // match by position) and fall back to all overlays positionally so that + // overlay files mirroring the source navigation structure still resolve + // when slugs don't line up exactly (e.g. tree slug `home` vs. overlay + // slug `/`). const noSlugOverlays = overlays.filter((o) => o.slug == null); if (positionIndex < noSlugOverlays.length) { return noSlugOverlays[positionIndex]; } + if (noSlugOverlays.length === 0 && positionIndex < overlays.length) { + return overlays[positionIndex]; + } return undefined; } @@ -494,17 +527,23 @@ function matchPageOverlay( // First, try to match by slug for (const o of overlays) { - if (o.slug != null && o.slug === pageSlug) { + if (o.slug != null && normalizeOverlaySlug(o.slug) === pageSlug) { return o; } } - // Positional fallback: only use overlays that don't have a slug defined, - // to avoid incorrectly applying a slug-targeted overlay to the wrong sibling. + // Positional fallback: prefer overlays without a slug (those are meant to + // match by position) and fall back to all overlays positionally so that + // overlay files mirroring the source navigation structure still resolve + // when slugs don't line up exactly (e.g. tree slug `home` vs. overlay + // slug `/`). const noSlugOverlays = overlays.filter((o) => o.slug == null); if (positionIndex < noSlugOverlays.length) { return noSlugOverlays[positionIndex]; } + if (noSlugOverlays.length === 0 && positionIndex < overlays.length) { + return overlays[positionIndex]; + } return undefined; } @@ -516,12 +555,46 @@ function extractLastSlugSegment(slug: string | undefined): string | undefined { return parts[parts.length - 1]; } +/** + * Normalises an overlay's `slug:` value for slug-based matching against a + * tree node's resolved last-segment slug. Overlays mirror the docs.yml + * syntax, where slugs can be absolute (`/`, `/path`) or relative (`leaf`). + * The tree's slug, by contrast, is always the resolved final segment. + */ +function normalizeOverlaySlug(slug: string): string { + if (slug === "/" || slug === "") { + return ""; + } + const trimmed = slug.startsWith("/") ? slug.slice(1) : slug; + const parts = trimmed.split("/"); + return parts[parts.length - 1] ?? ""; +} + /** * Applies variant overlays to tab variant children. - * Matches variants by slug first, then falls back to positional matching. + * + * Matching strategy: + * - First tries to match by slug. + * - Falls back to positional matching among overlays that have no slug, + * skipping noSlug overlays already consumed by an earlier sibling. + * + * For each matched variant the title/subtitle override is applied, and the + * variant's children are walked with the overlay's `layout:` scoped as the + * navigation context so nested sections/pages can be translated. */ -function applyVariantOverlays(variants: unknown[], overlays: docsYml.VariantOverlay[]): unknown[] { - return variants.map((variant, index) => { +function applyVariantOverlays( + variants: unknown[], + overlays: docsYml.VariantOverlay[], + parentOverlay: docsYml.TranslationNavigationOverlay +): unknown[] { + const consumedNoSlugIndices = new Set(); + let noSlugCursor = 0; + const noSlugOverlayIndices = overlays + .map((o, idx) => ({ o, idx })) + .filter(({ o }) => o.slug == null) + .map(({ idx }) => idx); + + return variants.map((variant) => { const variantObj = variant as Record | null; if (variantObj == null || typeof variantObj !== "object") { return variant; @@ -535,25 +608,54 @@ function applyVariantOverlays(variants: unknown[], overlays: docsYml.VariantOver matchedOverlay = overlays.find((o) => o.slug != null && o.slug === variantSlug); } - // Positional fallback: only use overlays that don't have a slug defined + // Positional fallback: walk through noSlug overlays in declaration + // order, skipping any already consumed by an earlier variant. if (matchedOverlay == null) { - const noSlugOverlays = overlays.filter((o) => o.slug == null); - if (index < noSlugOverlays.length) { - matchedOverlay = noSlugOverlays[index]; + while (noSlugCursor < noSlugOverlayIndices.length) { + const overlayIdx = noSlugOverlayIndices[noSlugCursor]; + if (overlayIdx === undefined) { + break; + } + noSlugCursor++; + if (consumedNoSlugIndices.has(overlayIdx)) { + continue; + } + consumedNoSlugIndices.add(overlayIdx); + matchedOverlay = overlays[overlayIdx]; + break; } } - if (matchedOverlay != null) { - const result = { ...variantObj }; - if (matchedOverlay.title != null) { - result["title"] = matchedOverlay.title; - } - if (matchedOverlay.subtitle != null) { - result["subtitle"] = matchedOverlay.subtitle; - } + if (matchedOverlay == null) { + return walkAndApply(variant, parentOverlay); + } + + const result: Record = { ...variantObj }; + if (matchedOverlay.title != null) { + result["title"] = matchedOverlay.title; + } + if (matchedOverlay.subtitle != null) { + result["subtitle"] = matchedOverlay.subtitle; + } + + // Scope the per-variant layout for the variant's children so nested + // sections and pages get translated against the variant's overlay. + // The variant node directly owns pages/sections (no sidebarRoot + // wrapper) so we apply the layout overlays straight to its children. + const children = Array.isArray(result["children"]) ? (result["children"] as unknown[]) : undefined; + if (matchedOverlay.layout != null && children != null) { + const variantScopedOverlay: docsYml.TranslationNavigationOverlay = { + tabs: undefined, + products: undefined, + versions: undefined, + announcement: undefined, + navigation: matchedOverlay.layout, + navbarLinks: undefined + }; + result["children"] = applySidebarChildOverlays(children, matchedOverlay.layout, variantScopedOverlay); return result; } - return variant; + return walkAndApply(result, parentOverlay); }); } From 124c6a6a3b07222daf845f09cf1f16c079c1e5d1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 15:55:36 +0000 Subject: [PATCH 2/3] fix(cli): translate variant identifiers in addition to variant titles When a variant overlay sets `title:`, also override the variant node's `variantId` so downstream renderers that key off the typed variant identifier (e.g. coreweave's custom navbar dropdown) display the translated label. The original `variantId` mirrors the source variant `title` (see DocsDefinitionResolver.toVariantNode), so keeping these in sync per locale is consistent with the source behaviour. Also stop mutating the input root in applyTabOverlayToNode (caught by the existing 'does not mutate the original root' test). Co-Authored-By: will.kendall@buildwithfern.com --- .../fix-translate-variant-layouts.yml | 6 ++++- .../applyTranslatedNavigationOverlays.test.ts | 6 +++++ .../src/applyTranslatedNavigationOverlays.ts | 24 ++++++++++++++----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml b/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml index d312dde37f42..634c543bb767 100644 --- a/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml +++ b/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml @@ -6,5 +6,9 @@ per-variant `layout:` whose section/page titles override the resolved navigation tree. Overlay slugs are also matched leniently so that relative slugs (e.g. `smtp`) and absolute slugs (e.g. `/`) line up with - the tree's resolved last-segment slug. + the tree's resolved last-segment slug. When a variant overlay sets + `title:`, the variant's `variantId` is also updated so downstream + renderers that key off the typed variant identifier (such as custom + navbar dropdowns built directly from the navigation tree) display the + translated label. type: fix diff --git a/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts b/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts index 7806ffc7ba60..9e082212a32c 100644 --- a/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts +++ b/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts @@ -578,6 +578,7 @@ describe("applyTranslatedNavigationOverlays", () => { { type: "variant", title: "Home", + variantId: "Home", slug: "products", children: [ { @@ -602,6 +603,7 @@ describe("applyTranslatedNavigationOverlays", () => { { type: "variant", title: "W&B Models", + variantId: "W&B Models", slug: "products/models", children: [ { @@ -673,6 +675,9 @@ describe("applyTranslatedNavigationOverlays", () => { const home = variants[0] as Record; expect(home.title).toBe("Accueil"); + // variantId is also translated so downstream renderers (custom dropdowns, + // navigation state) display the translated label rather than the source. + expect(home.variantId).toBe("Accueil"); const homeChildren = home.children as Array>; expect(homeChildren[0]?.title).toBe("Documentation FR"); const platformSection = homeChildren[1] as Record; @@ -682,6 +687,7 @@ describe("applyTranslatedNavigationOverlays", () => { const models = variants[1] as Record; expect(models.title).toBe("W&B Modèles"); + expect(models.variantId).toBe("W&B Modèles"); const modelsChildren = models.children as Array>; expect(modelsChildren[0]?.title).toBe("W&B Modèles"); expect(modelsChildren[1]?.title).toBe("Gérer les secrets"); diff --git a/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts b/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts index 4e473d6132c4..1719564ba096 100644 --- a/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts +++ b/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts @@ -26,7 +26,6 @@ export function applyTranslatedNavigationOverlays( if (root == null) { return undefined; } - const result = walkAndApply(root, overlay) as FernNavigation.V1.RootNode; return result; } @@ -275,6 +274,9 @@ function applyTabOverlayToNode( ): unknown { const tabSlug = extractLastSlugSegment(node["slug"] as string | undefined); + // Shallow-copy so we never mutate the input tree. + const out: Record = { ...node }; + // Look up tab display-name override from overlay.tabs. // Match precedence: slug-based match → positional fallback (covers // skip-slug tabs that collapse into the parent slug and therefore can't @@ -284,7 +286,7 @@ function applyTabOverlayToNode( for (const [tabId, tabOverlay] of Object.entries(overlay.tabs)) { const isMatch = tabId === tabSlug || (tabOverlay.slug != null && tabOverlay.slug === tabSlug); if (isMatch && tabOverlay.displayName != null) { - node["title"] = tabOverlay.displayName; + out["title"] = tabOverlay.displayName; appliedTabId = tabId; break; } @@ -293,7 +295,7 @@ function applyTabOverlayToNode( if (appliedTabId == null && positionalTabId != null && overlay.tabs != null) { const tabOverlayEntry = overlay.tabs[positionalTabId]; if (tabOverlayEntry?.displayName != null) { - node["title"] = tabOverlayEntry.displayName; + out["title"] = tabOverlayEntry.displayName; appliedTabId = positionalTabId; } } @@ -306,7 +308,7 @@ function applyTabOverlayToNode( if (overlay.tabs != null && appliedTabId == null) { const tabOverlayEntry = overlay.tabs[tabNavOverlay.tabId]; if (tabOverlayEntry?.displayName != null) { - node["title"] = tabOverlayEntry.displayName; + out["title"] = tabOverlayEntry.displayName; } } @@ -326,11 +328,15 @@ function applyTabOverlayToNode( navbarLinks: undefined }; if (scopedOverlay.navigation != null) { - return walkAndApply(node, scopedOverlay); + // walkAndApply iterates over keys of `out` and produces a fully + // new tree (children too); the title override we just set on + // `out` is preserved because walkAndApply does not touch the + // "title" key. + return walkAndApply(out, scopedOverlay); } } - return walkAndApply(node, overlay); + return walkAndApply(out, overlay); } /** @@ -633,6 +639,12 @@ function applyVariantOverlays( const result: Record = { ...variantObj }; if (matchedOverlay.title != null) { result["title"] = matchedOverlay.title; + // Also override variantId so downstream renderers that key off the + // typed variant identifier (e.g. custom dropdowns built from the + // navigation tree) display the translated label. The original + // variantId mirrors the source `title` (see DocsDefinitionResolver + // toVariantNode), so it is safe to keep these in sync per locale. + result["variantId"] = matchedOverlay.title; } if (matchedOverlay.subtitle != null) { result["subtitle"] = matchedOverlay.subtitle; From b60a49f4f791d04d8fb868a8b447209a945a51b9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 17:47:18 +0000 Subject: [PATCH 3/3] fix(cli): drop positional fallback for slug-bearing overlays and warn Translation overlays for sections/pages are no longer silently re-attached positionally when their slug doesn't match any sibling navigation entry. Instead the matcher warns via the CLI logger so drifted overlays surface during fern generate / fern check instead of producing mistranslated nav. Overlays without a slug remain positional (the intentional case), and the existing leaf-segment slug normalization continues to handle absolute and relative slug forms symmetrically. Co-Authored-By: will.kendall@buildwithfern.com --- .../fix-translate-variant-layouts.yml | 17 +- .../docs-preview/src/runAppPreviewServer.ts | 2 +- .../cli/docs-preview/src/runPreviewServer.ts | 2 +- .../applyTranslatedNavigationOverlays.test.ts | 93 +++++++++- .../src/applyTranslatedNavigationOverlays.ts | 167 ++++++++++++------ .../src/publishDocs.ts | 2 +- 6 files changed, 214 insertions(+), 69 deletions(-) diff --git a/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml b/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml index 634c543bb767..80f44a74add8 100644 --- a/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml +++ b/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml @@ -4,11 +4,14 @@ Support translating section and page titles inside variant layouts. Translation overlays for `tab` navigation items can now provide a per-variant `layout:` whose section/page titles override the resolved - navigation tree. Overlay slugs are also matched leniently so that - relative slugs (e.g. `smtp`) and absolute slugs (e.g. `/`) line up with - the tree's resolved last-segment slug. When a variant overlay sets - `title:`, the variant's `variantId` is also updated so downstream - renderers that key off the typed variant identifier (such as custom - navbar dropdowns built directly from the navigation tree) display the - translated label. + navigation tree. Overlay slugs are matched leniently against the tree's + resolved last-segment slug so that relative (`smtp`) and absolute + (`/path/to/smtp`) forms both work. Section/page overlay entries whose + `slug:` does not match any sibling tree node are now ignored and reported + via a CLI warning instead of being silently re-attached positionally, so + drifted overlays surface during `fern check` / `fern generate` instead of + producing mistranslated navigation. When a variant overlay sets `title:`, + the variant's `variantId` is also updated so downstream renderers that + key off the typed variant identifier (such as custom navbar dropdowns + built directly from the navigation tree) display the translated label. type: fix diff --git a/packages/cli/docs-preview/src/runAppPreviewServer.ts b/packages/cli/docs-preview/src/runAppPreviewServer.ts index 9201cc456971..705648fa5356 100644 --- a/packages/cli/docs-preview/src/runAppPreviewServer.ts +++ b/packages/cli/docs-preview/src/runAppPreviewServer.ts @@ -843,7 +843,7 @@ export async function runAppPreviewServer({ let translatedAnnouncement = docsDefinition.config.announcement; let translatedNavbarLinks = docsDefinition.config.navbarLinks; if (localeNavOverlay != null) { - updatedRoot = applyTranslatedNavigationOverlays(updatedRoot, localeNavOverlay); + updatedRoot = applyTranslatedNavigationOverlays(updatedRoot, localeNavOverlay, context); translatedAnnouncement = getTranslatedAnnouncement(localeNavOverlay) ?? translatedAnnouncement; if (localeNavOverlay.navbarLinks != null) { translatedNavbarLinks = localeNavOverlay.navbarLinks; diff --git a/packages/cli/docs-preview/src/runPreviewServer.ts b/packages/cli/docs-preview/src/runPreviewServer.ts index aa290c05e4db..ae75df01211e 100644 --- a/packages/cli/docs-preview/src/runPreviewServer.ts +++ b/packages/cli/docs-preview/src/runPreviewServer.ts @@ -269,7 +269,7 @@ export async function runPreviewServer({ let translatedAnnouncement = docsDefinition.config.announcement; let translatedNavbarLinks = docsDefinition.config.navbarLinks; if (localeNavOverlay != null) { - updatedRoot = applyTranslatedNavigationOverlays(updatedRoot, localeNavOverlay); + updatedRoot = applyTranslatedNavigationOverlays(updatedRoot, localeNavOverlay, context); translatedAnnouncement = getTranslatedAnnouncement(localeNavOverlay) ?? translatedAnnouncement; if (localeNavOverlay.navbarLinks != null) { translatedNavbarLinks = localeNavOverlay.navbarLinks; diff --git a/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts b/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts index 9e082212a32c..b455ac7960de 100644 --- a/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts +++ b/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts @@ -1,9 +1,29 @@ import { docsYml } from "@fern-api/configuration"; import { FernNavigation } from "@fern-api/fdr-sdk"; -import { describe, expect, it } from "vitest"; +import { TaskContext } from "@fern-api/task-context"; +import { describe, expect, it, vi } from "vitest"; import { applyTranslatedNavigationOverlays, getTranslatedAnnouncement } from "../applyTranslatedNavigationOverlays.js"; +function makeContextWithWarn(): { context: TaskContext; warn: ReturnType } { + const warn = vi.fn(); + const noop = (): void => undefined; + // Minimal stub: only `logger.warn` is exercised by the matcher. + const context = { + logger: { + disable: noop, + enable: noop, + trace: noop, + debug: noop, + info: noop, + warn, + error: noop, + log: noop + } + } as unknown as TaskContext; + return { context, warn }; +} + function asRoot(obj: unknown): FernNavigation.V1.RootNode { return obj as FernNavigation.V1.RootNode; } @@ -692,6 +712,77 @@ describe("applyTranslatedNavigationOverlays", () => { expect(modelsChildren[0]?.title).toBe("W&B Modèles"); expect(modelsChildren[1]?.title).toBe("Gérer les secrets"); }); + + it("does not apply an overlay entry whose slug does not match any sibling tree node", () => { + // Stricter slug matching: overlays that specify `slug:` are matched + // only by slug. They are NOT silently re-attached positionally when + // no sibling tree node has that slug — instead the overlay is ignored + // and a warning is emitted so authors can fix drifted overlays. + const root = { + type: "root", + child: { + type: "sidebarRoot", + children: [ + { type: "page", title: "Getting Started", slug: "getting-started" }, + { type: "page", title: "Reference", slug: "reference" } + ] + } + }; + const overlay: docsYml.TranslationNavigationOverlay = { + ...emptyOverlay(), + navigation: [ + { type: "page", title: "Mise en route", slug: "getting-started" }, + // This entry's slug doesn't match any sibling in the tree. + // Previously the positional fallback would silently apply it + // to whichever tree page sat at position 1 ("reference"). The + // stricter matcher leaves "Reference" untranslated and warns. + { type: "page", title: "Référence des SDK", slug: "sdk-reference" } + ] + }; + + const { context, warn } = makeContextWithWarn(); + const result = applyTranslatedNavigationOverlays(asRoot(root), overlay, context) as unknown as Record< + string, + unknown + >; + + const sidebarRoot = result.child as Record; + const pages = sidebarRoot.children as Array>; + expect(pages[0]?.title).toBe("Mise en route"); + expect(pages[1]?.title).toBe("Reference"); + + expect(warn).toHaveBeenCalledTimes(1); + const [warning] = warn.mock.calls[0] ?? []; + expect(warning).toContain("sdk-reference"); + expect(warning).toContain("page"); + }); + + it("does not warn when an overlay omits slug and matches positionally", () => { + // Overlays without `slug:` are intentionally positional. They should + // not trigger the unmatched-slug warning. + const root = { + type: "root", + child: { + type: "sidebarRoot", + children: [{ type: "page", title: "Getting Started", slug: "getting-started" }] + } + }; + const overlay: docsYml.TranslationNavigationOverlay = { + ...emptyOverlay(), + navigation: [{ type: "page", title: "Mise en route", slug: undefined }] + }; + + const { context, warn } = makeContextWithWarn(); + const result = applyTranslatedNavigationOverlays(asRoot(root), overlay, context) as unknown as Record< + string, + unknown + >; + + const sidebarRoot = result.child as Record; + const pages = sidebarRoot.children as Array>; + expect(pages[0]?.title).toBe("Mise en route"); + expect(warn).not.toHaveBeenCalled(); + }); }); describe("getTranslatedAnnouncement", () => { diff --git a/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts b/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts index 1719564ba096..2f888b79dcdb 100644 --- a/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts +++ b/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts @@ -1,5 +1,6 @@ import { docsYml } from "@fern-api/configuration"; import { FernNavigation } from "@fern-api/fdr-sdk"; +import { TaskContext } from "@fern-api/task-context"; /** * Applies translated navigation overlays to the resolved nav tree. @@ -16,17 +17,20 @@ import { FernNavigation } from "@fern-api/fdr-sdk"; * - Variants: matched by variant slug first, then positionally among * overlays without an explicit slug; the matched overlay's `layout:` * becomes the scoped navigation context for the variant's children - * - Sections/Pages: matched by slug first, then positionally within the - * currently scoped overlay's navigation items + * - Sections/Pages: matched by slug first, then positionally only among + * overlay entries that omit `slug:`. Overlay entries whose `slug:` is set + * but does not match any sibling tree node are reported via `context.logger` + * so authors can fix drifted overlays instead of silently falling through. */ export function applyTranslatedNavigationOverlays( root: FernNavigation.V1.RootNode | undefined, - overlay: docsYml.TranslationNavigationOverlay + overlay: docsYml.TranslationNavigationOverlay, + context?: TaskContext ): FernNavigation.V1.RootNode | undefined { if (root == null) { return undefined; } - const result = walkAndApply(root, overlay) as FernNavigation.V1.RootNode; + const result = walkAndApply(root, overlay, context) as FernNavigation.V1.RootNode; return result; } @@ -40,12 +44,16 @@ export function getTranslatedAnnouncement(overlay: docsYml.TranslationNavigation return undefined; } -function walkAndApply(node: unknown, overlay: docsYml.TranslationNavigationOverlay): unknown { +function walkAndApply( + node: unknown, + overlay: docsYml.TranslationNavigationOverlay, + context: TaskContext | undefined +): unknown { if (node == null || typeof node !== "object") { return node; } if (Array.isArray(node)) { - return node.map((item) => walkAndApply(item, overlay)); + return node.map((item) => walkAndApply(item, overlay, context)); } const obj = node as Record; @@ -53,9 +61,9 @@ function walkAndApply(node: unknown, overlay: docsYml.TranslationNavigationOverl for (const [k, v] of Object.entries(obj)) { if (k === "children" && Array.isArray(v)) { - updated[k] = applyChildOverlays(v, obj, overlay); + updated[k] = applyChildOverlays(v, obj, overlay, context); } else { - updated[k] = walkAndApply(v, overlay); + updated[k] = walkAndApply(v, overlay, context); } } @@ -65,7 +73,8 @@ function walkAndApply(node: unknown, overlay: docsYml.TranslationNavigationOverl function applyChildOverlays( children: unknown[], parent: Record, - overlay: docsYml.TranslationNavigationOverlay + overlay: docsYml.TranslationNavigationOverlay, + context: TaskContext | undefined ): unknown[] { const parentType = parent["type"] as string | undefined; @@ -74,7 +83,7 @@ function applyChildOverlays( return children.map((child, index) => { const childObj = child as Record | null; if (childObj == null || typeof childObj !== "object") { - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); } const productOverlay = findProductOverlay(childObj, overlay.products ?? [], index); if (productOverlay != null) { @@ -87,10 +96,10 @@ function applyChildOverlays( navigation: productOverlay.navigation ?? overlay.navigation, navbarLinks: overlay.navbarLinks }; - const walked = walkAndApply(child, scopedOverlay) as Record; + const walked = walkAndApply(child, scopedOverlay, context) as Record; return applyProductOverlayToNode(walked, productOverlay); } - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); }); } @@ -99,7 +108,7 @@ function applyChildOverlays( return children.map((child, index) => { const childObj = child as Record | null; if (childObj == null || typeof childObj !== "object") { - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); } const versionOverlay = findVersionOverlay(childObj, overlay.versions ?? [], index); if (versionOverlay != null) { @@ -112,10 +121,10 @@ function applyChildOverlays( navigation: versionOverlay.navigation ?? overlay.navigation, navbarLinks: overlay.navbarLinks }; - const walked = walkAndApply(child, scopedOverlay) as Record; + const walked = walkAndApply(child, scopedOverlay, context) as Record; return applyVersionOverlayToNode(walked, versionOverlay); } - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); }); } @@ -129,14 +138,14 @@ function applyChildOverlays( return children.map((child) => { const childObj = child as Record | null; if (childObj == null || typeof childObj !== "object") { - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); } if (childObj["type"] === "tab") { const positionalTabId = orderedTabIds[tabIndex]; tabIndex++; - return applyTabOverlayToNode(childObj, overlay, positionalTabId); + return applyTabOverlayToNode(childObj, overlay, positionalTabId, context); } - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); }); } @@ -145,7 +154,7 @@ function applyChildOverlays( if (parentType === "varianted") { const variantOverlays = collectVariantOverlaysFromTabLayout(overlay); if (variantOverlays != null && variantOverlays.length > 0) { - return applyVariantOverlays(children, variantOverlays, overlay); + return applyVariantOverlays(children, variantOverlays, overlay, context); } } @@ -153,7 +162,7 @@ function applyChildOverlays( if (parentType === "sidebarRoot") { const navOverlays = collectFlatNavigationOverlays(overlay); if (navOverlays.length > 0) { - return applySidebarChildOverlays(children, navOverlays, overlay); + return applySidebarChildOverlays(children, navOverlays, overlay, context); } } @@ -162,7 +171,7 @@ function applyChildOverlays( if (parentType === "sidebarGroup") { const navOverlays = collectFlatNavigationOverlays(overlay); if (navOverlays.length > 0) { - return applySidebarChildOverlays(children, navOverlays, overlay); + return applySidebarChildOverlays(children, navOverlays, overlay, context); } } @@ -170,10 +179,10 @@ function applyChildOverlays( if (parentType === "section") { // Section children are handled via the section overlay's contents // This is managed through the section overlay propagation - return children.map((child) => walkAndApply(child, overlay)); + return children.map((child) => walkAndApply(child, overlay, context)); } - return children.map((child) => walkAndApply(child, overlay)); + return children.map((child) => walkAndApply(child, overlay, context)); } function findProductOverlay( @@ -270,7 +279,8 @@ function applyVersionOverlayToNode( function applyTabOverlayToNode( node: Record, overlay: docsYml.TranslationNavigationOverlay, - positionalTabId?: string + positionalTabId: string | undefined, + context: TaskContext | undefined ): unknown { const tabSlug = extractLastSlugSegment(node["slug"] as string | undefined); @@ -332,11 +342,11 @@ function applyTabOverlayToNode( // new tree (children too); the title override we just set on // `out` is preserved because walkAndApply does not touch the // "title" key. - return walkAndApply(out, scopedOverlay); + return walkAndApply(out, scopedOverlay, context); } } - return walkAndApply(out, overlay); + return walkAndApply(out, overlay, context); } /** @@ -439,15 +449,20 @@ function collectFlatNavigationOverlays(overlay: docsYml.TranslationNavigationOve function applySidebarChildOverlays( children: unknown[], navOverlays: docsYml.NavigationItemOverlay[], - overlay: docsYml.TranslationNavigationOverlay + overlay: docsYml.TranslationNavigationOverlay, + context: TaskContext | undefined ): unknown[] { let sectionIdx = 0; let pageIdx = 0; - return children.map((child) => { + // Track which overlay entries actually matched a tree node so we can + // warn on slug-bearing overlays that didn't line up with any sibling. + const matchedOverlays = new Set(); + + const result = children.map((child) => { const childObj = child as Record | null; if (childObj == null || typeof childObj !== "object") { - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); } const childType = childObj["type"] as string | undefined; @@ -460,7 +475,8 @@ function applySidebarChildOverlays( sectionIdx++; if (matched != null) { - const walked = walkAndApply(child, overlay) as Record; + matchedOverlays.add(matched); + const walked = walkAndApply(child, overlay, context) as Record; if (matched.title != null) { walked["title"] = matched.title; } @@ -468,12 +484,12 @@ function applySidebarChildOverlays( // Re-apply section content overlays recursively const childArray = walked["children"] as unknown[] | undefined; if (childArray != null) { - walked["children"] = applySidebarChildOverlays(childArray, matched.contents, overlay); + walked["children"] = applySidebarChildOverlays(childArray, matched.contents, overlay, context); } } return walked; } - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); } if (childType === "page" || childType === "landingPage") { @@ -483,16 +499,53 @@ function applySidebarChildOverlays( const matched = matchPageOverlay(childObj, pageOverlays, pageIdx); pageIdx++; - if (matched?.title != null) { - const walked = walkAndApply(child, overlay) as Record; - walked["title"] = matched.title; - return walked; + if (matched != null) { + matchedOverlays.add(matched); + if (matched.title != null) { + const walked = walkAndApply(child, overlay, context) as Record; + walked["title"] = matched.title; + return walked; + } } - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); } - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); }); + + warnOnUnmatchedSlugOverlays(navOverlays, matchedOverlays, context); + return result; +} + +/** + * Emits a warning for each overlay entry that has a `slug:` set but did not + * match any sibling tree node. Overlays with no `slug:` are intentionally + * positional and not subject to the warning. + */ +function warnOnUnmatchedSlugOverlays( + overlays: docsYml.NavigationItemOverlay[], + matched: Set, + context: TaskContext | undefined +): void { + if (context == null) { + return; + } + for (const o of overlays) { + if (matched.has(o)) { + continue; + } + if (o.type !== "section" && o.type !== "page") { + continue; + } + const slug = (o as { slug?: string }).slug; + if (slug == null) { + continue; + } + const titleSuffix = o.title != null ? ` (title: "${o.title}")` : ""; + context.logger.warn( + `Translation overlay ${o.type} with slug "${slug}"${titleSuffix} did not match any sibling navigation entry; the overlay was ignored.` + ); + } } function matchSectionOverlay( @@ -509,18 +562,14 @@ function matchSectionOverlay( } } - // Positional fallback: prefer overlays without a slug (those are meant to - // match by position) and fall back to all overlays positionally so that - // overlay files mirroring the source navigation structure still resolve - // when slugs don't line up exactly (e.g. tree slug `home` vs. overlay - // slug `/`). + // Positional fallback: only match against overlays that omit `slug:`. + // Overlays with a `slug:` that didn't match by slug are NOT silently + // re-attached by position — they're reported via `warnOnUnmatchedSlugOverlays` + // so authors can fix drifted overlays. const noSlugOverlays = overlays.filter((o) => o.slug == null); if (positionIndex < noSlugOverlays.length) { return noSlugOverlays[positionIndex]; } - if (noSlugOverlays.length === 0 && positionIndex < overlays.length) { - return overlays[positionIndex]; - } return undefined; } @@ -538,18 +587,14 @@ function matchPageOverlay( } } - // Positional fallback: prefer overlays without a slug (those are meant to - // match by position) and fall back to all overlays positionally so that - // overlay files mirroring the source navigation structure still resolve - // when slugs don't line up exactly (e.g. tree slug `home` vs. overlay - // slug `/`). + // Positional fallback: only match against overlays that omit `slug:`. + // Overlays with a `slug:` that didn't match by slug are NOT silently + // re-attached by position — they're reported via `warnOnUnmatchedSlugOverlays` + // so authors can fix drifted overlays. const noSlugOverlays = overlays.filter((o) => o.slug == null); if (positionIndex < noSlugOverlays.length) { return noSlugOverlays[positionIndex]; } - if (noSlugOverlays.length === 0 && positionIndex < overlays.length) { - return overlays[positionIndex]; - } return undefined; } @@ -591,7 +636,8 @@ function normalizeOverlaySlug(slug: string): string { function applyVariantOverlays( variants: unknown[], overlays: docsYml.VariantOverlay[], - parentOverlay: docsYml.TranslationNavigationOverlay + parentOverlay: docsYml.TranslationNavigationOverlay, + context: TaskContext | undefined ): unknown[] { const consumedNoSlugIndices = new Set(); let noSlugCursor = 0; @@ -633,7 +679,7 @@ function applyVariantOverlays( } if (matchedOverlay == null) { - return walkAndApply(variant, parentOverlay); + return walkAndApply(variant, parentOverlay, context); } const result: Record = { ...variantObj }; @@ -664,10 +710,15 @@ function applyVariantOverlays( navigation: matchedOverlay.layout, navbarLinks: undefined }; - result["children"] = applySidebarChildOverlays(children, matchedOverlay.layout, variantScopedOverlay); + result["children"] = applySidebarChildOverlays( + children, + matchedOverlay.layout, + variantScopedOverlay, + context + ); return result; } - return walkAndApply(result, parentOverlay); + return walkAndApply(result, parentOverlay, context); }); } diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts index c3fbf73372f6..5269d13ba2c9 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocs.ts @@ -797,7 +797,7 @@ export async function publishDocs({ let translatedAnnouncement = docsDefinition.config.announcement; let translatedNavbarLinks = docsDefinition.config.navbarLinks; if (localeNavOverlay != null) { - updatedRoot = applyTranslatedNavigationOverlays(updatedRoot, localeNavOverlay); + updatedRoot = applyTranslatedNavigationOverlays(updatedRoot, localeNavOverlay, context); translatedAnnouncement = getTranslatedAnnouncement(localeNavOverlay) ?? translatedAnnouncement; if (localeNavOverlay.navbarLinks != null) {