@krizaka/i18n
v0.2.7
Published
The i18n engine of every Krizaka app: typed message keys and placeholders from en.json, format(), plurals through @krizaka/intl, <Rich> (<b>, <a>), a React provider, and the krizaka-i18n CLI that checks catalogues and finds hard-coded strings.
Maintainers
Readme
@krizaka/i18n
The i18n engine of every Krizaka app, written once. Typed message keys and placeholders from en.json, format(),
plural sets on @krizaka/intl, <Rich> for <b> and <a>, a React provider, and the krizaka-i18n CLI that checks
the catalogues and finds text written in the code. It replaces the lib/i18n.ts and the check script each app used to
carry.
The rule
Every user-facing string lives in
messages/<locale>.json.en.jsonis the reference: its shape types the keys, every other locale has exactly the same keys, the same{placeholders}and the same markup. The code holds keys, never words.
Install
npm install @krizaka/i18nNode ≥ 22, ESM only. React (≥ 18) only for ./rich and ./react; TypeScript only for krizaka-i18n scan.
| Entry | What | React |
| :-- | :-- | :-- |
| @krizaka/i18n | createI18n, format, placeholdersOf, isPluralMessage, the types | no |
| @krizaka/i18n/rich | <Rich text href? renderLink? renderBold? slots?> | no hook — Server Components too |
| @krizaka/i18n/react | createI18nReact(i18n \| { locales, defaultLocale }) → I18nProvider (messages), useI18n | client ("use client") |
| @krizaka/i18n/check | checkMessages, findUnused, scanHardcoded, main (the CLI, programmatic) | no |
| krizaka-i18n (bin) | check · scan | no |
Bind it once in the app
// lib/i18n.ts
import { createI18n } from "@krizaka/i18n";
import en from "@/messages/en.json";
import fr from "@/messages/fr.json";
export type Locale = "en" | "fr";
export type Messages = typeof en;
// The type arguments make fr.json type-check against en.json: a missing key fails to compile.
export const i18n = createI18n<Messages, Locale>({ en, fr }, { defaultLocale: "en" });
export const { getDictionary, asLocale, t } = i18n;
export { format } from "@krizaka/i18n";i18n.locales; // ["en", "fr"]
i18n.asLocale("de"); // "en" — the default for anything unsupported
i18n.getDictionary("fr").home.title; // the catalogue, for Server Components and long content
t("auction.phase.SOLD"); // the key is typed: a typo fails to compile
i18n.translator("fr")("home.lead", { count: 3, org: "krizaka" });
format("{count} repositories", { count: 23 }); // "23 repositories"; a missing value stays "{count}"Keys are dotted paths to a string or a plural set (
MessageKey<typeof en>). Lists are content, read as a branch.Placeholders: a JSON import types messages as
string, sotaccepts any values; a catalogue writtenas constmakes them required (t("lead", {})fails whenleadis"{count} items").Plurals: a plural set is an object of CLDR categories with
other;countpicks the form through@krizaka/intl'splural(zerois the explicit "exactly 0" form, in any language):{ "bids": { "zero": "No bid yet", "one": "{count} bid", "other": "{count} bids" } }t("bids", { count: 2 })→"2 bids"; in French0and1takeone.Missing keys fall back to the default locale, then to the key itself — never an empty string.
React
// app/components/I18nProvider.tsx
"use client";
import { createI18nReact } from "@krizaka/i18n/react";
import { i18n } from "@/lib/i18n";
export const { I18nProvider, useI18n } = createI18nReact(i18n);
// <I18nProvider locale={locale} setLocale={(l) => router.push(`/${l}`)}>…</I18nProvider>
// const { locale, messages, t, format, setLocale } = useI18n();setLocale is the app's (a route, a cookie); outside a provider useI18n() reads the default locale. Server Components
skip the context and call getDictionary(locale).
Ship one language, not all of them
Bound to the engine, the client bundle carries every catalogue. Bind the React side to the locales alone and let the server pass the active catalogue — the browser then downloads one language:
// app/components/I18nProvider.tsx — no messages/*.json imported here
"use client";
import { createI18nReact } from "@krizaka/i18n/react";
import type en from "@/messages/en.json";
export const { I18nProvider, useI18n } = createI18nReact<typeof en, "en" | "fr">({ locales: ["en", "fr"], defaultLocale: "en" });
// app/[locale]/layout.tsx (server)
<I18nProvider locale={locale} messages={i18n.getDictionary(locale)}>{children}</I18nProvider>messages is required in that mode (and useI18n() throws outside a provider); over a full engine it is optional and
a key missing from it falls back to the default catalogue. createTranslator(locale, messages, fallback?) is the same
translator, exported from @krizaka/i18n.
<Rich> — <b> and <a>, nothing else
import { Rich } from "@krizaka/i18n/rich";
<Rich text={t("home.lead")} /> // "<b>Open</b> source" → <strong>Open</strong> source
<Rich text={t("legal.read")} href={["/terms", "/privacy"]} renderLink={({ href, children }) => <Link href={href!}>{children}</Link>} /><Rich text={t("auth.sent")} slots={{ email: <strong className="break-all">{email}</strong> }} /> // "{email}" → the node
<Rich text={t("home.lead")} renderBold={(children) => <strong className="font-semibold text-fg">{children}</strong>} />Messages are text, never HTML: an unknown or unbalanced tag is shown as written. Each <a> takes the next href; a
link without one renders its words only. <b> and <a> may hold each other. slots puts a node in place of a
{name} placeholder (one without a slot stays as written); renderBold styles the <b>.
The CLI
krizaka-i18n check messages # same keys as en.json, no empty string, same {placeholders}, same markup
krizaka-i18n check messages --unused app --unused lib # …and fail on keys no source reads
krizaka-i18n check messages --json # machine-readable report for CI
krizaka-i18n scan app components --allow Krizaka --skip /app/api/ # hard-coded strings in .tsx (needs typescript)check <dir>— every<locale>.jsonof<dir>against--reference(defaulten): missing and unknown keys, empty strings, placeholders and markup that differ, a plural set where the reference has a string (or the reverse), markup other than<b>/<a>. A plural set may carry the categories its language needs (Frenchmany), as long as it hasotherand the same placeholders.--unused <src>(repeatable) is a heuristic: a key is read when its path appears in a source, when a branch is read dynamically (`phase.${p}`,nav[id],"plus." + k) or taken whole (const h = t.home;,messages("legal")).scan <path>…— JSX text,placeholder/title/aria-label/alt/labelliterals, messages given toset…Error/Message/Notice/Hint/Status/Feedback/Toastsetters,label/hint/title/placeholder/description/message/text/captionin data, andalert/confirm/prompt.--allow(comma-separated, repeatable) lists brand names;--skip(regular expression on the path, repeatable) leaves files out; tests and stories are always skipped, and Next.jsmetadata/generateMetadataobjects too.i18n-ignorein a comment on the line or the line above is a deliberate exception.--summarycounts per file.- Exit codes:
0clean,1problems,2usage error.--jsonprints{ ok, reference, locales, messages, problems }(check) or{ ok, offenders }(scan) on stdout.
{ "scripts": { "lint": "eslint && krizaka-i18n check messages" } }