@broberg/i18n

中文文档
0.4.1发布于 2026/10/24MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 2818

Headless locale engine: a framework-free createI18n() with dot-path t() (fallback chain + {var} interpolation), a bilingual() helper for {da,en} content objects, localStorage persistence + navigator.language detection, an html[lang] sync, a pub/sub with n

原始使用文档
文档目录8 节
  1. Usage
  2. What it gets right
  3. Which detection default do you want?
  4. On the server (Next server components, Hono, Bun) — 0.2.0
  5. Plurals — 0.2.0
  6. Missing translations, and filling them with an LLM — 0.3.0
  7. API
  8. License

@broberg/i18n

A headless locale engine — the same ~120-line i18n core that trail, sanneandersen and xrt81 each hand-rolled, extracted once. Detect the locale, persist it, resolve a dot-path key with a fallback chain + {var} interpolation, and a bilingual() helper for {da,en} content objects. Framework-free, SSR-safe, and pnpm-hoist-safe. The LanguageSwitcher widget stays copy-owned per stack.

npm i @broberg/i18n

Usage

import { createI18n } from "@broberg/i18n";

const i18n = createI18n({
  locales: ["da", "en"],                // first = default
  fallbackLocale: "en",                 // the complete base dict
  messages: {
    da: { greeting: "Hej", nav: { home: "Hjem" } },
    en: { greeting: "Hello", nav: { home: "Home" }, welcome: "Hi {name}" },
  },
  storageKey: "acme-locale",
});

i18n.getLocale();                        // "da" (stored > navigator.language > default)
i18n.t("nav.home");                      // "Hjem"
i18n.t("welcome", { name: "Sanne" });    // "Hi Sanne"  (falls back to en, interpolates)
i18n.setLocale("en");                    // persists, syncs <html lang>, notifies subscribers
i18n.bilingual({ da: "Goddag", en: "Good day" });   // active-locale string, plain-string tolerant

const off = i18n.subscribe((locale) => rerender());  // pub/sub, no context provider

What it gets right

  • Fallback chain — t(key) resolves the active locale, then the fallback locale, then returns the raw key (never a blank string).
  • {var} interpolation — t("welcome", { name }) fills placeholders.
  • bilingual() — picks the active-locale field from a {da,en} object and tolerates a plain string, so LLM-generated bilingual content and legacy plain strings both work.
  • Detection — stored locale → navigator.language exact-match → navigator.language prefix-match → default, all SSR-guarded. Read the next section before you accept the default here — it decides what language your product speaks.
  • pnpm-hoist safe — a globalThis-keyed registry means two bundle copies with the same storageKey share one instance, so the active locale can never desync.

Which detection default do you want?

detect is true unless you set it to false. The consequence, stated plainly: without detect: false, your product's language is decided by a browser setting your user never touched.

That is not the same sentence as "we detect the locale", and the difference is the whole point. Nothing breaks, nothing errors, no test goes red — the engine does exactly what it was configured to do, and the configuration was our default.

What it looks like when it bites (a real one, fd-sundhed, August 2026): a Danish-first product, locales: ["da", "en"], no stored choice yet. A municipal leader opens her first page mid-demo on a laptop with an en-US browser and gets the entire leader surface in English — "For approval", "Awaiting your decision". She had never chosen English. Nobody had. It took that team five separate symptom-fixes across several weeks before anyone asked whether they wanted the behaviour at all.

The opt-out is one line:

const i18n = createI18n({
  locales: ["da", "en"],
  fallbackLocale: "en",
  messages,
  detect: false,          // ← the product decides the language, not the browser
});

Pick deliberately:

Your productSet
One primary language, an explicit fallbackLocale, users who expect your language on first visitdetect: false — the common case
A genuinely multilingual audience with no single primary language, where meeting the user in their browser's language is the pointdetect: true (today's default)

Either way, an explicit setLocale() from a language switcher always wins and is persisted — detection only ever decides the first render, before the user has chosen.

On the server (Next server components, Hono, Bun) — 0.2.0

Do not use the shared instance (createI18n() → t()/getLocale()) on the server. It is one locale per process; with requests in different languages running at the same time, one user's language ends up on another user's page. Use one of these instead — neither touches globalThis:

