diff --git a/packages/cli/cli/changes/unreleased/add-docs-translate-command.yml b/packages/cli/cli/changes/unreleased/add-docs-translate-command.yml new file mode 100644 index 000000000000..401d7d244395 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/add-docs-translate-command.yml @@ -0,0 +1,5 @@ +- summary: | + Add `fern docs translation generate` command that interactively sets up internationalization + by letting users pick target languages, updating docs.yml with a translations config, + and creating the corresponding translation directories on disk. + type: feat diff --git a/packages/cli/cli/src/cli.ts b/packages/cli/cli/src/cli.ts index 9baf84ef9a27..a9904a085eb7 100644 --- a/packages/cli/cli/src/cli.ts +++ b/packages/cli/cli/src/cli.ts @@ -55,6 +55,7 @@ import { listDocsPreview } from "./commands/docs-preview/listDocsPreview.js"; import { exportDocsTheme } from "./commands/docs-theme/exportDocsTheme.js"; import { listDocsThemes } from "./commands/docs-theme/listDocsThemes.js"; import { uploadDocsTheme } from "./commands/docs-theme/uploadDocsTheme.js"; +import { docsTranslate } from "./commands/docs-translate/docsTranslate.js"; import { downgrade } from "./commands/downgrade/downgrade.js"; import { generateOpenAPIForWorkspaces } from "./commands/export/generateOpenAPIForWorkspaces.js"; import { formatWorkspaces } from "./commands/format/formatWorkspaces.js"; @@ -1744,10 +1745,39 @@ function addDocsCommand(cli: Argv, cliContext: CliContext) { addDocsDiffCommand(yargs, cliContext); addDocsMdCommand(yargs, cliContext); addDocsThemeCommand(yargs, cliContext); + addDocsTranslationCommand(yargs, cliContext); return yargs; }); } +function addDocsTranslationCommand(cli: Argv, cliContext: CliContext) { + cli.command("translation", "Commands for managing documentation translations", (yargs) => { + addDocsTranslationGenerateCommand(yargs, cliContext); + return yargs; + }); +} + +function addDocsTranslationGenerateCommand(cli: Argv, cliContext: CliContext) { + cli.command( + "generate", + "Interactively set up internationalization for your documentation", + (yargs) => yargs, + async () => { + cliContext.instrumentPostHogEvent({ + command: "fern docs translation generate" + }); + + await docsTranslate({ + project: await loadProjectAndRegisterWorkspacesWithContext(cliContext, { + defaultToAllApiWorkspaces: true, + commandLineApiWorkspace: undefined + }), + cliContext + }); + } + ); +} + function addDocsThemeCommand(cli: Argv, cliContext: CliContext) { cli.command("theme", "Manage org-level themes for your documentation", (yargs) => { addDocsThemeExportCommand(yargs, cliContext); diff --git a/packages/cli/cli/src/commands/docs-translate/docsTranslate.ts b/packages/cli/cli/src/commands/docs-translate/docsTranslate.ts new file mode 100644 index 000000000000..5188d5dd4e87 --- /dev/null +++ b/packages/cli/cli/src/commands/docs-translate/docsTranslate.ts @@ -0,0 +1,332 @@ +import { docsYml } from "@fern-api/configuration"; +import { DOCS_CONFIGURATION_FILENAME } from "@fern-api/configuration-loader"; +import { join, RelativeFilePath } from "@fern-api/fs-utils"; +import { Project } from "@fern-api/project-loader"; +import { checkbox, confirm } from "@inquirer/prompts"; +import chalk from "chalk"; +import { existsSync } from "fs"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import yaml from "js-yaml"; + +import { CliContext } from "../../cli-context/CliContext.js"; + +type Language = docsYml.RawSchemas.Language; + +const LANGUAGE_DISPLAY_NAMES: Record = { + en: "English", + es: "Spanish (Español)", + fr: "French (Français)", + de: "German (Deutsch)", + it: "Italian (Italiano)", + pt: "Portuguese (Português)", + ja: "Japanese (日本語)", + zh: "Chinese (中文)", + ko: "Korean (한국어)", + el: "Greek (Ελληνικά)", + no: "Norwegian (Norsk)", + pl: "Polish (Polski)", + ru: "Russian (Русский)", + sv: "Swedish (Svenska)", + tr: "Turkish (Türkçe)" +}; + +const ALL_LANGUAGES: Language[] = [ + docsYml.RawSchemas.Language.En, + docsYml.RawSchemas.Language.Es, + docsYml.RawSchemas.Language.Fr, + docsYml.RawSchemas.Language.De, + docsYml.RawSchemas.Language.It, + docsYml.RawSchemas.Language.Pt, + docsYml.RawSchemas.Language.Ja, + docsYml.RawSchemas.Language.Zh, + docsYml.RawSchemas.Language.Ko, + docsYml.RawSchemas.Language.El, + docsYml.RawSchemas.Language.No, + docsYml.RawSchemas.Language.Pl, + docsYml.RawSchemas.Language.Ru, + docsYml.RawSchemas.Language.Sv, + docsYml.RawSchemas.Language.Tr +]; + +interface ExistingTranslationState { + defaultLang: Language | undefined; + configuredLangs: Language[]; +} + +interface RawTranslationEntry { + lang: string; + default?: boolean; +} + +function isLanguage(value: string): value is Language { + return Object.values(docsYml.RawSchemas.Language).includes(value as Language); +} + +function getExistingTranslationState(rawContent: string): ExistingTranslationState { + const parsed = yaml.load(rawContent) as Record | undefined; + if (parsed == null) { + return { defaultLang: undefined, configuredLangs: [] }; + } + + const translations = parsed.translations; + if (Array.isArray(translations) && translations.length > 0) { + let defaultLang: Language | undefined; + const configuredLangs: Language[] = []; + for (const entry of translations as RawTranslationEntry[]) { + if (isLanguage(entry.lang)) { + configuredLangs.push(entry.lang); + if (entry.default === true) { + defaultLang = entry.lang; + } + } + } + if (defaultLang == null) { + defaultLang = configuredLangs[0]; + } + return { defaultLang, configuredLangs }; + } + + const languages = parsed.languages; + if (Array.isArray(languages) && languages.length > 0) { + const validLangs = languages.filter((l): l is Language => typeof l === "string" && isLanguage(l)); + const defaultLang = validLangs[0]; + return { defaultLang, configuredLangs: validLangs }; + } + + return { defaultLang: undefined, configuredLangs: [] }; +} + +function buildTranslationsYamlBlock(defaultLang: Language, targetLangs: Language[]): string { + const lines: string[] = []; + lines.push("translations:"); + lines.push(` - lang: ${defaultLang}`); + lines.push(" default: true"); + for (const lang of targetLangs) { + lines.push(` - lang: ${lang}`); + } + return lines.join("\n"); +} + +function updateDocsYamlContent({ + rawContent, + defaultLang, + allTargetLangs +}: { + rawContent: string; + defaultLang: Language; + allTargetLangs: Language[]; +}): string { + const translationsBlock = buildTranslationsYamlBlock(defaultLang, allTargetLangs); + + const translationsRegex = /^translations:\s*\n(?:[ \t]+[^\n]*(?:\n|$))*/m; + if (translationsRegex.test(rawContent)) { + return rawContent.replace(translationsRegex, translationsBlock + "\n"); + } + + const languagesRegex = /^languages:\s*\n(?:[ \t]+[^\n]*(?:\n|$))*/m; + if (languagesRegex.test(rawContent)) { + return rawContent.replace(languagesRegex, translationsBlock + "\n"); + } + + return rawContent.trimEnd() + "\n\n" + translationsBlock + "\n"; +} + +export async function docsTranslate({ + project, + cliContext +}: { + project: Project; + cliContext: CliContext; +}): Promise { + const docsWorkspace = project.docsWorkspaces; + if (docsWorkspace == null) { + cliContext.failAndThrow("No docs workspace found. Please ensure you have a docs.yml file configured."); + return; + } + + const fernDirectory = docsWorkspace.absoluteFilePath; + const docsConfigPath = join(fernDirectory, RelativeFilePath.of(DOCS_CONFIGURATION_FILENAME)); + + const rawDocsContent = await readFile(docsConfigPath, "utf-8"); + + cliContext.logger.info(""); + cliContext.logger.info(chalk.bold("🌐 Fern Docs — Internationalization Setup")); + cliContext.logger.info(chalk.dim("─".repeat(45))); + cliContext.logger.info(""); + + const existingState = getExistingTranslationState(rawDocsContent); + + if (existingState.configuredLangs.length > 0) { + cliContext.logger.info( + chalk.cyan(" Existing translations detected: ") + + existingState.configuredLangs.map((l) => chalk.bold(LANGUAGE_DISPLAY_NAMES[l])).join(", ") + ); + if (existingState.defaultLang != null) { + cliContext.logger.info( + chalk.cyan(" Default language: ") + chalk.bold(LANGUAGE_DISPLAY_NAMES[existingState.defaultLang]) + ); + } + cliContext.logger.info(""); + } + + const alreadyConfigured = new Set(existingState.configuredLangs); + const availableLanguages = ALL_LANGUAGES.filter((lang) => !alreadyConfigured.has(lang)); + + if (availableLanguages.length === 0) { + cliContext.logger.info(chalk.green(" All supported languages are already configured!")); + cliContext.logger.info(""); + return; + } + + let defaultLang = existingState.defaultLang; + if (defaultLang == null) { + cliContext.logger.info( + chalk.white(" Your default (source) language will be set to ") + chalk.bold("English") + chalk.white(".") + ); + cliContext.logger.info(chalk.dim(" This is the language your existing docs are written in.")); + cliContext.logger.info(""); + + const confirmDefault = await confirm({ + message: "Is English your default documentation language?", + default: true + }); + + if (confirmDefault) { + defaultLang = docsYml.RawSchemas.Language.En; + } else { + const allLangsForDefault = ALL_LANGUAGES; + const selectedDefault = await checkbox({ + message: "Select your default (source) language:", + choices: allLangsForDefault.map((lang) => ({ + name: LANGUAGE_DISPLAY_NAMES[lang], + value: lang + })), + required: true, + validate: (selected) => { + if (selected.length !== 1) { + return "Please select exactly one default language."; + } + return true; + } + }); + + const pickedDefault = selectedDefault[0]; + if (pickedDefault == null) { + cliContext.failAndThrow("No default language selected."); + return; + } + defaultLang = pickedDefault; + } + + cliContext.logger.info(""); + cliContext.logger.info( + chalk.green(" ✓ ") + chalk.white("Default language: ") + chalk.bold(LANGUAGE_DISPLAY_NAMES[defaultLang]) + ); + cliContext.logger.info(""); + } + + if (defaultLang == null) { + cliContext.failAndThrow("Unable to determine default language."); + return; + } + + const languagesForSelection = availableLanguages.filter((lang) => lang !== defaultLang); + + if (languagesForSelection.length === 0) { + cliContext.logger.info(chalk.green(" All supported languages are already configured!")); + cliContext.logger.info(""); + return; + } + + const selectedLanguages = await checkbox({ + message: "Which languages would you like to add for translation?", + choices: languagesForSelection.map((lang) => ({ + name: LANGUAGE_DISPLAY_NAMES[lang], + value: lang + })), + required: true, + validate: (selected) => { + if (selected.length === 0) { + return "Please select at least one language."; + } + return true; + } + }); + + if (selectedLanguages.length === 0) { + cliContext.logger.info(chalk.yellow(" No languages selected. Exiting.")); + return; + } + + cliContext.logger.info(""); + cliContext.logger.info(chalk.bold(" Selected languages:")); + for (const lang of selectedLanguages) { + cliContext.logger.info(chalk.cyan(` • ${LANGUAGE_DISPLAY_NAMES[lang]}`) + chalk.dim(` (${lang})`)); + } + cliContext.logger.info(""); + + // Merge newly selected with existing non-default languages + const existingTargetLangs = existingState.configuredLangs.filter((l) => l !== defaultLang); + const allTargetLangs = [...existingTargetLangs, ...selectedLanguages]; + + // Step 1: Update docs.yml + cliContext.logger.info(chalk.dim("─".repeat(45))); + cliContext.logger.info(chalk.bold(" Updating configuration...")); + cliContext.logger.info(""); + + const updatedContent = updateDocsYamlContent({ + rawContent: rawDocsContent, + defaultLang, + allTargetLangs + }); + await writeFile(docsConfigPath, updatedContent, "utf-8"); + + cliContext.logger.info(chalk.green(" ✓ ") + chalk.white(`Updated ${DOCS_CONFIGURATION_FILENAME}`)); + + // Step 2: Create translation directories + cliContext.logger.info(""); + cliContext.logger.info(chalk.bold(" Creating translation directories...")); + cliContext.logger.info(""); + + const translationsDirectory = join(fernDirectory, RelativeFilePath.of("translations")); + + for (const lang of selectedLanguages) { + const langDir = join(translationsDirectory, RelativeFilePath.of(lang)); + + if (existsSync(langDir)) { + cliContext.logger.info( + chalk.yellow(" ○ ") + chalk.dim(`translations/${lang}/`) + chalk.dim(" (already exists)") + ); + continue; + } + + await mkdir(langDir, { recursive: true }); + cliContext.logger.info(chalk.green(" ✓ ") + chalk.white(`Created translations/${lang}/`)); + } + + // Summary + cliContext.logger.info(""); + cliContext.logger.info(chalk.dim("─".repeat(45))); + cliContext.logger.info(chalk.bold(" 🎉 Internationalization setup complete!")); + cliContext.logger.info(""); + cliContext.logger.info(chalk.white(" Your documentation now supports:")); + cliContext.logger.info(chalk.bold(` ${LANGUAGE_DISPLAY_NAMES[defaultLang]}`) + chalk.dim(" (default)")); + for (const lang of allTargetLangs) { + cliContext.logger.info(chalk.bold(` ${LANGUAGE_DISPLAY_NAMES[lang]}`)); + } + + cliContext.logger.info(""); + cliContext.logger.info(chalk.bold(" Next steps:")); + cliContext.logger.info( + chalk.white(" 1. Add translated pages under each ") + + chalk.cyan("translations//") + + chalk.white(" directory") + ); + cliContext.logger.info(chalk.white(" 2. Mirror the same file paths from your main docs")); + cliContext.logger.info( + chalk.white(" 3. Run ") + + chalk.cyan("fern generate --docs") + + chalk.white(" to publish your translated documentation") + ); + cliContext.logger.info(""); +}