diff --git a/packages/cli/cli/changes/unreleased/translated-openapi-specs.yml b/packages/cli/cli/changes/unreleased/translated-openapi-specs.yml new file mode 100644 index 000000000000..49fd8a020bdc --- /dev/null +++ b/packages/cli/cli/changes/unreleased/translated-openapi-specs.yml @@ -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 diff --git a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts index 9762159a108b..b94bd0287197 100644 --- a/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts +++ b/packages/cli/configuration-loader/src/docs-yml/parseDocsConfiguration.ts @@ -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 = { diff --git a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts index 172cc34c307b..662319b5cfbd 100644 --- a/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts +++ b/packages/cli/configuration/src/docs-yml/ParsedDocsConfiguration.ts @@ -600,6 +600,7 @@ export interface AnnouncementOverlay { export type NavigationItemOverlay = | NavigationItemOverlay.Page | NavigationItemOverlay.Section + | NavigationItemOverlay.Api | NavigationItemOverlay.Tab | NavigationItemOverlay.Variant; @@ -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; diff --git a/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts b/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts index 6b0efbd2cee8..fb1d12c6a891 100644 --- a/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts +++ b/packages/cli/docs-resolver/src/__test__/applyTranslatedNavigationOverlays.test.ts @@ -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).child as Record; + const tabbed = unversioned.child as Record; + const tab = (tabbed.children as Array>)[0] as Record; + expect(tab.title).toBe("API 参考"); + + const sidebarRoot = tab.child as Record; + const apiReference = (sidebarRoot.children as Array>)[0] as Record; + expect(apiReference.title).toBe("植物商店 API"); + + const apiPackage = (apiReference.children as Array>)[0] as Record; + expect(apiPackage.title).toBe("植物"); + }); + it("matches tabs by overlay slug when tab ID differs from nav slug", () => { const root = { type: "root", diff --git a/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts b/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts index 9f0a9cec58eb..1e7da119ceaf 100644 --- a/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts +++ b/packages/cli/docs-resolver/src/applyTranslatedNavigationOverlays.ts @@ -270,7 +270,12 @@ function applyTabOverlayToNode( 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 @@ function findTabNavOverlay( 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 @@ function applySidebarChildOverlays( navOverlays: docsYml.NavigationItemOverlay[], overlay: docsYml.TranslationNavigationOverlay ): unknown[] { + let apiIdx = 0; let sectionIdx = 0; let pageIdx = 0; @@ -419,6 +425,29 @@ function applySidebarChildOverlays( 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; + 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 @@ function applySidebarChildOverlays( 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; + 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" @@ -462,6 +514,26 @@ function applySidebarChildOverlays( }); } +function matchApiOverlay( + apiReference: Record, + 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, overlays: docsYml.NavigationItemOverlay.Section[], @@ -516,6 +588,14 @@ function extractLastSlugSegment(slug: string | undefined): string | undefined { return parts[parts.length - 1]; } +function slugifyNavigationKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + /** * Applies variant overlays to tab variant children. * Matches variants by slug first, then falls back to positional matching. diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/package.json b/packages/cli/generation/remote-generation/remote-workspace-runner/package.json index 20cc458c9232..1c213f2bcff9 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/package.json +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/package.json @@ -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", diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/publishDocsTranslatedOpenApi.e2e.test.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/publishDocsTranslatedOpenApi.e2e.test.ts new file mode 100644 index 000000000000..ab4b256ae2c5 --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/publishDocsTranslatedOpenApi.e2e.test.ts @@ -0,0 +1,249 @@ +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { convertIrToFdrApi } from "@fern-api/register"; +import { createMockTaskContext } from "@fern-api/task-context"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + applyTranslatedApiNavigationTitlesInObject, + type RegisterApiDefinitionOptions, + registerTranslatedApiOverrides, + replaceApiDefinitionIdsInObject, + type TranslatedApiNavigationTitleOverridesByLocale +} from "../translatedApiOverrides.js"; + +type RegisteredApi = { + apiDefinitionId: string; + apiName: string; + definition: unknown; +}; + +describe("translated OpenAPI publish path e2e", () => { + let tmpDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tmpDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + tmpDirs = []; + }); + + it("uploads a translated OpenAPI JSON and embeds that API definition in fdr_zh.json", async () => { + const fernDir = await createTranslatedOpenApiProject(); + tmpDirs.push(fernDir); + + const context = createMockTaskContext(); + const fakeFdr = createFakeFdr(context); + const translatedApiNavigationTitleOverridesByLocale: TranslatedApiNavigationTitleOverridesByLocale = new Map(); + + const translatedApiIdsByLocale = await registerTranslatedApiOverrides({ + docsWorkspace: { + type: "docs", + workspaceName: undefined, + absoluteFilePath: AbsoluteFilePath.of(fernDir), + absoluteFilepathToDocsConfig: AbsoluteFilePath.of(join(fernDir, "docs.yml")), + config: { + instances: [{ url: "translated-openapi.docs.dev.buildwithfern.com" }], + navigation: [], + translations: [{ lang: "en", default: true }, { lang: "zh" }] + } + }, + cliVersion: "*", + context, + registeredApiIdsByName: new Map([["Plant Store API", "api-definition-base"]]), + registeredApiConfigsByName: new Map([["Plant Store API", { snippetsConfig: {} }]]), + registerApiDefinition: fakeFdr.registerApiDefinition, + translatedApiNavigationTitleOverridesByLocale + }); + + const zhApiId = translatedApiIdsByLocale.get("zh")?.get("api-definition-base"); + expect(zhApiId).toBe("api-definition-translated-zh"); + const zhTitleOverrides = translatedApiNavigationTitleOverridesByLocale.get("zh"); + const translatedEndpointId = Array.from( + zhTitleOverrides?.get("api-definition-base")?.endpointTitlesById.keys() ?? [] + )[0]; + expect(translatedEndpointId).toBeDefined(); + + const baseRoot = { + type: "root", + child: { + type: "apiReference", + apiDefinitionId: "api-definition-base", + children: [ + { + type: "endpoint", + apiDefinitionId: "api-definition-base", + endpointId: translatedEndpointId, + title: "List plants" + } + ] + } + }; + + const translatedRootWithTitles = applyTranslatedApiNavigationTitlesInObject( + baseRoot, + translatedApiNavigationTitleOverridesByLocale.get("zh") ?? new Map() + ); + const translatedRoot = replaceApiDefinitionIdsInObject( + translatedRootWithTitles, + translatedApiIdsByLocale.get("zh") ?? new Map() + ); + const fdrZhJson = fakeFdr.writeTranslation({ + locale: "zh", + docsDefinition: { + apis: {}, + apiNameToId: {}, + config: { + root: translatedRoot + } + } + }); + + expect(fakeFdr.registeredApis).toHaveLength(1); + expect(JSON.stringify(fakeFdr.registeredApis[0]?.definition)).toContain("列出植物"); + expect(JSON.stringify(fakeFdr.registeredApis[0]?.definition)).toContain("返回本地化的植物列表"); + expect(JSON.stringify(fdrZhJson.config.root)).toContain("api-definition-translated-zh"); + expect(JSON.stringify(fdrZhJson.config.root)).toContain("列出植物"); + expect(JSON.stringify(fdrZhJson.config.root)).not.toContain("List plants"); + expect(fdrZhJson.apis["api-definition-translated-zh"]).toBeDefined(); + expect(JSON.stringify(fdrZhJson.apis["api-definition-translated-zh"])).toContain("返回本地化的植物列表"); + expect(fdrZhJson.apiNameToId["Plant Store API"]).toBe("api-definition-translated-zh"); + expect(JSON.stringify(fdrZhJson)).not.toContain("api-definition-base"); + }); +}); + +function createFakeFdr(context: ReturnType): { + registeredApis: RegisteredApi[]; + registerApiDefinition: (opts: RegisterApiDefinitionOptions) => Promise; + writeTranslation: (input: { + locale: string; + docsDefinition: { + apis: Record; + apiNameToId: Record; + config: { root: unknown }; + }; + }) => { + apis: Record; + apiNameToId: Record; + config: { root: unknown }; + }; +} { + const registeredApis: RegisteredApi[] = []; + + return { + registeredApis, + registerApiDefinition: async (opts) => { + const apiDefinitionId = "api-definition-translated-zh"; + registeredApis.push({ + apiDefinitionId, + apiName: opts.apiName ?? "Plant Store API", + definition: convertIrToFdrApi({ + ir: opts.ir, + snippetsConfig: opts.snippetsConfig, + playgroundConfig: opts.playgroundConfig, + graphqlOperations: opts.graphqlOperations, + graphqlTypes: opts.graphqlTypes, + context, + apiNameOverride: opts.apiName + }) + }); + return apiDefinitionId; + }, + writeTranslation: ({ docsDefinition }) => { + const apiIds = collectApiDefinitionIds(docsDefinition.config.root); + return { + ...docsDefinition, + apis: { + ...docsDefinition.apis, + ...Object.fromEntries( + apiIds.flatMap((apiId) => { + const api = registeredApis.find((registeredApi) => registeredApi.apiDefinitionId === apiId); + return api == null ? [] : [[apiId, api.definition]]; + }) + ) + }, + apiNameToId: { + ...docsDefinition.apiNameToId, + ...Object.fromEntries( + apiIds.flatMap((apiId) => { + const api = registeredApis.find((registeredApi) => registeredApi.apiDefinitionId === apiId); + return api == null ? [] : [[api.apiName, apiId]]; + }) + ) + } + }; + } + }; +} + +async function createTranslatedOpenApiProject(): Promise { + const fernDir = await mkdtemp(join(tmpdir(), "fern-publish-translated-openapi-")); + await mkdir(join(fernDir, "translations", "zh", "apis", "Plant Store API"), { recursive: true }); + + await writeFile( + join(fernDir, "translations", "zh", "apis", "Plant Store API", "generators.yml"), + "api:\n specs:\n - openapi: openapi.json\n" + ); + await writeFile( + join(fernDir, "translations", "zh", "apis", "Plant Store API", "openapi.json"), + JSON.stringify( + { + openapi: "3.1.0", + info: { + title: "Plant Store API", + version: "1.0.0" + }, + paths: { + "/plants": { + get: { + operationId: "listPlants", + summary: "列出植物", + description: "返回本地化的植物列表", + responses: { + "200": { + description: "返回本地化的植物列表", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { + type: "string", + description: "本地化消息" + } + } + } + } + } + } + } + } + } + } + }, + null, + 2 + ) + ); + + return fernDir; +} + +function collectApiDefinitionIds(value: unknown): string[] { + const ids = new Set(); + const visit = (current: unknown) => { + if (Array.isArray(current)) { + current.forEach(visit); + return; + } + if (current != null && typeof current === "object") { + for (const [key, child] of Object.entries(current)) { + if (key === "apiDefinitionId" && typeof child === "string") { + ids.add(child); + } + visit(child); + } + } + }; + visit(value); + return Array.from(ids); +} diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/translatedApiOverrides.test.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/translatedApiOverrides.test.ts new file mode 100644 index 000000000000..e9d8fe523350 --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/translatedApiOverrides.test.ts @@ -0,0 +1,398 @@ +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { createMockTaskContext } from "@fern-api/task-context"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type ApiNavigationTitleOverrides, + applyTranslatedApiNavigationTitlesInObject, + getMissingEndpointKeys, + getNonDefaultTranslationLocales, + getTranslatedApiWorkspacePath, + registerTranslatedApiOverrides, + replaceApiDefinitionIdsInObject, + type TranslatedApiNavigationTitleOverridesByLocale +} from "../translatedApiOverrides.js"; + +describe("translated API overrides", () => { + let tmpDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tmpDirs.map((dir) => rm(dir, { recursive: true, force: true }))); + tmpDirs = []; + }); + + it("returns non-default translation locales", () => { + expect(getNonDefaultTranslationLocales(["en", "zh", "ja"])).toEqual(["zh", "ja"]); + expect( + getNonDefaultTranslationLocales([{ lang: "zh", default: true }, { lang: "en" }, { lang: "ja" }]) + ).toEqual(["en", "ja"]); + }); + + it("detects translated API workspaces in supported locations", async () => { + const root = await mkdtemp(join(tmpdir(), "fern-translated-api-")); + tmpDirs.push(root); + + const directApi = join(root, "translations", "zh", "apis", "Plant Store API"); + await mkdir(directApi, { recursive: true }); + await writeFile(join(directApi, "generators.yml"), "api:\n specs: []\n"); + + const nestedApi = join(root, "translations", "ja", "fern", "apis", "Plant Store API", "definition"); + await mkdir(nestedApi, { recursive: true }); + + const docsWorkspace = { absoluteFilePath: AbsoluteFilePath.of(root) }; + + await expect( + getTranslatedApiWorkspacePath({ + docsWorkspace, + locale: "zh", + apiName: "Plant Store API" + }) + ).resolves.toBe(directApi); + await expect( + getTranslatedApiWorkspacePath({ + docsWorkspace, + locale: "ja", + apiName: "Plant Store API" + }) + ).resolves.toBe(join(root, "translations", "ja", "fern", "apis", "Plant Store API")); + }); + + it("detects a default translated API workspace directly under the locale for single-API docs", async () => { + const root = await mkdtemp(join(tmpdir(), "fern-translated-api-default-")); + tmpDirs.push(root); + + const localeDir = join(root, "translations", "zh"); + await mkdir(localeDir, { recursive: true }); + await writeFile(join(localeDir, "generators.yml"), "api:\n specs:\n - openapi: openapi.json\n"); + + await expect( + getTranslatedApiWorkspacePath({ + docsWorkspace: { absoluteFilePath: AbsoluteFilePath.of(root) }, + locale: "zh", + apiName: "Default API", + allowDefaultApiOverride: true + }) + ).resolves.toBe(localeDir); + + await expect( + getTranslatedApiWorkspacePath({ + docsWorkspace: { absoluteFilePath: AbsoluteFilePath.of(root) }, + locale: "zh", + apiName: "Default API" + }) + ).resolves.toBeUndefined(); + }); + + it("recursively replaces API definition IDs without changing unrelated IDs", () => { + const translatedRoot = replaceApiDefinitionIdsInObject( + { + type: "root", + child: { + apiDefinitionId: "base-api-definition-id", + nested: [{ apiDefinitionId: "other-api-definition-id" }, { endpointId: "base-api-definition-id" }] + } + }, + new Map([["base-api-definition-id", "translated-api-definition-id"]]) + ); + + expect(translatedRoot).toEqual({ + type: "root", + child: { + apiDefinitionId: "translated-api-definition-id", + nested: [{ apiDefinitionId: "other-api-definition-id" }, { endpointId: "base-api-definition-id" }] + } + }); + }); + + it("recursively applies translated endpoint navigation titles", () => { + const translatedRoot = applyTranslatedApiNavigationTitlesInObject( + { + type: "root", + children: [ + { + type: "endpoint", + apiDefinitionId: "base-api-definition-id", + endpointId: "endpoint_addPlant", + title: "Add a new plant to the store" + }, + { + type: "endpoint", + apiDefinitionId: "other-api-definition-id", + endpointId: "endpoint_addPlant", + title: "Add a new plant to the store" + } + ] + }, + new Map([ + [ + "base-api-definition-id", + { + endpointTitlesById: new Map([["endpoint_addPlant", "添加一株新的植物"]]) + } + ] + ]) + ); + + expect(translatedRoot).toEqual({ + type: "root", + children: [ + { + type: "endpoint", + apiDefinitionId: "base-api-definition-id", + endpointId: "endpoint_addPlant", + title: "添加一株新的植物" + }, + { + type: "endpoint", + apiDefinitionId: "other-api-definition-id", + endpointId: "endpoint_addPlant", + title: "Add a new plant to the store" + } + ] + }); + }); + + it("detects endpoints missing from a translated API", () => { + expect( + getMissingEndpointKeys({ + baseEndpointKeys: ["GET /plants", "POST /plants", "PUT /plants/{plantId}"], + translatedEndpointKeys: ["GET /plants"] + }) + ).toEqual(["POST /plants", "PUT /plants/{plantId}"]); + }); + + it("loads and registers a translated OpenAPI workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "fern-translated-api-register-")); + tmpDirs.push(root); + + const translatedApi = join(root, "translations", "zh", "apis", "Plant Store API"); + await mkdir(translatedApi, { recursive: true }); + await writeFile(join(translatedApi, "generators.yml"), "api:\n specs:\n - openapi: openapi.yaml\n"); + await writeFile( + join(translatedApi, "openapi.yaml"), + [ + "openapi: 3.1.0", + "info:", + " title: Translated Plant Store API", + " version: 1.0.0", + "paths:", + " /plants:", + " get:", + " operationId: listPlantsZh", + " responses:", + " '200':", + " description: OK" + ].join("\n") + ); + + const registerCalls: unknown[] = []; + const translatedIds = await registerTranslatedApiOverrides({ + docsWorkspace: { + type: "docs", + workspaceName: undefined, + absoluteFilePath: AbsoluteFilePath.of(root), + absoluteFilepathToDocsConfig: AbsoluteFilePath.of(join(root, "docs.yml")), + config: { + instances: [], + navigation: [], + translations: ["en", "zh"] + } + }, + cliVersion: "*", + context: createMockTaskContext(), + registeredApiIdsByName: new Map([["Plant Store API", "base-api-definition-id"]]), + registeredApiConfigsByName: new Map([["Plant Store API", { snippetsConfig: {} }]]), + registerApiDefinition: async (opts) => { + registerCalls.push(opts); + return "translated-api-definition-id"; + } + }); + + expect(translatedIds.get("zh")?.get("base-api-definition-id")).toBe("translated-api-definition-id"); + expect(registerCalls).toHaveLength(1); + expect(registerCalls[0]).toMatchObject({ + apiName: "Plant Store API", + snippetsConfig: {}, + trackAsBaseApi: false + }); + }); + + it("does not register navigation title overrides when translated endpoints have no summary", async () => { + const root = await mkdtemp(join(tmpdir(), "fern-translated-api-no-summary-")); + tmpDirs.push(root); + + const translatedApi = join(root, "translations", "zh", "apis", "Plant Store API"); + await mkdir(translatedApi, { recursive: true }); + await writeFile(join(translatedApi, "generators.yml"), "api:\n specs:\n - openapi: openapi.yaml\n"); + // Translated OpenAPI intentionally has no summary on the endpoint — the base + // navigation title should not be clobbered with an auto-generated English title. + await writeFile( + join(translatedApi, "openapi.yaml"), + [ + "openapi: 3.1.0", + "info:", + " title: Translated Plant Store API", + " version: 1.0.0", + "paths:", + " /plants:", + " get:", + " operationId: listPlants", + " responses:", + " '200':", + " description: OK" + ].join("\n") + ); + + const translatedApiNavigationTitleOverridesByLocale: TranslatedApiNavigationTitleOverridesByLocale = new Map(); + await registerTranslatedApiOverrides({ + docsWorkspace: { + type: "docs", + workspaceName: undefined, + absoluteFilePath: AbsoluteFilePath.of(root), + absoluteFilepathToDocsConfig: AbsoluteFilePath.of(join(root, "docs.yml")), + config: { + instances: [], + navigation: [], + translations: ["en", "zh"] + } + }, + cliVersion: "*", + context: createMockTaskContext(), + registeredApiIdsByName: new Map([["Plant Store API", "base-api-definition-id"]]), + registeredApiConfigsByName: new Map([["Plant Store API", { snippetsConfig: {} }]]), + registerApiDefinition: async () => "translated-api-definition-id", + translatedApiNavigationTitleOverridesByLocale + }); + + expect(translatedApiNavigationTitleOverridesByLocale.get("zh")).toBeUndefined(); + }); + + it("registers navigation title overrides only for translated endpoints that provided a summary", async () => { + const root = await mkdtemp(join(tmpdir(), "fern-translated-api-partial-summary-")); + tmpDirs.push(root); + + const translatedApi = join(root, "translations", "zh", "apis", "Plant Store API"); + await mkdir(translatedApi, { recursive: true }); + await writeFile(join(translatedApi, "generators.yml"), "api:\n specs:\n - openapi: openapi.yaml\n"); + // Mixed: listPlants has a summary, addPlant does not. Only listPlants + // should receive a title override. + await writeFile( + join(translatedApi, "openapi.yaml"), + [ + "openapi: 3.1.0", + "info:", + " title: Translated Plant Store API", + " version: 1.0.0", + "paths:", + " /plants:", + " get:", + " operationId: listPlants", + " summary: 列出植物", + " responses:", + " '200':", + " description: OK", + " post:", + " operationId: addPlant", + " responses:", + " '200':", + " description: OK" + ].join("\n") + ); + + const translatedApiNavigationTitleOverridesByLocale: TranslatedApiNavigationTitleOverridesByLocale = new Map(); + await registerTranslatedApiOverrides({ + docsWorkspace: { + type: "docs", + workspaceName: undefined, + absoluteFilePath: AbsoluteFilePath.of(root), + absoluteFilepathToDocsConfig: AbsoluteFilePath.of(join(root, "docs.yml")), + config: { + instances: [], + navigation: [], + translations: ["en", "zh"] + } + }, + cliVersion: "*", + context: createMockTaskContext(), + registeredApiIdsByName: new Map([["Plant Store API", "base-api-definition-id"]]), + registeredApiConfigsByName: new Map([["Plant Store API", { snippetsConfig: {} }]]), + registerApiDefinition: async () => "translated-api-definition-id", + translatedApiNavigationTitleOverridesByLocale + }); + + const zhOverrides: ApiNavigationTitleOverrides | undefined = translatedApiNavigationTitleOverridesByLocale + .get("zh") + ?.get("base-api-definition-id"); + expect(zhOverrides).toBeDefined(); + const titles = Array.from(zhOverrides?.endpointTitlesById.values() ?? []); + expect(titles).toEqual(["列出植物"]); + }); + + it("warns when a translated OpenAPI workspace is missing default API endpoints", async () => { + const root = await mkdtemp(join(tmpdir(), "fern-translated-api-missing-endpoints-")); + tmpDirs.push(root); + + const translatedApi = join(root, "translations", "zh", "apis", "Plant Store API"); + await mkdir(translatedApi, { recursive: true }); + await writeFile(join(translatedApi, "generators.yml"), "api:\n specs:\n - openapi: openapi.yaml\n"); + await writeFile( + join(translatedApi, "openapi.yaml"), + [ + "openapi: 3.1.0", + "info:", + " title: Translated Plant Store API", + " version: 1.0.0", + "paths:", + " /plants:", + " get:", + " operationId: listPlants", + " responses:", + " '200':", + " description: OK" + ].join("\n") + ); + + const logger = { + disable: vi.fn(), + enable: vi.fn(), + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + log: vi.fn() + }; + + await registerTranslatedApiOverrides({ + docsWorkspace: { + type: "docs", + workspaceName: undefined, + absoluteFilePath: AbsoluteFilePath.of(root), + absoluteFilepathToDocsConfig: AbsoluteFilePath.of(join(root, "docs.yml")), + config: { + instances: [], + navigation: [], + translations: ["en", "zh"] + } + }, + cliVersion: "*", + context: createMockTaskContext({ logger }), + registeredApiIdsByName: new Map([["Plant Store API", "base-api-definition-id"]]), + registeredApiConfigsByName: new Map([ + [ + "Plant Store API", + { + snippetsConfig: {}, + endpointKeys: ["GET /plants", "PUT /plants"] + } + ] + ]), + registerApiDefinition: async () => "translated-api-definition-id" + }); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("PUT /plants")); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("missing 1 endpoint")); + }); +}); 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 7deed77a97e2..08c361aad714 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 @@ -4,15 +4,17 @@ import { docsYml, generatorsYml } from "@fern-api/configuration"; import { createFdrService } from "@fern-api/core"; import { MediaType, replaceEnvVariables } from "@fern-api/core-utils"; import { - applyTranslatedFrontmatterToNavTree, - applyTranslatedNavigationOverlays, - DocsDefinitionResolver, - getTranslatedAnnouncement, replaceImagePathsAndUrls, replaceReferencedCode, replaceReferencedMarkdown, stripMdxComments, - transformAtPrefixImports, + transformAtPrefixImports +} from "@fern-api/docs-markdown-utils"; +import { + applyTranslatedFrontmatterToNavTree, + applyTranslatedNavigationOverlays, + DocsDefinitionResolver, + getTranslatedAnnouncement, UploadedFile, wrapWithHttps } from "@fern-api/docs-resolver"; @@ -47,6 +49,15 @@ import * as mime from "mime-types"; import terminalLink from "terminal-link"; import { getDynamicGeneratorConfig } from "./getDynamicGeneratorConfig.js"; import { measureImageSizes } from "./measureImageSizes.js"; +import { + applyTranslatedApiNavigationTitlesInObject, + getHttpEndpointKeys, + type RegisterApiDefinitionOptions, + type RegisteredApiConfig, + registerTranslatedApiOverrides, + replaceApiDefinitionIdsInObject, + type TranslatedApiNavigationTitleOverridesByLocale +} from "./translatedApiOverrides.js"; import { asyncPool } from "./utils/asyncPool.js"; const MEASURE_IMAGE_BATCH_SIZE = 10; @@ -257,6 +268,153 @@ export async function publishDocs({ taskContext: context }); + const registeredApiIdsByName = new Map(); + const registeredApiConfigsByName = new Map(); + const registerApiDefinition = async ({ + ir, + snippetsConfig, + playgroundConfig, + apiName, + workspace, + graphqlOperations, + graphqlTypes, + trackAsBaseApi = true + }: RegisterApiDefinitionOptions): Promise => { + const apiId = apiName ?? getOriginalName(ir.apiName); + // Use apiName from docs.yml (folder name) as the API identifier for FDR. + // This ensures users can reference APIs by their folder name in docs components. + let apiDefinition = convertIrToFdrApi({ + ir, + snippetsConfig, + playgroundConfig, + graphqlOperations, + graphqlTypes, + context, + apiNameOverride: apiName + }); + + const aiEnhancerConfig = getAIEnhancerConfig( + withAiExamples, + docsWorkspace.config.aiExamples?.style ?? docsWorkspace.config.experimental?.aiExampleStyleInstructions + ); + if (trackAsBaseApi && aiEnhancerConfig) { + const sources = workspace?.getSources(); + const openApiSources = sources + ?.filter((source) => source.type === "openapi") + .map((source) => ({ + absoluteFilePath: source.absoluteFilePath, + absoluteFilePathToOverrides: source.absoluteFilePathToOverrides + })); + + if (openApiSources == null || openApiSources.length === 0) { + context.logger.debug("Skipping AI example enhancement: no OpenAPI source file paths available"); + } else { + apiDefinition = await enhanceExamplesWithAI( + apiDefinition, + aiEnhancerConfig, + context, + token, + organization, + openApiSources + ); + } + } + + let dynamicIRsByLanguage: Record | undefined; + let languagesWithExistingSdkDynamicIr: Set = new Set(); + if (!trackAsBaseApi) { + context.logger.debug(`Skipping snippet generation for translated API "${apiId}"...`); + } else if (Object.keys(snippetsConfig).length === 0) { + context.logger.debug(`No snippets configuration defined, skipping snippet generation...`); + } else if (!disableDynamicSnippets) { + const existingSdkDynamicIrs = await checkAndDownloadExistingSdkDynamicIRs({ + fdr, + workspace, + organization, + context, + snippetsConfig + }); + + if (existingSdkDynamicIrs && Object.keys(existingSdkDynamicIrs).length > 0) { + dynamicIRsByLanguage = existingSdkDynamicIrs; + languagesWithExistingSdkDynamicIr = new Set(Object.keys(existingSdkDynamicIrs)); + context.logger.debug( + `Using existing SDK dynamic IRs for: ${Object.keys(existingSdkDynamicIrs).join(", ")}` + ); + } + + const generatedDynamicIRs = await generateLanguageSpecificDynamicIRs({ + workspace, + organization, + context, + snippetsConfig, + skipLanguages: languagesWithExistingSdkDynamicIr + }); + + if (generatedDynamicIRs) { + dynamicIRsByLanguage = { + ...dynamicIRsByLanguage, + ...generatedDynamicIRs + }; + } + } + + let response; + try { + response = await fdr.api.register.registerApiDefinition({ + orgId: CjsFdrSdk.OrgId(organization), + apiId: CjsFdrSdk.ApiId(apiId), + definition: apiDefinition, + dynamicIRs: dynamicIRsByLanguage + }); + } catch (error) { + const errorDetails = extractErrorDetails(error); + context.logger.error( + `FDR registerApiDefinition failed. Error details:\n${JSON.stringify(errorDetails, undefined, 2)}` + ); + if (apiName != null) { + return context.failAndThrow( + `Failed to publish docs because API definition (${apiName}) could not be uploaded. Please contact support@buildwithfern.com`, + errorDetails, + { code: CliError.Code.NetworkError } + ); + } + return context.failAndThrow( + `Failed to publish docs because API definition could not be uploaded. Please contact support@buildwithfern.com`, + errorDetails, + { code: CliError.Code.NetworkError } + ); + } + + context.logger.debug(`Registered API Definition ${apiId}: ${response.apiDefinitionId}`); + + if (response.dynamicIRs && dynamicIRsByLanguage) { + if (skipUpload) { + context.logger.debug("Skip-upload mode: skipping dynamic IR uploads"); + } else { + await uploadDynamicIRs({ + dynamicIRs: dynamicIRsByLanguage, + dynamicIRUploadUrls: response.dynamicIRs, + context, + apiId: response.apiDefinitionId + }); + } + } + + if (trackAsBaseApi) { + registeredApiIdsByName.set(apiId, response.apiDefinitionId); + registeredApiConfigsByName.set(apiId, { + snippetsConfig, + playgroundConfig, + graphqlOperations, + graphqlTypes, + endpointKeys: getHttpEndpointKeys(ir) + }); + } + + return response.apiDefinitionId; + }; + const resolver = new DocsDefinitionResolver({ domain, docsWorkspace: effectiveWorkspace, @@ -457,140 +615,7 @@ export async function publishDocs({ ); } }, - registerApi: async ({ - ir, - snippetsConfig, - playgroundConfig, - apiName, - workspace, - graphqlOperations, - graphqlTypes - }) => { - // Use apiName from docs.yml (folder name) as the API identifier for FDR - // This ensures users can reference APIs by their folder name in docs components - let apiDefinition = convertIrToFdrApi({ - ir, - snippetsConfig, - playgroundConfig, - graphqlOperations, - graphqlTypes, - context, - apiNameOverride: apiName - }); - - const aiEnhancerConfig = getAIEnhancerConfig( - withAiExamples, - docsWorkspace.config.aiExamples?.style ?? - docsWorkspace.config.experimental?.aiExampleStyleInstructions - ); - if (aiEnhancerConfig) { - const sources = workspace?.getSources(); - const openApiSources = sources - ?.filter((source) => source.type === "openapi") - .map((source) => ({ - absoluteFilePath: source.absoluteFilePath, - absoluteFilePathToOverrides: source.absoluteFilePathToOverrides - })); - - if (openApiSources == null || openApiSources.length === 0) { - context.logger.debug("Skipping AI example enhancement: no OpenAPI source file paths available"); - } else { - apiDefinition = await enhanceExamplesWithAI( - apiDefinition, - aiEnhancerConfig, - context, - token, - organization, - openApiSources - ); - } - } - - // create dynamic IR + metadata for each generator language - let dynamicIRsByLanguage: Record | undefined; - let languagesWithExistingSdkDynamicIr: Set = new Set(); - if (Object.keys(snippetsConfig).length === 0) { - context.logger.debug(`No snippets configuration defined, skipping snippet generation...`); - } else if (!disableDynamicSnippets) { - // Check for existing SDK dynamic IRs before generating - const existingSdkDynamicIrs = await checkAndDownloadExistingSdkDynamicIRs({ - fdr, - workspace, - organization, - context, - snippetsConfig - }); - - if (existingSdkDynamicIrs && Object.keys(existingSdkDynamicIrs).length > 0) { - dynamicIRsByLanguage = existingSdkDynamicIrs; - languagesWithExistingSdkDynamicIr = new Set(Object.keys(existingSdkDynamicIrs)); - context.logger.debug( - `Using existing SDK dynamic IRs for: ${Object.keys(existingSdkDynamicIrs).join(", ")}` - ); - } - - // Generate dynamic IRs for languages that don't have existing SDK dynamic IRs - const generatedDynamicIRs = await generateLanguageSpecificDynamicIRs({ - workspace, - organization, - context, - snippetsConfig, - skipLanguages: languagesWithExistingSdkDynamicIr - }); - - if (generatedDynamicIRs) { - dynamicIRsByLanguage = { - ...dynamicIRsByLanguage, - ...generatedDynamicIRs - }; - } - } - - let response; - try { - response = await fdr.api.register.registerApiDefinition({ - orgId: CjsFdrSdk.OrgId(organization), - apiId: CjsFdrSdk.ApiId(apiName ?? getOriginalName(ir.apiName)), - definition: apiDefinition, - dynamicIRs: dynamicIRsByLanguage - }); - } catch (error) { - const errorDetails = extractErrorDetails(error); - context.logger.error( - `FDR registerApiDefinition failed. Error details:\n${JSON.stringify(errorDetails, undefined, 2)}` - ); - if (apiName != null) { - return context.failAndThrow( - `Failed to publish docs because API definition (${apiName}) could not be uploaded. Please contact support@buildwithfern.com`, - errorDetails, - { code: CliError.Code.NetworkError } - ); - } else { - return context.failAndThrow( - `Failed to publish docs because API definition could not be uploaded. Please contact support@buildwithfern.com`, - errorDetails, - { code: CliError.Code.NetworkError } - ); - } - } - - context.logger.debug(`Registered API Definition ${apiName}: ${response.apiDefinitionId}`); - - if (response.dynamicIRs && dynamicIRsByLanguage) { - if (skipUpload) { - context.logger.debug("Skip-upload mode: skipping dynamic IR uploads"); - } else { - await uploadDynamicIRs({ - dynamicIRs: dynamicIRsByLanguage, - dynamicIRUploadUrls: response.dynamicIRs, - context, - apiId: response.apiDefinitionId - }); - } - } - - return response.apiDefinitionId; - }, + registerApi: registerApiDefinition, targetAudiences }); @@ -652,6 +677,16 @@ export async function publishDocs({ const translationPages = resolver.getTranslationPages(); const translationNavigationOverlays = resolver.getTranslationNavigationOverlays(); const translationDomain = preview ? urlToOutput : domain; + const translatedApiNavigationTitleOverridesByLocale: TranslatedApiNavigationTitleOverridesByLocale = new Map(); + const translatedApiDefinitionIdsByLocale = await registerTranslatedApiOverrides({ + docsWorkspace: effectiveWorkspace, + cliVersion, + context, + registeredApiIdsByName, + registeredApiConfigsByName, + registerApiDefinition, + translatedApiNavigationTitleOverridesByLocale + }); if (translationPages != null && Object.keys(translationPages).length > 0) { context.logger.info(`Registering translations for ${Object.keys(translationPages).length} locale(s)...`); await Promise.all( @@ -796,10 +831,26 @@ export async function publishDocs({ updatedRoot = applyTranslatedNavigationOverlays(updatedRoot, localeNavOverlay); translatedAnnouncement = getTranslatedAnnouncement(localeNavOverlay) ?? translatedAnnouncement; - if (localeNavOverlay.navbarLinks != null) { - translatedNavbarLinks = localeNavOverlay.navbarLinks; + const localeNavbarLinks = localeNavOverlay.navbarLinks; + if (localeNavbarLinks != null) { + translatedNavbarLinks = localeNavbarLinks; } } + const translatedApiNavigationTitleOverrides = + translatedApiNavigationTitleOverridesByLocale.get(locale); + if ( + translatedApiNavigationTitleOverrides != null && + translatedApiNavigationTitleOverrides.size > 0 + ) { + updatedRoot = applyTranslatedApiNavigationTitlesInObject( + updatedRoot, + translatedApiNavigationTitleOverrides + ); + } + const translatedApiDefinitionIds = translatedApiDefinitionIdsByLocale.get(locale); + if (translatedApiDefinitionIds != null && translatedApiDefinitionIds.size > 0) { + updatedRoot = replaceApiDefinitionIdsInObject(updatedRoot, translatedApiDefinitionIds); + } const translatedDefinition: DocsDefinition = { ...docsDefinition, diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForDocsWorkspace.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForDocsWorkspace.ts index 8b62e6cea626..f5bd46c17aec 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForDocsWorkspace.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForDocsWorkspace.ts @@ -104,6 +104,7 @@ export async function runRemoteGenerationForDocsWorkspace({ }); return; } + const multiSource = maybeInstance.multiSource ?? false; // TODO: validate custom domains const customDomains: string[] = []; @@ -116,7 +117,7 @@ export async function runRemoteGenerationForDocsWorkspace({ } } - if (maybeInstance.multiSource === true) { + if (multiSource) { validateMultiSourceBasepaths(maybeInstance.url, customDomains, context); } @@ -156,7 +157,7 @@ export async function runRemoteGenerationForDocsWorkspace({ ciSource, deployerAuthor, loginCommand, - multiSource: maybeInstance.multiSource ?? false + multiSource }); for (let attempt = 0; ; attempt++) { diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/translatedApiOverrides.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/translatedApiOverrides.ts new file mode 100644 index 000000000000..79e25b8c076c --- /dev/null +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/translatedApiOverrides.ts @@ -0,0 +1,440 @@ +import { SourceResolverImpl } from "@fern-api/cli-source-resolver"; +import { docsYml } from "@fern-api/configuration"; +import { replaceEnvVariables } from "@fern-api/core-utils"; +import { APIV1Write } from "@fern-api/fdr-sdk"; +import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; +import { generateIntermediateRepresentation } from "@fern-api/ir-generator"; +import type { HttpPath, IntermediateRepresentation } from "@fern-api/ir-sdk"; +import { OSSWorkspace } from "@fern-api/lazy-fern-workspace"; +import { CliError, TaskContext } from "@fern-api/task-context"; +import { + DocsWorkspace, + FernWorkspace, + handleFailedWorkspaceParserResult, + loadAPIWorkspace +} from "@fern-api/workspace-loader"; + +export type RegisterApiDefinitionOptions = { + ir: IntermediateRepresentation; + snippetsConfig: APIV1Write.SnippetsConfig; + playgroundConfig?: Pick; + apiName?: string; + workspace?: FernWorkspace; + graphqlOperations?: Record; + graphqlTypes?: Record; + trackAsBaseApi?: boolean; +}; + +export type RegisteredApiConfig = Pick< + RegisterApiDefinitionOptions, + "snippetsConfig" | "playgroundConfig" | "graphqlOperations" | "graphqlTypes" +> & { + endpointKeys?: string[]; +}; + +export type ApiNavigationTitleOverrides = { + endpointTitlesById: Map; +}; + +export type TranslatedApiNavigationTitleOverridesByLocale = Map>; + +export async function registerTranslatedApiOverrides({ + docsWorkspace, + cliVersion, + context, + registeredApiIdsByName, + registeredApiConfigsByName, + registerApiDefinition, + translatedApiNavigationTitleOverridesByLocale +}: { + docsWorkspace: DocsWorkspace; + cliVersion: string | undefined; + context: TaskContext; + registeredApiIdsByName: Map; + registeredApiConfigsByName: Map; + registerApiDefinition: (opts: RegisterApiDefinitionOptions) => Promise; + translatedApiNavigationTitleOverridesByLocale?: TranslatedApiNavigationTitleOverridesByLocale; +}): Promise>> { + const locales = getNonDefaultTranslationLocales(docsWorkspace.config.translations); + const translatedApiDefinitionIdsByLocale = new Map>(); + if (locales.length === 0 || registeredApiIdsByName.size === 0) { + return translatedApiDefinitionIdsByLocale; + } + + type RegistrationResult = { + locale: string; + baseApiDefinitionId: string; + translatedApiDefinitionId: string; + navigationTitleOverrides: ApiNavigationTitleOverrides | undefined; + }; + + const workItems = locales.flatMap((locale) => + Array.from(registeredApiIdsByName.entries()).map(([apiName, baseApiDefinitionId]) => ({ + locale, + apiName, + baseApiDefinitionId + })) + ); + + const results = await Promise.all( + workItems.map(async ({ locale, apiName, baseApiDefinitionId }): Promise => { + const translatedWorkspacePath = await getTranslatedApiWorkspacePath({ + docsWorkspace, + locale, + apiName, + allowDefaultApiOverride: registeredApiIdsByName.size === 1 + }); + if (translatedWorkspacePath == null) { + return undefined; + } + + context.logger.info(`Registering translated API "${apiName}" for locale "${locale}"...`); + const baseConfig = registeredApiConfigsByName.get(apiName); + const { ir, workspace } = await loadTranslatedApiWorkspace({ + docsWorkspace, + translatedWorkspacePath, + apiName, + locale, + cliVersion, + context + }); + warnIfTranslatedApiIsMissingEndpoints({ + baseEndpointKeys: baseConfig?.endpointKeys ?? [], + translatedEndpointKeys: getHttpEndpointKeys(ir), + apiName, + locale, + context + }); + const navigationTitleOverrides = getApiNavigationTitleOverrides(ir); + const translatedApiDefinitionId = await registerApiDefinition({ + ir, + apiName, + snippetsConfig: baseConfig?.snippetsConfig ?? {}, + playgroundConfig: baseConfig?.playgroundConfig, + graphqlOperations: baseConfig?.graphqlOperations, + graphqlTypes: baseConfig?.graphqlTypes, + workspace, + trackAsBaseApi: false + }); + context.logger.debug( + `Registered translated API "${apiName}" for locale "${locale}": ${translatedApiDefinitionId}` + ); + return { + locale, + baseApiDefinitionId, + translatedApiDefinitionId, + navigationTitleOverrides: + navigationTitleOverrides.endpointTitlesById.size > 0 ? navigationTitleOverrides : undefined + }; + }) + ); + + // Aggregate results sequentially to avoid concurrent writes to the shared maps. + for (const result of results) { + if (result == null) { + continue; + } + const apiIdOverrides = translatedApiDefinitionIdsByLocale.get(result.locale) ?? new Map(); + apiIdOverrides.set(result.baseApiDefinitionId, result.translatedApiDefinitionId); + translatedApiDefinitionIdsByLocale.set(result.locale, apiIdOverrides); + + if (result.navigationTitleOverrides != null && translatedApiNavigationTitleOverridesByLocale != null) { + const localeOverrides = + translatedApiNavigationTitleOverridesByLocale.get(result.locale) ?? + new Map(); + localeOverrides.set(result.baseApiDefinitionId, result.navigationTitleOverrides); + translatedApiNavigationTitleOverridesByLocale.set(result.locale, localeOverrides); + } + } + + return translatedApiDefinitionIdsByLocale; +} + +export function getNonDefaultTranslationLocales( + translations: docsYml.RawSchemas.TranslationConfig[] | undefined +): string[] { + if (translations == null || translations.length === 0) { + return []; + } + const normalizedTranslations = translations.map((translation) => + docsYml.DocsYmlSchemas.normalizeTranslationConfig(translation) + ); + const defaultTranslation = + normalizedTranslations.find((translation) => translation.default === true) ?? normalizedTranslations[0]; + return normalizedTranslations + .map((translation) => translation.lang) + .filter((locale) => locale !== defaultTranslation?.lang); +} + +export async function getTranslatedApiWorkspacePath({ + docsWorkspace, + locale, + apiName, + allowDefaultApiOverride = false +}: { + docsWorkspace: Pick; + locale: string; + apiName: string; + allowDefaultApiOverride?: boolean; +}): Promise { + const candidates = [ + join(docsWorkspace.absoluteFilePath, RelativeFilePath.of(`translations/${locale}/apis/${apiName}`)), + join(docsWorkspace.absoluteFilePath, RelativeFilePath.of(`translations/${locale}/fern/apis/${apiName}`)) + ]; + if (allowDefaultApiOverride) { + candidates.push( + join(docsWorkspace.absoluteFilePath, RelativeFilePath.of(`translations/${locale}`)), + join(docsWorkspace.absoluteFilePath, RelativeFilePath.of(`translations/${locale}/fern`)) + ); + } + + for (const candidate of candidates) { + const hasGeneratorsYml = await doesPathExist(join(candidate, RelativeFilePath.of("generators.yml"))); + const hasDefinition = await doesPathExist(join(candidate, RelativeFilePath.of("definition"))); + if (hasGeneratorsYml || hasDefinition) { + return candidate; + } + } + + return undefined; +} + +async function loadTranslatedApiWorkspace({ + docsWorkspace, + translatedWorkspacePath, + apiName, + locale, + cliVersion, + context +}: { + docsWorkspace: DocsWorkspace; + translatedWorkspacePath: AbsoluteFilePath; + apiName: string; + locale: string; + cliVersion: string | undefined; + context: TaskContext; +}): Promise<{ ir: IntermediateRepresentation; workspace?: FernWorkspace }> { + const loadedWorkspace = await loadAPIWorkspace({ + absolutePathToWorkspace: translatedWorkspacePath, + context, + cliVersion: cliVersion ?? "*", + workspaceName: apiName, + lenient: true + }); + + if (!loadedWorkspace.didSucceed) { + handleFailedWorkspaceParserResult(loadedWorkspace, context.logger); + return context.failAndThrow(`Failed to load translated API "${apiName}" for locale "${locale}".`, undefined, { + code: CliError.Code.ConfigError + }); + } + + if (loadedWorkspace.workspace instanceof OSSWorkspace) { + let ir = await loadedWorkspace.workspace.getIntermediateRepresentation({ + context, + audiences: { type: "all" }, + enableUniqueErrorsPerEndpoint: true, + generateV1Examples: false, + logWarnings: false + }); + if (docsWorkspace.config.settings?.substituteEnvVars) { + ir = replaceEnvVariables( + ir, + { + onError: (e) => + context.failAndThrow( + `Error substituting environment variables in translated API spec: ${e}`, + undefined, + { code: CliError.Code.EnvironmentError } + ) + }, + { substituteAsEmpty: false } + ); + } + + let fernWorkspace: FernWorkspace | undefined; + try { + fernWorkspace = await loadedWorkspace.workspace.toFernWorkspace( + { context }, + { + enableUniqueErrorsPerEndpoint: true, + detectGlobalHeaders: false, + objectQueryParameters: true, + preserveSchemaIds: true + } + ); + } catch (error) { + context.logger.warn( + `Could not convert translated API workspace for "${apiName}" (locale "${locale}") to a Fern workspace; dynamic snippets will be unavailable for this translation: ${String(error)}` + ); + context.logger.debug( + `toFernWorkspace error for translated workspace ${locale}/${apiName}:`, + error instanceof Error ? (error.stack ?? error.message) : String(error) + ); + } + return { ir, workspace: fernWorkspace }; + } + + const fernWorkspace = await loadedWorkspace.workspace.toFernWorkspace( + { context }, + { + enableUniqueErrorsPerEndpoint: true, + detectGlobalHeaders: false, + objectQueryParameters: true, + preserveSchemaIds: true + } + ); + let ir = generateIntermediateRepresentation({ + workspace: fernWorkspace, + audiences: { type: "all" }, + generationLanguage: undefined, + keywords: undefined, + smartCasing: false, + exampleGeneration: { + disabled: false, + skipAutogenerationIfManualExamplesExist: true, + skipErrorAutogenerationIfManualErrorExamplesExist: true + }, + readme: undefined, + version: undefined, + packageName: undefined, + context, + sourceResolver: new SourceResolverImpl(context, fernWorkspace) + }); + if (docsWorkspace.config.settings?.substituteEnvVars) { + ir = replaceEnvVariables( + ir, + { + onError: (e) => + context.failAndThrow( + `Error substituting environment variables in translated API spec: ${e}`, + undefined, + { code: CliError.Code.EnvironmentError } + ) + }, + { substituteAsEmpty: false } + ); + } + + return { ir, workspace: fernWorkspace }; +} + +export function replaceApiDefinitionIdsInObject(value: T, replacements: Map): T { + if (Array.isArray(value)) { + return value.map((item) => replaceApiDefinitionIdsInObject(item, replacements)) as T; + } + if (value != null && typeof value === "object") { + const record = value as Record; + return Object.fromEntries( + Object.entries(record).map(([key, child]) => { + if (key === "apiDefinitionId" && typeof child === "string") { + return [key, replacements.get(child) ?? child]; + } + return [key, replaceApiDefinitionIdsInObject(child, replacements)]; + }) + ) as T; + } + return value; +} + +export function applyTranslatedApiNavigationTitlesInObject( + value: T, + replacements: Map +): T { + if (Array.isArray(value)) { + return value.map((item) => applyTranslatedApiNavigationTitlesInObject(item, replacements)) as T; + } + if (value != null && typeof value === "object") { + const record = value as Record; + const translatedRecord = Object.fromEntries( + Object.entries(record).map(([key, child]) => [ + key, + applyTranslatedApiNavigationTitlesInObject(child, replacements) + ]) + ); + if (record.type === "endpoint") { + const apiDefinitionId = record.apiDefinitionId; + const endpointId = record.endpointId; + if (typeof apiDefinitionId === "string" && typeof endpointId === "string") { + const translatedTitle = replacements.get(apiDefinitionId)?.endpointTitlesById.get(endpointId); + if (translatedTitle != null) { + return { + ...translatedRecord, + title: translatedTitle + } as T; + } + } + } + return translatedRecord as T; + } + return value; +} + +export function getHttpEndpointKeys(ir: IntermediateRepresentation): string[] { + const endpointKeys = new Set(); + for (const service of Object.values(ir.services)) { + for (const endpoint of service.endpoints) { + endpointKeys.add(`${endpoint.method} ${stringifyHttpPath(endpoint.fullPath)}`); + } + } + return Array.from(endpointKeys).sort(); +} + +export function getMissingEndpointKeys({ + baseEndpointKeys, + translatedEndpointKeys +}: { + baseEndpointKeys: string[]; + translatedEndpointKeys: string[]; +}): string[] { + const translatedEndpointKeySet = new Set(translatedEndpointKeys); + return baseEndpointKeys.filter((endpointKey) => !translatedEndpointKeySet.has(endpointKey)); +} + +// Collects translated endpoint titles keyed by the IR endpoint id. Only endpoints whose +// translated spec actually provided a title (i.e. an OpenAPI summary / Fern display-name) +// are included — falling back to the FDR endpoint name would re-introduce the auto-generated +// English `startCase(operationId)` and clobber any custom layout titles in the translated locale. +// The IR endpoint id matches the FDR `originalEndpointId`, which is what FernNavigation uses +// when building endpoint nav ids for IR-derived APIs. +function getApiNavigationTitleOverrides(ir: IntermediateRepresentation): ApiNavigationTitleOverrides { + const endpointTitlesById = new Map(); + for (const service of Object.values(ir.services)) { + for (const endpoint of service.endpoints) { + if (endpoint.displayName != null) { + endpointTitlesById.set(endpoint.id, endpoint.displayName); + } + } + } + return { endpointTitlesById }; +} + +function warnIfTranslatedApiIsMissingEndpoints({ + baseEndpointKeys, + translatedEndpointKeys, + apiName, + locale, + context +}: { + baseEndpointKeys: string[]; + translatedEndpointKeys: string[]; + apiName: string; + locale: string; + context: TaskContext; +}): void { + const missingEndpointKeys = getMissingEndpointKeys({ baseEndpointKeys, translatedEndpointKeys }); + if (missingEndpointKeys.length === 0) { + return; + } + + const preview = missingEndpointKeys.slice(0, 10).join(", "); + const suffix = missingEndpointKeys.length > 10 ? `, and ${missingEndpointKeys.length - 10} more` : ""; + context.logger.warn( + `Translated API "${apiName}" for locale "${locale}" is missing ${missingEndpointKeys.length} endpoint(s) from the default API. ` + + `The default docs navigation may link to unavailable translated API pages: ${preview}${suffix}.` + ); +} + +function stringifyHttpPath(path: HttpPath): string { + return `${path.head}${path.parts.map((part) => `{${part.pathParameter}}${part.tail}`).join("")}`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7517bd03c62f..e2f6a4cada90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6224,6 +6224,9 @@ importers: '@fern-api/core-utils': specifier: workspace:* version: link:../../../../commons/core-utils + '@fern-api/docs-markdown-utils': + specifier: workspace:* + version: link:../../../docs-markdown-utils '@fern-api/docs-resolver': specifier: workspace:* version: link:../../../docs-resolver