@blax-software/i18n
v0.2.0
Published
Per-page, all-locale translation sources merged into vue-i18n messages, plus a CLI that builds one bundle per locale and fails CI on missing keys.
Maintainers
Readme
@blax-software/i18n
Per-page, all-locale translation sources for vue-i18n / Nuxt i18n, plus a CLI that builds one message bundle per locale and fails CI when a key is missing.
The source layout it works on
i18n/
locales/en.ts export default { common: { save: 'Save' } }
locales/de.ts export default { common: { save: 'Speichern' } }
locales/pl.ts
pages/cart.ts export default { en: {...}, de: {...}, pl: {...} } satisfies TranslationFile
pages/checkout.ts
.messages/ generated by `blax-i18n build` (gitignore it)Base files hold one locale each. Page files hold every locale side by side, so a
translator sees en/de/pl of the same screen in one place. The merged result
is locales seeded first, then every page deep-merged in path order (last wins).
Install
npm install @blax-software/i18nRuntime API (@blax-software/i18n)
Framework-agnostic, no dependencies, safe in the browser.
import {
TranslationMissing, type TranslationFile,
mergeMessages, fallbackChain, createLazyLoaders,
} from '@blax-software/i18n'| Export | What it does |
| --- | --- |
| TranslationMissing | The string '[missing]'. Put it where a translation is still open; blax-i18n check reports every occurrence. |
| TranslationFile | { [locale: string]: Record<string, any> } — the page-module shape. Use satisfies TranslationFile. |
| mergeMessages(locales, pages, { packages? }) | { [locale]: messages } — seed from locales, deep-merge each page's [locale] slice in order. Inputs are not mutated. Only locales present in locales are emitted. packages (page-shaped objects) merge first, so the app overrides them. |
| fallbackChain(locales, defaultLocale = 'en') | vue-i18n fallbackLocale map: { de: ['en'], pl: ['en'], default: ['en'] }. |
| createLazyLoaders(loaders) | { loadLocaleMessages(locale), hasLocaleBundle(locale) } over static import() calls of the built bundles. |
| checkTranslations(sources, { locales, reference? }) | The check logic on already-loaded data (what the CLI runs). formatReport(report) renders it. |
Eager merge (Vite / Nuxt)
// i18n/messageloader.ts
import { mergeMessages } from '@blax-software/i18n'
const localeMods = import.meta.glob('./locales/*.ts', { eager: true, import: 'default' })
const pageMods = import.meta.glob('./pages/**/*.ts', { eager: true, import: 'default' })
const byPath = (mods: Record<string, any>) => Object.keys(mods).sort().map((p) => mods[p])
const locales = Object.fromEntries(
Object.keys(localeMods).sort().map((p) => [p.replace(/^.*\/([^/.]+)\.ts$/, '$1'), localeMods[p]]),
)
export default mergeMessages(locales, byPath(pageMods))// i18n.config.ts
import { fallbackChain } from '@blax-software/i18n'
import messages from './i18n/messageloader'
export default {
fallbackLocale: fallbackChain(['en', 'de', 'pl']),
messages,
}Lazy per-locale bundles
Run blax-i18n build (below), then load only the active locale:
// i18n/lazy-loader.ts
import { createLazyLoaders } from '@blax-software/i18n'
export const { loadLocaleMessages, hasLocaleBundle } = createLazyLoaders({
de: () => import('./.messages/de.js'),
pl: () => import('./.messages/pl.js'),
})Leave the default locale out of the map and import it eagerly, so there is always a complete fallback.
Marking open translations
import { TranslationMissing, type TranslationFile } from '@blax-software/i18n'
export default {
en: { cart: { title: 'Your cart' } },
de: { cart: { title: 'Dein Warenkorb' } },
pl: { cart: { title: TranslationMissing } },
} satisfies TranslationFileCLI (blax-i18n)
blax-i18n build [--src i18n] [--out <src>/.messages] [--locales en,de,pl] [--default en] [--packages auto|a,b]
blax-i18n check [--src i18n] [--locales en,de,pl] [--default en] [--packages auto|a,b] [--json]buildwrites<out>/<locale>.js, oneexport default {...}ESM module per locale. Sources are transpiled with esbuild, so.tsfiles need no bundler.--defaultis always emitted, on top of--localesor of every base file when--localesis omitted.--packagesmerges translations that npm packages register (see below).autotakes every dependency in the app's package.json that registers some; a comma list names them.checkwalks every key across the listed locales and reports, per locale, keys that are missing and values that still hold[missing]. Exit code 1 when anything is missing;--jsonprints the report object. Without--defaultthe reference key set is the union of all locales; with it, that locale's keys (surplus keys elsewhere are listed asextrabut do not fail the run).
{
"scripts": {
"i18n:build": "blax-i18n build --src i18n --out i18n/.messages --locales en,de,pl",
"i18n:check": "blax-i18n check --src i18n --locales en,de,pl"
}
}$ blax-i18n check --src i18n --locales en,de,pl
en: ok (312 keys)
de: ok (312 keys)
pl: 1 missing, 2 [missing] (311 keys)
pages/account.ts account.logout missing
locales/* common.cancel [missing]
pages/cart.ts cart.empty [missing]
i18n check: 3 problemsTranslation files are loaded as pure data: relative imports between them resolve,
import { TranslationMissing } from '@blax-software/i18n' resolves, any other import
yields {}.
Translations shipped by packages
A library that shows text of its own (a connection toast, a validation error) can ship
those strings instead of making every app redefine them. It points a blax-i18n field in
its package.json at a page-shaped module:
{
"name": "@blax-software/networking",
"blax-i18n": "./dist/i18n.js",
"exports": { "./i18n": { "import": "./dist/i18n.js", "require": "./dist/i18n.cjs" } }
}// src/i18n.ts in the library
export default {
en: { websocket: { connectionlost: 'Connection lost. Reconnecting…' } },
de: { websocket: { connectionlost: 'Verbindung unterbrochen. Verbinde neu…' } },
}The app opts in with --packages auto (or packages: 'auto' in the Node API). Package
strings merge first, so any base file or page can override one. check then also verifies
every package key in every checked locale, and accepts a locale the package lacks when the
app supplies it. Apps that merge at runtime import the module themselves:
import networking from '@blax-software/networking/i18n'
export default mergeMessages(locales, pages, { packages: [networking] })Keep keys under one namespace per package (websocket.*) so they cannot collide with app keys.
Node API (@blax-software/i18n/node)
readSourceTree, buildMessages, writeMessages, checkSourceTree, readPackageSources
and loadModule are what the CLI calls; use them from a build script (for example a Nuxt
hook that regenerates bundles on change). Every one of them takes packages and root.
Development
npm install
npm run build # tsup → dist/ (ESM + CJS + d.ts, bin/cli.js)
npm run typecheck
npm test # vitestLicense
MIT
