DEV Community

Ademola Thompson
Ademola Thompson

Posted on

How to Build a CLI Tool to Auto-Translate OpenAPI Specifications

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.

an overview of how the CLI tool works

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.

  1. 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
    
  2. Create folders for your CLI logic and frontend viewer:

    mkdir cli viewer
    
  3. 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.js and a folder called src. This folder is where most of your CLI logic will live.

  4. 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 @apidevtools/swagger-parser @lingo.dev/compiler tailwindcss @tailwindcss/vite react-dom react-markdown && cd ..
    

    The commands above initialize a React application using Vite. They also install the following:

    • js-yaml to read and write YAML structures in JavaScript for display.
    • @apidevtools/swagger-parser to parse and validate OpenAPI/Swagger spec files, resolving all $ref references into a clean, workable object.
    • @lingo.dev/compiler to handle translation for UI text.
    • react-dom to render your React components in the browser.
    • tailwindcss and @tailwindcss/vite for styling.
    • react-markdown to handle markdown syntax.

    NOTE: If the first command starts a React server, you can simply end the process with ctrl + c (command + c on Mac).

  5. 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
Enter fullscreen mode Exit fullscreen mode

Now, you are ready to start this section.

Step 1. Create the Authentication Flow

  1. In your src/auth.js file, 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,
    • ora for a loading spinner,
    • chalk for colored terminal output.
  2. 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));
    }
    
  3. 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 true or false in 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: The isAuthenticated() function uses 2>&1 because Lingo.dev writes the "Authenticated as" output to stderr instead of stdout. execSync only captures stdout by default.

  4. 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.

  5. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

  1. Open your src/config.js file 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");
    
  2. Write a generateConfig function 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 languages string in any reasonable format, such as es,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.

  1. Open your src/translate.js and 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;
    
  2. Now add the runTranslation function, 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 because lingo.dev run looks 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.

  3. 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.

Step 5: Create Your CLI Entry Point

The index.js file created earlier will serve as the entry point for your CLI tool. It uses Commander to define the commands and connect them to the functions you've built so far.

  1. Paste your imports into index.js:

    #!/usr/bin/env node
    
    import { program } from "commander";
    import chalk from "chalk";
    import path from "path";
    import { checkAuth } from "./src/auth.js";
    import { setup } from "./src/setup.js";
    import { generateConfig } from "./src/config.js";
    import { translate } from "./src/translate.js";
    
    const TRANSSPEC_DIR = ".trans-spec";
    
    program
    .name("trans-spec")
    .description("Translate your OpenAPI spec into multiple languages")
    .version("1.0.0");
    
  2. Add the generate command:

    program
    .command("generate")
    .description("Translate an OpenAPI spec into multiple languages")
    .requiredOption("--spec <path>", "Path to your OpenAPI spec file")
    .option("--languages <languages>", "Target languages e.g. es,fr,de")
    .option("--source <language>", "Source language (default: en)", "en")
    .action(async (options) => {
    console.log(chalk.bold("\n🌍 Trans-Spec\n"));
    
    await checkAuth();
    await setup(options.spec, options.source);
    const targets = await generateConfig(options.languages, options.source);
    await translate(targets);
    
    console.log(chalk.bold("\n✔ Done! Your translated specs are in:\n"));
    targets.forEach((lang) => {
      console.log(chalk.cyan(`  ${TRANSSPEC_DIR}/i18n/${lang}/api.yaml`));
    });
    console.log(chalk.white("\nTo view your docs, run:"));
    console.log(chalk.cyan("  npx trans-spec serve\n"));
    });
    

    Each step maps directly to a file you've built — checkAuth, setup, generateConfig, and translate run in sequence. When done, the command prints the path to each translated file and suggests the next step.

  3. Now add the serve command that will allow the user generate a frontend to browse their API documentation:

    This command does the following:

    • It looks for a Lingo.dev API key. If it does not find it, it prompts the user for it, and the key is saved so they aren't asked again on the next run. This is because the Lingo.dev CLI and Compiler do not share auth state, so users need to authenticate for both scenarios.
    • It generates a Vite configuration file (vite.config.generated.js) dynamically from i18n.json rather than being a static file. This means it always reflects the current language setup without the user having to touch any config manually. We'll cover the viewer itself in the next section.

If you have gotten to this section, congratulations! You now have a CLI tool that translates OpenAPI specification files. To test it, navigate to your cli directory and run this command:

node index.js generate --spec api.yaml --languages es,fr
Enter fullscreen mode Exit fullscreen mode

NOTE: Ensure you have an api.yaml file in your directory. You should also update your package.json files as such:

 "type": "module"
