This guide walks through building a tool that flags missing translations. By the end, you'll understand how to load a project and query messages with the CRUD API.

What tools can do

Tools read and write translations through the .inlang format via the CRUD API. Because plugins handle conversion at the boundary, your tool works with any translation format — JSON, XLIFF, i18next, etc. — without parsing each one directly.

An .inlang project is canonically a single binary file. In Git repositories, it is often unpacked into a directory; loadProjectFromDirectory() loads that Git-friendly representation.

If a project.inlang/ directory already exists, load it with loadProjectFromDirectory(). If your tool is generating a new localization project from scratch, start with newProject() and save the packed file with project.toBlob(); see Getting Started for a runnable create-save-reload example.

┌─────────────────┐
│    Your Tool    │
├─────────────────┤
│    CRUD API     │  ◄── Query and modify messages
├─────────────────┤
│  .inlang file   │
├─────────────────┤
│    Plugins      │  ◄── Handle file formats
└─────────────────┘

Step 1: Load the project

import { loadProjectFromDirectory } from "@inlang/sdk";
import fs from "node:fs";

const project = await loadProjectFromDirectory({
  path: "./project.inlang",
  fs,
});

That's it. The project is loaded with all translations from your files (via plugins). The external files are compatibility files; the tool works against the shared .inlang data model.

Step 2: Check load errors

Plugin import and resource-file errors are exposed through project.errors.get(). Check them before trusting query results.

const errors = await project.errors.get();
if (errors.length > 0) {
  throw new AggregateError(errors, "Could not load inlang project");
}

In one-off scripts and CLIs, close the project before the process exits:

await project.close();

Step 3: Get project settings

const settings = await project.settings.get();

console.log("Base locale:", settings.baseLocale);
console.log("Locales:", settings.locales);
// Base locale: en
// Locales: ["en", "de", "fr"]

Step 4: Query all bundles

const bundles = await project.db.selectFrom("bundle").selectAll().execute();

console.log(`Found ${bundles.length} translation keys`);

Step 5: Find missing translations

Now let's find bundles that are missing translations for certain locales:

async function findMissingTranslations(project) {
  const settings = await project.settings.get();
  const bundles = await project.db.selectFrom("bundle").selectAll().execute();
  const messages = await project.db.selectFrom("message").selectAll().execute();

  const missing = [];

  for (const bundle of bundles) {
    // Get all messages for this bundle
    const bundleMessages = messages.filter((m) => m.bundleId === bundle.id);
    const localesWithTranslation = bundleMessages.map((m) => m.locale);

    // Find which locales are missing
    for (const locale of settings.locales) {
      if (!localesWithTranslation.includes(locale)) {
        missing.push({
          bundleId: bundle.id,
          locale,
        });
      }
    }
  }

  return missing;
}

Step 6: Put it together

Here's a complete CLI tool:

import { loadProjectFromDirectory } from "@inlang/sdk";
import fs from "node:fs";

async function main() {
  const project = await loadProjectFromDirectory({
    path: "./project.inlang",
    fs,
  });

  try {
    const errors = await project.errors.get();
    if (errors.length > 0) {
      throw new AggregateError(errors, "Could not load inlang project");
    }

    const settings = await project.settings.get();
    const bundles = await project.db.selectFrom("bundle").selectAll().execute();
    const messages = await project.db
      .selectFrom("message")
      .selectAll()
      .execute();

    const missing = [];

    for (const bundle of bundles) {
      const bundleMessages = messages.filter((m) => m.bundleId === bundle.id);
      const localesWithTranslation = bundleMessages.map((m) => m.locale);

      for (const locale of settings.locales) {
        if (!localesWithTranslation.includes(locale)) {
          missing.push({ bundleId: bundle.id, locale });
        }
      }
    }

    if (missing.length === 0) {
      console.log("All translations complete!");
    } else {
      console.log(`Found ${missing.length} missing translations:\n`);
      for (const { bundleId, locale } of missing) {
        console.log(`  - "${bundleId}" is missing locale "${locale}"`);
      }
      process.exitCode = 1;
    }
  } finally {
    await project.close();
  }
}

main();

Run it:

$ npx tsx check-translations.ts

Found 3 missing translations:

  - "greeting" is missing locale "fr"
  - "error_404" is missing locale "de"
  - "error_404" is missing locale "fr"

Building report queries

The CRUD API is powered by Kysely. For reports, it is often clearest to query the rows you need and aggregate them in JavaScript:

// Find bundles missing a specific locale
const bundles = await project.db.selectFrom("bundle").selectAll().execute();
const germanMessages = await project.db
  .selectFrom("message")
  .select("bundleId")
  .where("locale", "=", "de")
  .execute();

const translatedBundleIds = new Set(
  germanMessages.map((message) => message.bundleId),
);
const missingGerman = bundles.filter(
  (bundle) => translatedBundleIds.has(bundle.id) === false,
);

// Count translations per locale
const messages = await project.db.selectFrom("message").selectAll().execute();
const counts = Object.entries(
  messages.reduce<Record<string, number>>((result, message) => {
    result[message.locale] = (result[message.locale] ?? 0) + 1;
    return result;
  }, {}),
).map(([locale, count]) => ({ locale, count }));

Modifying translations

Tools can also create, update, and delete translations:

// Add a missing translation
const message = await project.db
  .insertInto("message")
  .values({
    id: crypto.randomUUID(),
    bundleId: "greeting",
    locale: "fr",
    selectors: [],
  })
  .returning("id")
  .executeTakeFirstOrThrow();

// Add the variant with text
await project.db
  .insertInto("variant")
  .values({
    id: crypto.randomUUID(),
    messageId: message.id,
    matches: [],
    pattern: [{ type: "text", value: "Bonjour!" }],
  })
  .execute();

Saving changes

If you're using the unpacked format, changes sync automatically when syncInterval is enabled. To explicitly save:

import { saveProjectToDirectory } from "@inlang/sdk";
import fs from "node:fs";

await saveProjectToDirectory({
  fs,
  project,
  path: "./project.inlang",
});

loadProjectFromDirectory() and saveProjectToDirectory() both accept node:fs. saveProjectToDirectory() writes translation resource files through import/export plugins. If no exporter plugin is configured, save the canonical packed file instead:

import fs from "node:fs/promises";

const blob = await project.toBlob();
await fs.writeFile("project.inlang", new Uint8Array(await blob.arrayBuffer()));

Next steps