Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion packages/cli/docs-preview/src/runAppPreviewServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/docs-preview/src/runPreviewServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> } {
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;
}
Expand Down Expand Up @@ -556,6 +576,213 @@ describe("applyTranslatedNavigationOverlays", () => {
const origTab = (origTabbed[0]?.children as Array<Record<string, unknown>>)[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<string, unknown>;
const tabbed = (result.child as Record<string, unknown>).children as Array<Record<string, unknown>>;
const tab = tabbed[0] as Record<string, unknown>;
expect(tab.title).toBe("Produits");

const sidebarRoot = tab.child as Record<string, unknown>;
const varianted = (sidebarRoot.children as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
const variants = varianted.children as Array<Record<string, unknown>>;

const home = variants[0] as Record<string, unknown>;
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<Record<string, unknown>>;
expect(homeChildren[0]?.title).toBe("Documentation FR");
const platformSection = homeChildren[1] as Record<string, unknown>;
expect(platformSection.title).toBe("Détails de la plateforme");
const platformChildren = platformSection.children as Array<Record<string, unknown>>;
expect(platformChildren[0]?.title).toBe("Configurer SMTP");

const models = variants[1] as Record<string, unknown>;
expect(models.title).toBe("W&B Modèles");
expect(models.variantId).toBe("W&B Modèles");
const modelsChildren = models.children as Array<Record<string, unknown>>;
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<string, unknown>;
const pages = sidebarRoot.children as Array<Record<string, unknown>>;
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<string, unknown>;
const pages = sidebarRoot.children as Array<Record<string, unknown>>;
expect(pages[0]?.title).toBe("Mise en route");
expect(warn).not.toHaveBeenCalled();
});
});

describe("getTranslatedAnnouncement", () => {
Expand Down
Loading
Loading