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,3 @@
- summary: |
Support translated API specs in docs publishes by registering locale-specific API definitions and wiring translated docs payloads and endpoint navigation titles to those API IDs.
type: feat
Original file line number Diff line number Diff line change
Expand Up @@ -2476,6 +2476,18 @@ function parseNavigationItemOverlays(items: unknown[]): docsYml.NavigationItemOv
continue;
}

// An API reference item: { api: "Title", layout: [...] }
if (typeof obj.api === "string") {
const apiOverlay: docsYml.NavigationItemOverlay.Api = {
type: "api",
title: obj.api,
slug: typeof obj.slug === "string" ? obj.slug : undefined,
layout: Array.isArray(obj.layout) ? parseNavigationItemOverlays(obj.layout) : undefined
};
result.push(apiOverlay);
continue;
}

// A page item: { page: "Title", path: "..." }
if (typeof obj.page === "string") {
const pageOverlay: docsYml.NavigationItemOverlay.Page = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ export interface AnnouncementOverlay {
export type NavigationItemOverlay =
| NavigationItemOverlay.Page
| NavigationItemOverlay.Section
| NavigationItemOverlay.Api
| NavigationItemOverlay.Tab
| NavigationItemOverlay.Variant;

Expand All @@ -615,6 +616,12 @@ export declare namespace NavigationItemOverlay {
slug: string | undefined;
contents: NavigationItemOverlay[] | undefined;
}
export interface Api {
type: "api";
title: string | undefined;
slug: string | undefined;
layout: NavigationItemOverlay[] | undefined;
}
export interface Tab {
type: "tab";
tabId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,83 @@ describe("applyTranslatedNavigationOverlays", () => {
expect(sectionChildren[0]?.title).toBe("概要");
});

it("applies api reference and api package title overrides from navigation overlay", () => {
const root = {
type: "root",
child: {
type: "unversioned",
child: {
type: "tabbed",
children: [
{
type: "tab",
title: "API Reference",
slug: "docs/api-reference",
child: {
type: "sidebarRoot",
children: [
{
type: "apiReference",
title: "Plant Store API",
slug: "docs/api-reference/plant-store-api",
children: [
{
type: "apiPackage",
title: "Plants",
slug: "docs/api-reference/plant-store-api/plants",
children: [
{
type: "endpoint",
title: "Create plant",
slug: "docs/api-reference/plant-store-api/plants/create-plant"
}
]
}
]
}
]
}
}
]
}
}
};
const overlay: docsYml.TranslationNavigationOverlay = {
...emptyOverlay(),
tabs: {
"api-reference": { displayName: "API 参考", slug: undefined }
},
navigation: [
{
type: "tab",
tabId: "API Reference",
layout: [
{
type: "api",
title: "植物商店 API",
slug: "plant-store-api",
layout: [{ type: "section", title: "植物", slug: "plants", contents: undefined }]
}
],
variants: undefined
}
]
};

const result = applyTranslatedNavigationOverlays(asRoot(root), overlay);
const unversioned = (result as unknown as Record<string, unknown>).child as Record<string, unknown>;
const tabbed = unversioned.child as Record<string, unknown>;
const tab = (tabbed.children as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
expect(tab.title).toBe("API 参考");

const sidebarRoot = tab.child as Record<string, unknown>;
const apiReference = (sidebarRoot.children as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
expect(apiReference.title).toBe("植物商店 API");

const apiPackage = (apiReference.children as Array<Record<string, unknown>>)[0] as Record<string, unknown>;
expect(apiPackage.title).toBe("植物");
});

it("matches tabs by overlay slug when tab ID differs from nav slug", () => {
const root = {
type: "root",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,12 @@
let appliedTabId: string | undefined;
if (overlay.tabs != null && tabSlug != null) {
for (const [tabId, tabOverlay] of Object.entries(overlay.tabs)) {
const isMatch = tabId === tabSlug || (tabOverlay.slug != null && tabOverlay.slug === tabSlug);
// Match by tab ID (YAML key) against nav tree slug segment, or
// by the overlay's explicit slug field against the slug segment
const isMatch =
tabId === tabSlug ||
slugifyNavigationKey(tabId) === tabSlug ||
(tabOverlay.slug != null && tabOverlay.slug === tabSlug);
if (isMatch && tabOverlay.displayName != null) {
node["title"] = tabOverlay.displayName;
appliedTabId = tabId;
Expand Down Expand Up @@ -340,7 +345,7 @@
continue;
}
// Match by tab ID against slug
if (tabSlug != null && navItem.tabId === tabSlug) {
if (tabSlug != null && (navItem.tabId === tabSlug || slugifyNavigationKey(navItem.tabId) === tabSlug)) {
return navItem;
}
// Match by overlay tab's explicit slug field against the nav tree slug
Expand Down Expand Up @@ -408,6 +413,7 @@
navOverlays: docsYml.NavigationItemOverlay[],
overlay: docsYml.TranslationNavigationOverlay
): unknown[] {
let apiIdx = 0;
let sectionIdx = 0;
let pageIdx = 0;

Expand All @@ -419,6 +425,29 @@

const childType = childObj["type"] as string | undefined;

if (childType === "apiReference") {
const apiOverlays = navOverlays.filter(
(item): item is docsYml.NavigationItemOverlay.Api => item.type === "api"
);
const matched = matchApiOverlay(childObj, apiOverlays, apiIdx);
apiIdx++;

if (matched != null) {
const walked = walkAndApply(child, overlay) as Record<string, unknown>;
if (matched.title != null) {
walked["title"] = matched.title;
}
if (matched.layout != null) {
const childArray = walked["children"] as unknown[] | undefined;
if (childArray != null) {
walked["children"] = applySidebarChildOverlays(childArray, matched.layout, overlay);
}
}
return walked;
}
return walkAndApply(child, overlay);
}

if (childType === "section") {
const sectionOverlays = navOverlays.filter(
(item): item is docsYml.NavigationItemOverlay.Section => item.type === "section"
Expand All @@ -443,6 +472,29 @@
return walkAndApply(child, overlay);
}

if (childType === "apiPackage") {
const sectionOverlays = navOverlays.filter(
(item): item is docsYml.NavigationItemOverlay.Section => item.type === "section"
);
const matched = matchSectionOverlay(childObj, sectionOverlays, sectionIdx);
sectionIdx++;

if (matched != null) {
const walked = walkAndApply(child, overlay) as Record<string, unknown>;
if (matched.title != null) {
walked["title"] = matched.title;
}
if (matched.contents != null) {
const childArray = walked["children"] as unknown[] | undefined;
if (childArray != null) {
walked["children"] = applySidebarChildOverlays(childArray, matched.contents, overlay);
}
}
return walked;
}
return walkAndApply(child, overlay);
}

if (childType === "page" || childType === "landingPage") {
const pageOverlays = navOverlays.filter(
(item): item is docsYml.NavigationItemOverlay.Page => item.type === "page"
Expand All @@ -459,12 +511,32 @@
}

return walkAndApply(child, overlay);
});
}

function matchApiOverlay(
apiReference: Record<string, unknown>,
overlays: docsYml.NavigationItemOverlay.Api[],
positionIndex: number
): docsYml.NavigationItemOverlay.Api | undefined {
const apiSlug = extractLastSlugSegment(apiReference["slug"] as string | undefined);

for (const o of overlays) {
if (o.slug != null && o.slug === apiSlug) {
return o;
}
}

const noSlugOverlays = overlays.filter((o) => o.slug == null);
if (positionIndex < noSlugOverlays.length) {
return noSlugOverlays[positionIndex];
}
return undefined;
}

function matchSectionOverlay(
section: Record<string, unknown>,
overlays: docsYml.NavigationItemOverlay.Section[],

Check failure on line 539 in packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts

View check run for this annotation

Claude / Claude Code Review

Positional fallback skips trailing no-slug overlays after slug-matched siblings

Positional fallback in `matchApiOverlay` (lines 517-535) and the matching `matchSectionOverlay`/`matchPageOverlay` paths can silently drop a no-slug overlay at the tail of the children list. `apiIdx`/`sectionIdx`/`pageIdx` are incremented for every sibling of that kind in `applySidebarChildOverlays`, but the fallback then bounds-checks against `noSlugOverlays.length` — so a slug-matched earlier sibling inflates the index past the no-slug array length, and the trailing sibling silently keeps its
Comment on lines 514 to 539
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Positional fallback in matchApiOverlay (lines 517-535) and the matching matchSectionOverlay/matchPageOverlay paths can silently drop a no-slug overlay at the tail of the children list. apiIdx/sectionIdx/pageIdx are incremented for every sibling of that kind in applySidebarChildOverlays, but the fallback then bounds-checks against noSlugOverlays.length — so a slug-matched earlier sibling inflates the index past the no-slug array length, and the trailing sibling silently keeps its base-locale title. This affects the new matchApiOverlay and apiPackage branches introduced by this PR (and the pre-existing section/page paths). Fix: track a parallel counter that only increments on slug-miss, or filter noSlugOverlays once outside the loop and consume sequentially.

Extended reasoning...

What the bug is

In applySidebarChildOverlays (applyTranslatedNavigationOverlays.ts:411-515), the per-kind counters apiIdx, sectionIdx, and pageIdx are unconditionally incremented after every sibling of that kind (lines 433, 456, 480, 503), regardless of whether the match succeeded by slug or by positional fallback. But matchApiOverlay (517-535), matchSectionOverlay (537-558), and matchPageOverlay (560-581) all share the same fallback shape:

const noSlugOverlays = overlays.filter((o) => o.slug == null);
if (positionIndex < noSlugOverlays.length) {
    return noSlugOverlays[positionIndex];
}
return undefined;

The counter passed in as positionIndex was incremented for every sibling — including those that were resolved via the earlier slug-match loop. So when overlays mix slug and no-slug entries, the index is inflated past noSlugOverlays.length and a trailing no-slug overlay is never consumed.

Why existing code does not prevent it

Both the slug-match loop and the increment are unconditional — there is no signal that distinguishes "was matched by slug" from "missed and fell through." The increment happens at the call site (e.g. line 433 apiIdx++) immediately after the call, and there is no separate counter tracking only no-slug consumption. The noSlugOverlays filter is also recomputed on every child (a separate minor cost), and there is no test fixture covering the mixed slug + no-slug overlay case at the same level.

Step-by-step proof (apiReference branch — newly introduced by this PR)

Layout: children = [apiReference(slug:'plants'), apiReference(slug:'pets')]
Overlay: navOverlays = [{type:'api', slug:'plants', title:'植物'}, {type:'api', slug:undefined, title:'宠物'}]

  1. Enter applySidebarChildOverlays with apiIdx = 0.
  2. Iter 0: childType is 'apiReference', apiOverlays = both entries. Call matchApiOverlay(plantsChild, apiOverlays, 0).
    • Slug match loop: overlays[0].slug === 'plants' ✓ → returns overlays[0].
    • apiIdx++apiIdx = 1. 'plants' apiReference gets title '植物'.
  3. Iter 1: childType is 'apiReference'. Call matchApiOverlay(petsChild, apiOverlays, 1).
    • Slug match loop: no overlay has slug 'pets' → falls through.
    • noSlugOverlays = [overlays[1]] (length 1).
    • Bounds check: positionIndex(1) < noSlugOverlays.length(1) is false → returns undefined.
    • 'pets' apiReference silently retains its base-locale English title.

The intended overlay ({slug:undefined, title:'宠物'}) was meant to apply positionally to 'pets' but is dropped. No warning is emitted.

The same trace applies symmetrically to:

  • The new apiPackage branch (lines 475-496), which shares sectionIdx with the existing section branch — so any prior slug-matched section in the same scope inflates the index for a trailing no-slug apiPackage overlay (and vice versa, since both kinds advance the same counter).
  • The pre-existing section and page branches (the bug already existed there before this PR).

Impact

Silent partial-translation: any docs site whose translated navigation overlays mix slug-keyed and non-slug-keyed entries at the same nesting level will see the trailing no-slug entry dropped whenever an earlier sibling matched by slug. Users authoring overlays naturally end up in this state — a subset of items have explicit slugs to disambiguate, the rest rely on positional ordering. The newly introduced matchApiOverlay and apiPackage branches extend the failure mode to API navigation translation, which is the headline feature of this PR.

How to fix

Either (a) thread a parallel counter that is only bumped on a non-slug-match, e.g.:

let noSlugApiIdx = 0;
// ...
if (childType === 'apiReference') {
    const matched = matchApiOverlay(childObj, apiOverlays, noSlugApiIdx);
    if (matched != null && matched.slug == null) noSlugApiIdx++;
}

or (b) filter noSlugOverlays once outside the loop and consume entries sequentially via Array.shift() (or an iterator) so each no-slug overlay is used exactly once, regardless of how many slug-matched siblings appear before it.

positionIndex: number
): docsYml.NavigationItemOverlay.Section | undefined {
const sectionSlug = extractLastSlugSegment(section["slug"] as string | undefined);
Expand Down Expand Up @@ -516,6 +588,14 @@
return parts[parts.length - 1];
}

function slugifyNavigationKey(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}

Check warning on line 597 in packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts

View check run for this annotation

Claude / Claude Code Review

slugifyNavigationKey diverges from lodash kebabCase used to generate tab slugs

The new `slugifyNavigationKey` helper at `applyTranslatedNavigationOverlays.ts:591-597` diverges from the `lodash.kebabCase` used to generate tab nav slugs in `DocsDefinitionResolver.toTabNode` (DocsDefinitionResolver.ts:2147,2175). For camelCase YAML tab keys (e.g. `apiReference`), `slugifyNavigationKey('apiReference')` returns `'apireference'` while `kebabCase('API Reference')` returns `'api-reference'`, so the translated tab title plus all section/page titles inside that tab silently fall bac
Comment on lines +591 to +597
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new slugifyNavigationKey helper at applyTranslatedNavigationOverlays.ts:591-597 diverges from the lodash.kebabCase used to generate tab nav slugs in DocsDefinitionResolver.toTabNode (DocsDefinitionResolver.ts:2147,2175). For camelCase YAML tab keys (e.g. apiReference), slugifyNavigationKey('apiReference') returns 'apireference' while kebabCase('API Reference') returns 'api-reference', so the translated tab title plus all section/page titles inside that tab silently fall back to the base locale. One-line fix: import kebabCase from lodash-es (already imported throughout this package) and use it in place of slugifyNavigationKey in both applyTabOverlayToNode and findTabNavOverlay.

Extended reasoning...

What the bug is

This PR introduces a new fallback for matching overlay tab keys (the YAML keys under translations/<locale>/docs.yml tabs:) against the tab's nav-tree slug:

// applyTranslatedNavigationOverlays.ts:591-597
function slugifyNavigationKey(value: string): string {
    return value
        .trim()
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-+|-+$/g, "");
}

Used in two places:

  • applyTabOverlayToNode (line 277): slugifyNavigationKey(tabId) === tabSlug
  • findTabNavOverlay (line 348): slugifyNavigationKey(navItem.tabId) === tabSlug

But the other half of the mapping — the tab's nav-tree slug itself — is generated by lodash kebabCase:

  • DocsDefinitionResolver.toTabNode (DocsDefinitionResolver.ts:2147): urlSlug: item.slug ?? kebabCase(item.title)
  • DocsDefinitionResolver.toTabNodeWithVariants (DocsDefinitionResolver.ts:2175): same.

lodash.kebabCase splits camelCase / PascalCase boundaries; slugifyNavigationKey does not, because it only collapses runs of non-alphanumeric characters after a toLowerCase().

Why existing safeguards do not catch this

The matching ladder in applyTabOverlayToNode is:

  1. tabId === tabSlug — exact equality, fails for camelCase YAML keys vs kebab-case slugs.
  2. slugifyNavigationKey(tabId) === tabSlug — the new fallback, also fails for camelCase keys.
  3. tabOverlay.slug === tabSlug — only succeeds if the user explicitly sets slug: on every translated tab entry (undocumented for this purpose).

The positional fallback in findTabNavOverlay only rescues skip-slug tabs that share a parent slug — not ordinary slug-distinct tabs.

Step-by-step proof

Given a base docs.yml:

tabs:
  apiReference:
    display-name: "API Reference"
  1. The base nav-tree builds the tab via toTabNode, producing urlSlug = kebabCase("API Reference") = "api-reference". The tab's slug ends in "api-reference".

  2. The translated translations/zh/docs.yml:

    tabs:
      apiReference:
        display-name: "API 参考"

    parses to an overlay with tabs: { apiReference: { displayName: "API 参考", slug: undefined } }.

  3. In applyTabOverlayToNode, tabSlug = "api-reference", tabId = "apiReference":

    • tabId === tabSlug: "apiReference" === "api-reference" → false.
    • slugifyNavigationKey(tabId) === tabSlug: "apireference" === "api-reference" → false.
    • tabOverlay.slug === tabSlug: user did not set slug: → false.

    No match. The Chinese displayName is silently dropped; the tab keeps "API Reference" in the locale's nav.

  4. findTabNavOverlay performs the same slugifyNavigationKey(navItem.tabId) comparison, so any - tab: apiReference entry in the translated navigation: block also fails to match — translated section/page titles inside that tab silently fall back to the base.

If instead slugifyNavigationKey were lodash.kebabCase, both halves of the mapping would use the same algorithm and kebabCase("apiReference") = "api-reference" would match.

Impact

Nit severity: most users follow Fern's kebab-case YAML key convention (the existing tests use documentation, api-reference), so the bug only triggers for camelCase / PascalCase tab keys. When it does fire, the failure mode is silent — no warning — and the workaround is to set an explicit slug: on every translated tab entry. But the new slugifyNavigationKey was added in this PR specifically as the fallback for matching YAML keys to slugs, and aligning it with the kebab-case generator that the rest of the resolver uses for the same purpose is a one-line change with no real downside.

How to fix

import { kebabCase } from "lodash-es";

// in applyTabOverlayToNode (line 277):
kebabCase(tabId) === tabSlug

// in findTabNavOverlay (line 348):
kebabCase(navItem.tabId) === tabSlug

lodash-es is already a transitive dependency of this package and is imported by the sibling resolver modules (DocsDefinitionResolver.ts, ApiReferenceNodeConverter.ts, etc.).


/**
* Applies variant overlays to tab variant children.
* Matches variants by slug first, then falls back to positional matching.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@fern-api/configuration": "workspace:*",
"@fern-api/core": "workspace:*",
"@fern-api/core-utils": "workspace:*",
"@fern-api/docs-markdown-utils": "workspace:*",
"@fern-api/docs-resolver": "workspace:*",
"@fern-api/fai-sdk": "catalog:",
"@fern-api/fdr-sdk": "1.2.0-1d73d2e141",
Expand Down
Loading
Loading