Enter fullscreen mode Exit fullscreen mode

Build a Frontend Interface to View any OpenAPI Specification File

With your translated YAML files ready, you need a way to actually view them. In this section, you'll build a lightweight React interface that reads your OpenAPI spec files and renders them into a readable API reference with language switching built in.

Step 1: Set Up the Viewer File Structure

Navigate to your viewer/src folder and create the folders and files you will work with.

# navigate
cd viewer/src

# create necessary folders
mkdir components utils

## create files
touch utils/specLoader.js components/Sidebar.jsx components/EndpointDetail.jsx components/LanguageSwitcher.jsx
Enter fullscreen mode Exit fullscreen mode

The above creates the components and utils folders with associated files.

Step 2: Load and Parse the OpenAPI Spec Files

Before the UI can render anything, it needs to know what spec files are available and how to load them. specLoader.js handles all of that:

  • fetching the index
  • detecting the right language
  • parsing the YAML into a usable object.
  1. Open your viewer/src/utils/specLoader.js file and paste this:

    import SwaggerParser from "@apidevtools/swagger-parser";
    import yaml from "js-yaml";
    
  2. Now add the functions that fetch the available specs and languages:

    /**
    * Load the index to see what specs are available
    */
    export async function getSpecIndex() {
    const response = await fetch("/trans-spec/index.json");
    return await response.json();
    }
    
    /**
     * Load and parse the i18n.json config to get available languages
     */
    export async function getAvailableLanguages() {
    try {
    const response = await fetch("/trans-spec/i18n.json");
    const config = await response.json();
    
    return {
      source: config.locale.source,
      targets: config.locale.targets,
      all: [config.locale.source, ...config.locale.targets],
    };
    } catch (err) {
    console.error("Failed to load i18n config:", err);
    return { source: "en", targets: [], all: ["en"] };
    }
    }
    

    getSpecIndex fetches the index.json file the CLI generated — a map of every language to its available spec files. getAvailableLanguages reads i18n.json and returns the source language, the target languages, and a combined list of all of them.

  3. Next, add the language detection helpers:

    export function getBrowserLanguage() {
    // navigator.language returns like "en-US", "es-ES", etc.
    const lang = navigator.language.split("-")[0]; // Get just "en", "es", etc.
    return lang;
    }
    
    /**
     * Get default language based on browser preference
     */
    export async function getDefaultLanguage() {
    const browserLang = getBrowserLanguage();
    const { all, source } = await getAvailableLanguages();
    
      // Use browser language if available, otherwise fallback to source
    return all.includes(browserLang) ? browserLang : source;
    }
    

    getBrowserLanguage reads the browser's language preference. getDefaultLanguage then checks whether that language is actually available in the spec, falling back to the source language if not.

  4. Finally, add the functions that pick the default spec and load it:

    /**
    * Get default spec (first one alphabetically)
    */
    export async function getDefaultSpec() {
    const index = await getSpecIndex();
    const defaultLang = await getDefaultLanguage();
    const specs = index[defaultLang] || [];
    return specs[0] || null;
    }
    
    /**
     * Load and parse an OpenAPI spec for a specific language and filename
     */
    export async function loadSpec(language, filename) {
    try {
    const response = await fetch(`/trans-spec/i18n/${language}/${filename}`);
    const yamlText = await response.text();
    const parsed = yaml.load(yamlText);
    
    // Dereference all $refs for easier rendering
    const dereferenced = await SwaggerParser.dereference(parsed);
    
    return dereferenced;
    } catch (err) {
    console.error(`Failed to load spec ${filename} for ${language}:`, err);
    throw err;
    }
    }
    

    getDefaultSpec picks the first spec file alphabetically for the default language. This is useful if the user decides to generate more than one spec file. They will be able to toggle between them on the frontend.
    loadSpec fetches the raw YAML, parses it with js-yaml, then passes the result through SwaggerParser.dereference. This resolves all $ref references in the spec, so every component is fully expanded and ready to render without any additional lookups.

Step 3: Build the App Component

App.jsx is the root of the viewer. It initializes the app, manages state, and wires the components together.

In src/App.jsx:

import { useState, useEffect } from "react";
import { useLingoContext } from "@lingo.dev/compiler/react";
import {
  getDefaultLanguage,
  getDefaultSpec,
  getAvailableLanguages,
  getSpecIndex,
  loadSpec,
} from "./utils/specLoader";
import Sidebar from "./components/Sidebar";
import EndpointDetail from "./components/EndpointDetail";
import LanguageSwitcher from "./components/LanguageSwitcher";
Enter fullscreen mode Exit fullscreen mode

Now add the component:

