APIs defined with the OpenAPI Specification make integration easier, but language can still be a barrier. Many API specs, descriptions, and examples are written in a single language, which limits accessibility for global developers.
In this article, you’ll learn how to build a CLI tool that automatically translates an OpenAPI specification into multiple languages, making your API documentation more accessible without maintaining separate versions manually.
What You Will Build
By the end of this tutorial, you'll have built a complete CLI application that:
- Parses and manipulates OpenAPI/Swagger specs programmatically
- Integrates with Lingo.dev for context-aware translations
- Generates a dynamic React viewer with language switching
Prerequisites
- Intermediate JavaScript/Node.js knowledge
- Familiarity with React (for the frontend viewer portion)
- Basic understanding of OpenAPI/Swagger specifications
- A Lingo.dev account
How it Works
Trans-Spec operates as a three-stage pipeline that takes your OpenAPI specification and produces multilingual documentation with a browsable view.
The Process
1. Parse & Extract
- Reads your OpenAPI spec
- Identifies translatable content (descriptions, summaries)
- Preserves technical terms (paths, parameter names, enums)
2. Set up Structure and Translate
- Creates .trans-spec/i18n/ folder structure
- Generates i18n.json configuration
- Calls Lingo.dev to translate content
- Saves translated specs per language
3. Serve
- Copies the translated specs to the frontend public directory
- Generates a dynamic Vite config with your languages
- Translates the frontend UI using Lingo.dev Compiler
- Starts the dev server at localhost:3000
Key Feature: Language switching updates both the API docs content AND the viewer UI simultaneously. When you select Spanish, you see Spanish API descriptions with Spanish UI labels.
Incremental Updates: Re-running generate only re-translates changed content and preserves existing translations.
Project Initialization and Setup
This project will be a monorepo that contains the CLI and the frontend viewer in a single parent folder.
-
Create a new folder for your project and initialize a NodeJS project:
# create a new folder (called trans-spec) and navigate into it mkdir trans-spec && cd trans-spec # Initialize root package.json npm init -y -
Create folders for your CLI logic and frontend viewer:
mkdir cli viewer -
Set up your CLI:
# Setup CLI cd cli && npm init -y # install dependencies npm install commander chalk ora # create necessary directories and files mkdir src && touch index.js src/auth.js src/setup.js src/config.js src/translate.js && cd ..The above initializes a NodeJS application and installs:
- commander: to handle CLI commands and flags.
- chalk: to add colors in the terminal (makes output look nice).
- ora: for spinner/progress indicators while translations are running.
The command also creates an entry file called
index.jsand a folder calledsrc. This folder is where most of your CLI logic will live. -
Set up your viewer:
# Setup Viewer cd viewer && npm install -g create-vite && npm create vite@latest . -- --template react # install dependencies npm install js-yaml && cd ..The commands above initialize a React application using Vite and install
js-yamlto read and write YAML structures in JavaScript for display.
If the first command starts a React server, you can simply end the process withctrl + c(command + con Mac). -
Finally, open your project in VSCode:
code .
Building a CLI Tool to Translate OpenAPI Specification Files
If you have completed the section above, your cli folder should look like this:
cli/
├── index.js
├── package.json
└── src/
├── auth.js
├── config.js
└── setup.js
└── translate.js
Now, you are ready to start this section.
Step 1. Create the Authentication Flow
-
In your
src/auth.jsfile, start by importing necessary dependencies:
import { execSync, spawn } from "child_process"; import ora from "ora"; import chalk from "chalk";The code above imports:
-
execSync, which you can use to run a command and wait for it to finish before moving to the next process, -
spawn, which you can use to run a command in the background and react to events as they happen, -
orafor a loading spinner, -
chalkfor colored terminal output.
-
-
Next, add a small helper that allows you to pause execution for a given number of milliseconds. This is useful when you need to wait for a response:
const MAX_RETRIES = 2; function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -
Now write the function that checks whether the user is already logged in to their Lingo.dev account, since this project uses Lingo.dev for translation:
function isAuthenticated() { try { const output = execSync("npx lingo.dev@latest auth 2>&1", { encoding: "utf-8", stdio: "pipe", }); return output.includes("Authenticated as"); } catch (err) { return false; } }The code above runs the Lingo.dev CLI auth command and checks the output for "Authenticated as". This check is because Lingo.dev does not return output like
trueorfalsein the auth command; instead, it returns a message like:
Authenticated as <your-email>If anything goes wrong while running the command, the code simply returns false.
NOTE: TheisAuthenticated()function uses2>&1because Lingo.dev writes the "Authenticated as" output tostderrinstead ofstdout.execSynconly captures stdout by default. -
Write a function to trigger login if the user authentication fails:
async function triggerLogin() { return new Promise((resolve, reject) => { const login = spawn("npx", ["lingo.dev@latest", "login"], { stdio: "inherit", shell: true, }); login.on("close", (code) => { if (code === 0) { spinner.succeed("Login complete"); resolve(); } else { spinner.fail("Login failed"); reject(new Error("Login process exited with code " + code)); } }); login.on("error", (err) => { spinner.fail("Login failed"); reject(err); }); }); }NOTE: using
stdio: 'inherit'lets the login process use the same terminal as your CLI tool, so the user can see what's happening. -
Now, create and export a function that will run the full authentication process, using the code we already have:
export async function checkAuth() { const spinner = ora("Checking authentication").start(); // Already authenticated, no need to login if (isAuthenticated()) { spinner.succeed(chalk.green("Authenticated")); return; } spinner.stop(); // Not authenticated, trigger login once try { await triggerLogin(); } catch (err) { console.log(chalk.red("✖ Login failed: " + err.message)); process.exit(1); } // After login, wait and retry auth check up to MAX_RETRIES times for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { await wait(2000); // wait 2 seconds before checking if (isAuthenticated()) { console.log(chalk.green("✔ Successfully authenticated")); return; } if (attempt < MAX_RETRIES) { console.log( chalk.yellow( `Auth check failed. Retrying (${attempt}/${MAX_RETRIES})...`, ), ); } } console.log(chalk.red("✖ Authentication failed")); console.log( chalk.white("Please run: npx lingo.dev@latest login manually and try again." ), ); process.exit(1); }The spinner stops before
triggerLogin()is called. If it kept running while the login process took over the terminal, the output would be garbled. After login, the function retries the auth check up to two times with a 2-second gap, giving credentials time to be written to disk before giving up.
Step 2: Prepare Your OpenAPI File for Translation
Before your CLI tool can translate anything, it needs to know where your OpenAPI spec file is and what language it's written in.
setup.js handles that initialization step. It takes your existing spec file, creates the .trans-spec folder structure, and copies the file into the right place so the rest of the tool knows where to find it.
If the spec file is in English, it creates a .trans-spec/i18n/en/your-file.yaml and copies your file there. If it is in French, it uses the fr folder instead of en. Later, you will get the language source from the user so your code can know which language code to use.
Copy this code into your src/setup.js file:
import fs from "fs";
import path from "path";
import chalk from "chalk";
import ora from "ora";
const TRANSSPEC_DIR = ".trans-spec";
const I18N_DIR = path.join(TRANSSPEC_DIR, "i18n");
export async function setup(specPath, sourceLanguage) {
const spinner = ora("Setting up project...").start();
try {
const resolvedSpecPath = path.resolve(specPath);
if (!fs.existsSync(resolvedSpecPath)) {
spinner.fail(chalk.red(`Spec file not found: ${specPath}`));
process.exit(1);
}
// Use source language folder
const sourceDir = path.join(I18N_DIR, sourceLanguage);
if (!fs.existsSync(TRANSSPEC_DIR)) {
fs.mkdirSync(sourceDir, { recursive: true });
spinner.text = "Created .trans-spec folder structure";
}
// Preserve the original filename
const originalFilename = path.basename(resolvedSpecPath);
const destPath = path.join(sourceDir, originalFilename);
fs.copyFileSync(resolvedSpecPath, destPath);
spinner.succeed(chalk.green("Project setup complete"));
} catch (err) {
spinner.fail(chalk.red("Setup failed: " + err.message));
process.exit(1);
}
}
Step 3: Generate the Lingo.dev Configuration
Lingo.dev needs a configuration file to know which languages to translate from and to. src/config.js generates that file and saves it at .trans-spec/i18n.json.
-
Open your
src/config.jsfile and add your impors:
import fs from "fs"; import path from "path"; import chalk from "chalk"; import ora from "ora"; const TRANSSPEC_DIR = ".trans-spec"; const CONFIG_PATH = path.join(TRANSSPEC_DIR, "i18n.json"); -
Write a
generateConfigfunction to generate the configuration Lingo.dev expects:
export async function generateConfig(languages, source = "en") { const spinner = ora("Generating config...").start(); try { // Handles comma separated values: "es,fr,de" // Parse new languages if provided let newTargets = []; if (languages) { newTargets = languages .split(/[\s,]+/) .map((lang) => lang.trim()) .filter(Boolean); } // Check if config already exists let existingTargets = []; if (fs.existsSync(CONFIG_PATH)) { const existingConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")); existingTargets = existingConfig.locale?.targets || []; } // Merge: existing + new, remove duplicates const allTargets = [...new Set([...existingTargets, ...newTargets])]; if (allTargets.length === 0) { spinner.fail(chalk.red("No target languages provided")); process.exit(1); } const config = { $schema: "https://lingo.dev/schema/i18n.json", version: "1.12", locale: { source: source, targets: allTargets, }, buckets: { yaml: { include: ["i18n/[locale]/*.yaml"], }, }, }; fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2)); if (newTargets.length > 0) { spinner.succeed( chalk.green(`Config updated: ${source} → ${allTargets.join(", ")}`), ); } else { spinner.succeed( chalk.green( `Using existing config: ${source} → ${allTargets.join(", ")}`, ), ); } return allTargets; } catch (err) { spinner.fail(chalk.red("Config generation failed: " + err.message)); process.exit(1); } }The function accepts a
languagesstring in any reasonable format, such ases,fr. If a configuration file already exists, the new languages are merged with the existing ones rather than overwriting them.
Wrapping in new Set handles duplicates, so running the command twice with the same languages won't bloat the file.
The buckets field is what Lingo.dev uses to find your files. The [locale] placeholder in i18n/[locale]/*.yaml gets replaced with each target language code at translation time, mapping directly to the folder structure you created in the previous step.
You can read the official Lingo.dev documentation for further understanding.
Finally, the function returns the targets array because the index.js file will need it later to tell the user where their translated files are.
Step 4: Call Lingo.dev for Translation
Now that you have created the configuration that Lingo.dev expects, you can perform the actual translation by programmatically calling lingo.dev run.
-
Open your
src/translate.jsand add your imports:
import { spawn } from "child_process"; import chalk from "chalk"; import ora from "ora"; import path from "path"; const TRANSSPEC_DIR = ".trans-spec"; const MAX_RETRIES = 2; -
Now add the
runTranslationfunction, which spawns the Lingo.dev CLI and watches the output:
async function runTranslation() { return new Promise((resolve, reject) => { let output = ""; let hasErrors = false; const process = spawn("npx", ["lingo.dev@latest", "run"], { cwd: path.resolve(TRANSSPEC_DIR), shell: true, stdio: "pipe", }); // Capture stdout process.stdout.on("data", (data) => { const text = data.toString(); output += text; console.log(text); // Check for failure indicators if (text.includes("❌") || text.includes("[Failed Files]")) { hasErrors = true; } }); process.on("close", (code) => { if (code !== 0 || hasErrors) { reject(new Error("Translation had errors or failures")); } else { resolve(); } }); process.on("error", (err) => { reject(err); }); }); }Notice the cwd option:
cwd: path.resolve(TRANSSPEC_DIR)This tells the spawn process to run from inside the
.trans-spec/folder. This is critical becauselingo.dev runlooks for i18n.json in the current working directory. Without this, it would look in the wrong place and fail.The output is piped so the function can scan it in real time. A zero exit code alone isn't enough to confirm success. Lingo.dev can exit cleanly, but still report failed files in the output. So the function also watches for "❌" and "[Failed Files]", and treats those as errors.
-
Now, add the
translate()function that ties it together:
export async function translate(targets) { const spinner = ora("Translating...").start(); spinner.stop(); for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { await runTranslation(); console.log(chalk.green("\n✔ Translation complete")); return; } catch (err) { if (attempt < MAX_RETRIES) { console.log( chalk.yellow( `Translation failed. Retrying (${attempt}/${MAX_RETRIES})...`, ), ); } } } console.log(chalk.red("Translation failed after 2 attempts.")); console.log( chalk.white("Please try running manually: npx lingo.dev@latest run"), ); process.exit(1); }If the first attempt fails, it retries once more before giving up. On final failure, it exits with a message pointing the user to run the command manually — the same pattern you used in auth.js.


