No description
  • TypeScript 99.2%
  • JavaScript 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Andrés Correa Casablanca 23dc8ac0d3
feat(extract): migrate translation files to the current format
Signed-off-by: Andres Correa Casablanca <andreu@ctrlback.coop>
2026-10-06 21:35:08 +02:00
.hooks chore: init repository 2026-06-13 17:34:13 +02:00
examples feat!(json-load): read translations in legacy format 2026-10-06 19:28:23 +02:00
packages feat(extract): migrate translation files to the current format 2026-10-06 21:35:08 +02:00
patches fix(extract-astro): map synthesized text to where it was inserted 2026-10-06 10:55:35 +02:00
tools/mutation-report-guard fix(extract-astro): map synthesized text to where it was inserted 2026-10-06 10:55:35 +02:00
.gitignore fix(extract-astro): map synthesized text to where it was inserted 2026-10-06 10:55:35 +02:00
.prototools chore: more dep upgrades 2026-10-06 01:22:52 +02:00
CONTRIBUTING.md fix(extract-astro): map synthesized text to where it was inserted 2026-10-06 10:55:35 +02:00
LICENSE.md chore: init repository 2026-06-13 17:34:13 +02:00
package.json chore: init repository 2026-06-13 17:34:13 +02:00
pnpm-lock.yaml fix!(extract-astro): convert with astro2tsx 2026-10-06 15:36:00 +02:00
pnpm-workspace.yaml fix(extract-astro): map synthesized text to where it was inserted 2026-10-06 10:55:35 +02:00
README.md feat!: keep a translation's review state when its message leaves 2026-10-06 19:19:20 +02:00

@ctrlback/i18n

A set of lightweight FLOSS internationalization libraries for TypeScript, built on Unicode MessageFormat 2.0 (MF2).

Messages are identified by their source string (gettext style): you call tr with the untranslated MF2 source and get back its translation, falling back to the source when none exists.

The toolkit is split into small, focused packages so you only depend on what you use, and the runtime core is ESM-only and runtime-agnostic (browser, Node, Deno, workers).

Packages

Package Runtime Purpose
@ctrlback/i18n-mf2 universal Typed, Result-based wrapper over the messageformat v4 MF2 engine.
@ctrlback/i18n-core universal The constructible tr() factory, the catalog model, and the template/translation operations.
@ctrlback/i18n-json-load universal + Node Parse and validate catalog JSON (string or file) into typed documents.
@ctrlback/i18n-json-persist universal + Node Serialize catalog documents to deterministic JSON (string or file).
@ctrlback/i18n-extract Node CLI and library to extract tr(...) strings from sources and create, sync, and measure translations.

Dependency graph:

      messageformat v4
             |
         i18n-mf2
             |
         i18n-core
             |
   +---------+----------+
   |                    |
i18n-json-load   i18n-json-persist
    \                   /
     \                 /
        i18n-extract (uses oxc-parser)

How it fits together

On disk, catalogs mirror gettext's .pot / .po split:

  • a template (locale-agnostic, produced by the extractor) lists every source message;
  • a per-locale translation holds the actual messages and a status.

The typical loop:

  1. Write strings inline as MF2 in tr(...) calls.
  2. i18n-extract extract scans your sources into a template.
  3. i18n-extract sync creates or updates each locale's translation from the template (new messages become untranslated, existing ones are preserved, removed ones are marked obsolete and keep their state, so a message that comes back keeps its translation and its review).
  4. Translate, then i18n-extract coverage reports completeness.
  5. At runtime, build a catalog from a translation and construct a tr for the locale.

Quick start

Runtime (@ctrlback/i18n-core):

import { createTranslator, toRuntimeCatalog } from '@ctrlback/i18n-core'

const built = toRuntimeCatalog({
  format: 'ctrlback-i18n-translation@2',
  locale: 'ca',
  entries: [
    {
      source: 'Hello, {$name}!',
      context: null,
      message: 'Hola, {$name}!',
      status: 'translated',
      obsolete: false,
    },
  ],
})
if (!built.ok) throw new Error(built.error.kind)

const tr = createTranslator({ locale: 'ca', catalog: built.value })

tr('Hello, {$name}!', { name: 'Kat' }) // 'Hola, Kat!'
tr('Untranslated')                     // 'Untranslated' (falls back to source)

MF2 gives you pluralization, ordinals, exact-value overrides, and select/gender natively:

tr(`.input {$count :number}
.match $count
one {{You have {$count} unread message}}
*   {{You have {$count} unread messages}}`, { count: 3 })
// 'You have 3 unread messages'

Tooling (@ctrlback/i18n-extract):

i18n-extract extract  --root src --template locales/messages.template.json
i18n-extract sync     --template locales/messages.template.json --translations-dir locales --locales ca,es
i18n-extract coverage --template locales/messages.template.json --translations-dir locales --locales ca,es

Examples

  • examples/basic is a runnable, end-to-end walk-through: write strings, extract, sync, translate to Catalan, and render with plural and context selection. See its README.
  • examples/astro runs the same pipeline inside an Astro site: tr(...) calls written inline in .astro templates and a .ts API route, locale negotiation in middleware, and three locales (including right-to-left Arabic) across static and on-demand pages. See its README.

Contributing

See CONTRIBUTING.md.

License

Created by CTRLback.

Distributed under MIT License.