function App() {
  const { locale: uiLocale, setLocale: setUILocale } = useLingoContext();

  const [spec, setSpec] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [currentSpecFile, setCurrentSpecFile] = useState(null);
  const [availableLanguages, setAvailableLanguages] = useState([]);
  const [availableSpecs, setAvailableSpecs] = useState([]);
  const [selectedEndpoint, setSelectedEndpoint] = useState(null);
Enter fullscreen mode Exit fullscreen mode

useLingoContext gives you the current UI locale and a setter — this is how Lingo.dev's compiler knows which language to render UI text in. The rest of the state tracks the loaded spec, the selected endpoint, and what languages and spec files are available.

On mount, the app initializes everything in one go:

useEffect(() => {
    async function init() {
      try {
        setLoading(true);
        const defaultLang = await getDefaultLanguage();
        const defaultSpec = await getDefaultSpec();
        const languages = await getAvailableLanguages();
        const index = await getSpecIndex();

        setUILocale(defaultLang);
        setCurrentSpecFile(defaultSpec);
        setAvailableLanguages(languages.all);
        setAvailableSpecs(index[defaultLang] || []);

        const loadedSpec = await loadSpec(defaultLang, defaultSpec);
        setSpec(loadedSpec);

        if (loadedSpec.paths) {
          const firstPath = Object.keys(loadedSpec.paths)[0];
          const firstMethod = Object.keys(loadedSpec.paths[firstPath])[0];
          setSelectedEndpoint({ path: firstPath, method: firstMethod });
        }
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    init();
  }, [setUILocale]);
Enter fullscreen mode Exit fullscreen mode

It detects the default language, loads the spec, and automatically selects the first endpoint so the UI has something to show right away.

Next, add two more effects to handle updates after initialization. One will reload the spec when the language changes, and the other will update the list of available spec files:

useEffect(() => {
    if (!uiLocale || !currentSpecFile) return;
    async function reload() {
      try {
        setLoading(true);
        const loadedSpec = await loadSpec(uiLocale, currentSpecFile);
        setSpec(loadedSpec);
        if (loadedSpec.paths) {
          const firstPath = Object.keys(loadedSpec.paths)[0];
          const firstMethod = Object.keys(loadedSpec.paths[firstPath])[0];
          setSelectedEndpoint({ path: firstPath, method: firstMethod });
        }
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    reload();
  }, [uiLocale, currentSpecFile]);

  useEffect(() => {
    if (!uiLocale) return;
    async function updateSpecs() {
      const index = await getSpecIndex();
      setAvailableSpecs(index[uiLocale] || []);
    }
    updateSpecs();
  }, [uiLocale]);
Enter fullscreen mode Exit fullscreen mode

Finally, render your UI:

if (loading) return <div>Loading API documentation...</div>;
  if (error) return <div>Error: {error}  Make sure you've run trans-spec generate first.</div>;

  return (
    <div className="h-screen flex flex-col">
      <header className="border-b px-6 py-4 flex items-center justify-between">
        <div className="flex items-center gap-4">
          <h1 className="text-2xl font-bold">{spec?.info?.title || "API Documentation"}</h1>
          {availableSpecs.length > 1 && (
            <select value={currentSpecFile} onChange={(e) => setCurrentSpecFile(e.target.value)}>
              {availableSpecs.map((f) => <option key={f} value={f}>{f}</option>)}
            </select>
          )}
        </div>
        <LanguageSwitcher availableLanguages={availableLanguages} />
      </header>

      <div className="flex-1 flex overflow-hidden">
        <Sidebar spec={spec} selectedEndpoint={selectedEndpoint} onSelectEndpoint={setSelectedEndpoint} />
        <main className="flex-1 overflow-y-auto p-8">
          {selectedEndpoint && (
            <EndpointDetail spec={spec} path={selectedEndpoint.path} method={selectedEndpoint.method} />
          )}
        </main>
      </div>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

The header shows the API title and, if there are multiple spec files, a dropdown to switch between them. The LanguageSwitcher sits in the top right. Below that, the layout is a sidebar for navigation and a main area that renders the selected endpoint — both of which you'll build next.

Step 4: Update main.jsx to use LingoProvider

In your viewer/src/main.jsx file, paste this:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { LingoProvider } from '@lingo.dev/compiler/react'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <LingoProvider>
      <App />
    </LingoProvider>
  </StrictMode>,
)
Enter fullscreen mode Exit fullscreen mode

Step 5: Build a Language Switcher

Now, you should build a simple toggle that allows users to switch between languages:

import { useLingoContext } from "@lingo.dev/compiler/react";

export default function LanguageSwitcher({ availableLanguages }) {
  const { locale, setLocale } = useLingoContext();

  return (
    <div className="flex items-center space-x-2">
      <label htmlFor="language-select" className="text-sm text-gray-600">
        Language:
      </label>
      <select
        id="language-select"
        value={locale}
        onChange={(e) => setLocale(e.target.value)}
        className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        {availableLanguages.map((lang) => (
          <option key={lang} value={lang}>
            {lang.toUpperCase()}
          </option>
        ))}
      </select>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useLingoContext gives the component access to the current locale and the setLocale function. When the user picks a different language from the dropdown, setLocale updates the context, which triggers the useEffect in App.jsx that reloads the spec for the new language.

Step 6: Build the Sidebar

The sidebar lists all available endpoints grouped by tag, with a colored method badge and the endpoint path for each one.
Paste this in viewer/src/components/Sidebar.jsx:

export default function Sidebar({ spec, selectedEndpoint, onSelectEndpoint }) {
  if (!spec || !spec.paths) {
    return (
      <aside className="w-64 border-r p-4">
        <p className="text-sm">No endpoints found</p>
      </aside>
    );
  }

  const endpointsByTag = {};

  Object.entries(spec.paths).forEach(([path, pathItem]) => {
    Object.entries(pathItem).forEach(([method, operation]) => {
      if (method === "parameters" || method === "servers") return;

      const tags = operation.tags || ["Default"];
      tags.forEach((tag) => {
        if (!endpointsByTag[tag]) endpointsByTag[tag] = [];
        endpointsByTag[tag].push({ path, method, operation });
      });
    });
  });

  return (
    <aside className="w-80 border-r overflow-y-auto">
      <div className="p-4">
        <h2 className="text-xs font-semibold uppercase tracking-wider mb-4">Endpoints</h2>

        {Object.entries(endpointsByTag).map(([tag, endpoints]) => (
          <div key={tag} className="mb-6">
            <h3 className="text-sm font-semibold mb-2">{tag}</h3>
            <div className="space-y-1">
              {endpoints.map(({ path, method, operation }) => {
                const isSelected =
                  selectedEndpoint?.path === path &&
                  selectedEndpoint?.method === method;

                return (
                  <button
                    key={`${method}-${path}`}
                    onClick={() => onSelectEndpoint({ path, method })}
                    className={`w-full text-left px-3 py-2 rounded-md text-sm ${
                      isSelected ? "bg-blue-50 text-blue-700 font-medium" : "hover:bg-gray-50"
                    }`}
                  >
                    <div className="flex items-center gap-2">
                      <span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase ${getMethodColor(method)}`}>
                        {method}
                      </span>
                      <span className="truncate">{path}</span>
                    </div>
                    {operation.summary && (
                      <p className="text-xs text-gray-500 mt-1 truncate">{operation.summary}</p>
                    )}
                  </button>
                );
              })}
            </div>
          </div>
        ))}
      </div>
    </aside>
  );
}

function getMethodColor(method) {
  const colors = {
    get: "bg-blue-100 text-blue-800",
    post: "bg-green-100 text-green-800",
    put: "bg-yellow-100 text-yellow-800",
    delete: "bg-red-100 text-red-800",
    patch: "bg-purple-100 text-purple-800",
  };
  return colors[method.toLowerCase()] || "bg-gray-100 text-gray-800";
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Build a Component to Visualize Endpoints
This will be the main content area. When the user selects an endpoint from the sidebar, this component renders everything about it: the method, path, parameters, request body, responses, and metadata.

Paste this into your viewer/scr/componente/EndpointDetail.jsx file:

import ReactMarkdown from "react-markdown";

export default function EndpointDetail({ spec, path, method }) {
  if (!spec || !path || !method) return null;

  const operation = spec.paths[path]?.[method];
  if (!operation) return null;

  return (
    <div className="p-8 max-w-4xl space-y-8">

      {/* Header */}
      <div>
        <div className="flex items-center gap-3 mb-3">
          <span className={`px-3 py-1 rounded text-sm font-semibold uppercase ${getMethodColor(method)}`}>{method}</span>
          <code className="text-lg font-mono bg-gray-100 px-3 py-1 rounded">{path}</code>
        </div>
        {operation.summary && <h1 className="text-2xl font-bold mb-2">{operation.summary}</h1>}
        {operation.description && <ReactMarkdown>{operation.description}</ReactMarkdown>}
      </div>

      {/* Parameters */}
      {operation.parameters?.length > 0 && (
        <div>
          <h2 className="text-lg font-semibold mb-3">Parameters</h2>
          <table className="w-full text-sm border rounded-lg overflow-hidden">
            <thead className="bg-gray-50 text-left">
              <tr>
                {["Name", "Type", "In", "Required", "Description"].map((h) => (
                  <th key={h} className="px-4 py-2 text-xs uppercase font-semibold">{h}</th>
                ))}
              </tr>
            </thead>
            <tbody className="divide-y">
              {operation.parameters.map((param, idx) => (
                <tr key={idx}>
                  <td className="px-4 py-2"><code className="text-blue-600">{param.name}</code></td>
                  <td className="px-4 py-2 font-mono text-xs">{param.schema?.type || "any"}</td>
                  <td className="px-4 py-2"><span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{param.in}</span></td>
                  <td className="px-4 py-2">
                    {param.required
                      ? <span className="px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs">Required</span>
                      : <span className="text-gray-400 text-xs">Optional</span>}
                  </td>
                  <td className="px-4 py-2 text-gray-600">{param.description || "-"}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {/* Request Body */}
      {operation.requestBody && (
        <div>
          <h2 className="text-lg font-semibold mb-3">Request Body</h2>
          <div className="border rounded-lg p-4 space-y-2">
            {operation.requestBody.required && (
              <span className="px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs">Required</span>
            )}
            {operation.requestBody.description && (
              <ReactMarkdown>{operation.requestBody.description}</ReactMarkdown>
            )}
            {operation.requestBody.content && (
              <div className="flex gap-2 flex-wrap">
                {Object.keys(operation.requestBody.content).map((type) => (
                  <code key={type} className="px-2 py-1 bg-gray-50 border rounded text-xs">{type}</code>
                ))}
              </div>
            )}
          </div>
        </div>
      )}

      {/* Responses */}
      {operation.responses && (
        <div>
          <h2 className="text-lg font-semibold mb-3">Responses</h2>
          <div className="space-y-3">
            {Object.entries(operation.responses).map(([code, response]) => (
              <div key={code} className="border rounded-lg p-4">
                <div className="flex items-center gap-3">
                  <span className={`px-2 py-1 rounded text-sm font-bold ${getStatusColor(code)}`}>{code}</span>
                  <span>{response.description}</span>
                </div>
              </div>
            ))}
          </div>
        </div>
      )}

      {/* Metadata */}
      {(operation.operationId || operation.tags?.length > 0) && (
        <div className="pt-6 border-t flex gap-4 text-sm text-gray-500 flex-wrap">
          {operation.operationId && (
            <span>Operation ID: <code className="bg-gray-100 px-2 py-0.5 rounded">{operation.operationId}</code></span>
          )}
          {operation.tags?.map((tag) => (
            <span key={tag} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded">{tag}</span>
          ))}
        </div>
      )}

    </div>
  );
}

function getMethodColor(method) {
  const colors = { get: "bg-blue-100 text-blue-800", post: "bg-green-100 text-green-800", put: "bg-yellow-100 text-yellow-800", delete: "bg-red-100 text-red-800", patch: "bg-purple-100 text-purple-800" };
  return colors[method.toLowerCase()] || "bg-gray-100 text-gray-800";
}

function getStatusColor(status) {
  const code = parseInt(status);
  if (code >= 200 && code < 300) return "bg-green-100 text-green-800";
  if (code >= 400 && code < 500) return "bg-yellow-100 text-yellow-800";
  if (code >= 500) return "bg-red-100 text-red-800";
  return "bg-gray-100 text-gray-800";
}
Enter fullscreen mode Exit fullscreen mode

The component is split into four sections:

  • parameters
  • request body
  • responses
  • metadata

Each is only rendered if the operation actually has that data. getStatusColor maps HTTP status code ranges to colors so 2xx responses look green, 4xx yellow, and 5xx red at a glance.

Step 8: Update CSS and Configure the Entry Point

  1. Now, replace your viewer/src/index.css file so the project can use Tailwind CSS:

    @import "tailwindcss";
    
  2. Next, open your viewer/src/main.jsx file and add this at the top:

    import { Buffer } from "buffer";
    window.Buffer = Buffer;
    

    The Buffer polyfill comes first, so it's available before any other imports load. Buffer is a Node.js built-in that isn't available in the browser by default. @apidevtools/swagger-parser relies on it internally, so without this polyfill, it would throw an error in the browser.
    Importing it from the buffer package and assigning it to window.Buffer makes it globally available before anything else runs.

Congratulations! You have successfully built a CLI tool that translates OpenAPI specification files into multiple languages and provides a frontend to browse the documentation.

To test it, navigate to your cli folder and run this command:

node index.js serve
Enter fullscreen mode Exit fullscreen mode

Top comments (0)