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..80f44a74add8 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-translate-variant-layouts.yml @@ -0,0 +1,17 @@ +# 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 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/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-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 6b0efbd2cee8..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; } @@ -556,6 +576,213 @@ 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", + variantId: "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", + variantId: "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"); + // 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; + 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"); + 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"); + }); + + 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 9f0a9cec58eb..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. @@ -13,17 +14,23 @@ 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 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; } @@ -37,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; @@ -50,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); } } @@ -62,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; @@ -71,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) { @@ -84,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); }); } @@ -96,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) { @@ -109,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); }); } @@ -126,23 +138,31 @@ 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++; - const walked = walkAndApply(child, overlay) as Record; - return applyTabOverlayToNode(walked, overlay, positionalTabId); + return applyTabOverlayToNode(childObj, overlay, positionalTabId, context); } - return walkAndApply(child, overlay); + return walkAndApply(child, overlay, context); }); } + // 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, context); + } + } + // SidebarRoot children → match sections/pages with overlay.navigation if (parentType === "sidebarRoot") { const navOverlays = collectFlatNavigationOverlays(overlay); if (navOverlays.length > 0) { - return applySidebarChildOverlays(children, navOverlays, overlay); + return applySidebarChildOverlays(children, navOverlays, overlay, context); } } @@ -151,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); } } @@ -159,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( @@ -259,10 +279,14 @@ 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); + // 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 @@ -272,7 +296,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; } @@ -281,7 +305,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; } } @@ -294,36 +318,55 @@ 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; } } - // 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); - } - } + // 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) { + // 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, context); } + } - // 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 - }; - return walkAndApply(node, scopedOverlay); + return walkAndApply(out, overlay, context); +} + +/** + * 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 walkAndApply(node, overlay); + return undefined; } function findTabNavOverlay( @@ -406,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; @@ -427,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; } @@ -435,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") { @@ -450,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( @@ -471,13 +557,15 @@ 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: 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]; @@ -494,13 +582,15 @@ 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: 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]; @@ -516,12 +606,47 @@ 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, + context: TaskContext | undefined +): 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 +660,65 @@ 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, context); + } + + 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; + } + + // 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, + context + ); return result; } - return variant; + 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) {