import { translate, createI18n } from "@broberg/i18n";

// A pure function: pass the request's locale every call.
translate(messages, locale, "docs.count", { count: 3 }, { fallbackLocale: "da" });

// Or a private instance per request, same API as in the browser.
const i18n = createI18n({ locales: ["da", "en"], messages, locale: requestLocale, singleton: false });
i18n.t("hello", { name });

Plurals — 0.2.0

A value can be a plural entry; the form is chosen with Intl.PluralRules for the locale, from count. Missing form → other. Works in t() and translate().

const messages = {
  da: { docs: { one: "{count} dokument", other: "{count} dokumenter" } },
  pl: { docs: { one: "{count} dokument", few: "{count} dokumenty", many: "{count} dokumentów", other: "{count} dokumentu" } },
};
translate(messages, "pl", "docs", { count: 5 }); // "5 dokumentów"

Categories: zero, one, two, few, many, other (CLDR). An object is read as a plural entry only when it has a string other and no other keys.

Missing translations, and filling them with an LLM — 0.3.0

import { missingKeys } from "@broberg/i18n";
// A CI gate: every key in Danish exists in English.
expect(missingKeys(da, en)).toEqual([]);

missingKeys(base, target) lists every dot-path in base the target lacks (a plural entry counts as one key, missing until it has other). It pulls in nothing else.

@broberg/i18n/llm fills only those keys:

import { createAI } from "@broberg/ai-sdk";
import { fillMissing } from "@broberg/i18n/llm";
const { result, filled, rejected } = await fillMissing({ base: da, target: en, from: "da", to: "en", ai: createAI() });
  • Existing keys are never touched — that is how a human correction survives the next run. The input is not mutated; result is a new object.
  • Placeholders byte for byte. A translation whose {var} set differs from the source (renamed, dropped or added) is rejected for that key, reported in rejected, and not written.
  • EU route, no fallback: every call is override: { provider: "mistral", model: "mistral-large-2512" }. UI text is not personal data, but the house rule is EU, and a fallback is a route that can leave it.
  • Plural entries: each source form is translated; the target falls back to other for categories only its own language has.

CLI (needs @broberg/ai-sdk installed in your project):

npx i18n-fill locales/da.json locales/en.json --from da --to en          # dry run
npx i18n-fill locales/da.json locales/en.json --from da --to en --write  # save

Exit code 1 when any key was rejected, so it can gate CI.

API

createI18n({ locales, defaultLocale?, fallbackLocale?, messages?, storageKey?, storage?, detect? }): I18n
// I18n: getLocale · setLocale · subscribe · t(key, vars?) · bilingual(text) · detectInitial · locales

Dictionaries stay in your repo (bundling them into the package would couple every consumer's copy) — the package ships only the engine. React (Next URL-prefix switch) and Preact (pub/sub) LanguageSwitcher adapters build on this core.

License

MIT · part of the @broberg/* shared inventory.

版本与发布记录

@broberg/i18n 共发布 5 个版本,首次发布于 2026/07/12;过去 12 个月发布了 5 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.4.1latest2026/10/2415 天前
0.3.12026/10/2217 天前
0.3.02026/10/2217 天前
0.2.02026/10/2217 天前
0.1.02026/07/123 个月前

关于 @broberg/i18n 的常见问题

@broberg/i18n 是什么?

Headless locale engine: a framework-free createI18n() with dot-path t() (fallback chain + {var} interpolation), a bilingual() helper for {da,en} content objects, localStorage persistence + navigator.language detection, an html[lang] sync, a pub/sub with n

如何安装 @broberg/i18n?

在项目目录运行 npm install @broberg/i18n,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 i18n-fill,可以用 npx i18n-fill 直接运行。

@broberg/i18n 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@broberg/i18n 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@broberg/i18n 可以商用吗?

@broberg/i18n 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@broberg/i18n 的下载量有多少?

根据 npm 官方下载统计,@broberg/i18n 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,818 次,日均约 94 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@broberg/i18n 还在维护吗?

最新版本 0.4.1 发布于 2026/10/24(15 天前);至今共发布 5 个版本,其中过去 12 个月发布了 5 个;首次发布于 2026/07/12。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。