-
Notifications
You must be signed in to change notification settings - Fork 308
feat(cli): support translated OpenAPI specs #15670
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8c8ab39
2832c7d
1811b06
3256a54
b13d624
2fe3908
3b0d428
a45357c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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 | ||
|
|
@@ -408,6 +413,7 @@ | |
| navOverlays: docsYml.NavigationItemOverlay[], | ||
| overlay: docsYml.TranslationNavigationOverlay | ||
| ): unknown[] { | ||
| let apiIdx = 0; | ||
| let sectionIdx = 0; | ||
| let pageIdx = 0; | ||
|
|
||
|
|
@@ -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" | ||
|
|
@@ -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" | ||
|
|
@@ -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
|
||
| positionIndex: number | ||
| ): docsYml.NavigationItemOverlay.Section | undefined { | ||
| const sectionSlug = extractLastSlugSegment(section["slug"] as string | undefined); | ||
|
|
@@ -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
|
||
|
Comment on lines
+591
to
+597
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The new Extended reasoning...What the bug isThis PR introduces a new fallback for matching overlay tab keys (the YAML keys under // applyTranslatedNavigationOverlays.ts:591-597
function slugifyNavigationKey(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}Used in two places:
But the other half of the mapping — the tab's nav-tree slug itself — is generated by lodash
Why existing safeguards do not catch thisThe matching ladder in
The positional fallback in Step-by-step proofGiven a base tabs:
apiReference:
display-name: "API Reference"
If instead ImpactNit severity: most users follow Fern's kebab-case YAML key convention (the existing tests use How to fiximport { kebabCase } from "lodash-es";
// in applyTabOverlayToNode (line 277):
kebabCase(tabId) === tabSlug
// in findTabNavOverlay (line 348):
kebabCase(navItem.tabId) === tabSlug
|
||
|
|
||
| /** | ||
| * Applies variant overlays to tab variant children. | ||
| * Matches variants by slug first, then falls back to positional matching. | ||
|
|
||
There was a problem hiding this comment.
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 matchingmatchSectionOverlay/matchPageOverlaypaths can silently drop a no-slug overlay at the tail of the children list.apiIdx/sectionIdx/pageIdxare incremented for every sibling of that kind inapplySidebarChildOverlays, but the fallback then bounds-checks againstnoSlugOverlays.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 newmatchApiOverlayandapiPackagebranches introduced by this PR (and the pre-existing section/page paths). Fix: track a parallel counter that only increments on slug-miss, or filternoSlugOverlaysonce outside the loop and consume sequentially.Extended reasoning...
What the bug is
In
applySidebarChildOverlays(applyTranslatedNavigationOverlays.ts:411-515), the per-kind countersapiIdx,sectionIdx, andpageIdxare 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. ButmatchApiOverlay(517-535),matchSectionOverlay(537-558), andmatchPageOverlay(560-581) all share the same fallback shape:The counter passed in as
positionIndexwas 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 pastnoSlugOverlays.lengthand 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. ThenoSlugOverlaysfilter 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:'宠物'}]applySidebarChildOverlayswithapiIdx = 0.apiOverlays= both entries. CallmatchApiOverlay(plantsChild, apiOverlays, 0).overlays[0].slug === 'plants'✓ → returnsoverlays[0].apiIdx++→apiIdx = 1. 'plants' apiReference gets title '植物'.matchApiOverlay(petsChild, apiOverlays, 1).noSlugOverlays = [overlays[1]](length 1).positionIndex(1) < noSlugOverlays.length(1)is false → returnsundefined.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:
apiPackagebranch (lines 475-496), which sharessectionIdxwith the existingsectionbranch — so any prior slug-matched section in the same scope inflates the index for a trailing no-slugapiPackageoverlay (and vice versa, since both kinds advance the same counter).sectionandpagebranches (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
matchApiOverlayandapiPackagebranches 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.:
or (b) filter
noSlugOverlaysonce outside the loop and consume entries sequentially viaArray.shift()(or an iterator) so each no-slug overlay is used exactly once, regardless of how many slug-matched siblings appear before it.