Colocated i18n Library β Design Spec
Context
The goal is a small, opinionated i18n convention and runtime container for this toolkit β not a full i18n engine. The hard i18n work (ICU formatting, pluralization, number/date formatting) stays deferred to whatever library the consuming app already uses; this package only provides:
- A colocation convention:
a.ts has a sibling a.i18n/ folder containing one JSON file per locale (a.en.json, a.de.json, a.de-AT.json, β¦).
- A tiny runtime container that loads the right locale file for the active locale, with BCP-47 best-fit fallback, and returns a typed accessor function.
- No global translation-key registry β every module's keys are local to that module, derived from its own JSON files.
- First-class TypeScript support: translation keys are checked at compile time, and (best-effort) whether a key requires ICU placeholder arguments.
This was arrived at through an extended design discussion that ruled out several simpler-looking approaches for concrete technical reasons (see "Rejected approaches" below) β worth reading before proposing alternatives, since the reasons are non-obvious.
Recommended architecture
Runtime API
const i18n = await getI18n(); // auto-detects caller via CallSite trick (Node/Deno/Bun)
const i18n = await getI18n(import.meta); // explicit fallback, spec-guaranteed, always available
i18n("welcome"); // TS-checked key
i18n("greeting", { name: "Jo" }); // TS requires arg if the key has an ICU placeholder
Scope for this increment: native ESM runtimes only (Node, Deno, Bun), unbundled. Browser/bundler support (Vite plugin, generic file watcher for other bundlers) is an explicitly separate, later increment β the generated-file shape this produces is what a future bundler-side generator would also need to produce, so this isn't throwaway work.
Caller-location detection
- Primary:
Error.prepareStackTrace / CallSite API. Override Error.prepareStackTrace temporarily, capture a new Error() inside getI18n, walk to the caller's frame, and read callSite.getFileName(). Prefer this over regex-parsing .stack strings β the CallSite API is structured and is what callsites/error-stack-parser/Jest/Vitest use internally for the same purpose.
- Fallback: explicit
import.meta argument. Always available, spec-guaranteed, no engine-specific behavior. Use this when the automatic trick can't be trusted.
- Per-engine caveats to test directly, not assume:
- Node: native V8, most reliable target.
- Deno: implements
Error.prepareStackTrace (compatibility fixes landed as late as Deno 1.46 β pin a minimum version). Filesystem writes require explicit --allow-read/--allow-write permissions β document this as a real user-facing requirement, not just a compatibility detail.
- Bun: runs JavaScriptCore, not V8, but deliberately emulates the V8 stack-trace API for Node-compat. Has had real bugs (negative line/column numbers breaking
source-map-support, prepareStackTrace undefined by default) β pin a minimum Bun version and test directly.
- TS execution loaders (
tsx, ts-node, vite-node, Node's native type-stripping): verify the CallSite's file name resolves to the real .ts source path (via source maps) rather than a transformed/in-memory location, for each loader you want to support.
- Fail loudly, not silently, if automatic detection can't be trusted in the current environment β throw an actionable error directing the caller to pass
import.meta explicitly, rather than generating from a wrong path.
- Never run the filesystem-writing half in production. Gate strictly behind a dev-only check (e.g.
NODE_ENV !== "production"). Production filesystems are often read-only (serverless cold starts, some containers) β attempting a write there is a reliability bug, not just unnecessary work. Production should always consume already-generated files.
Shared codegen core
One plain function, e.g. generate(sourceDir: string, basename: string): void, that:
- Reads
<basename>.i18n/*.json.
- Designates one locale as canonical/source-of-truth (e.g.
en) β other locales are trusted at runtime, not statically checked against it.
- Computes the exact key set and (best-effort) which keys require ICU placeholder arguments, by parsing real JSON content β not via type-level template-literal-type tricks, which can't express real ICU (
plural/select/nesting) anyway.
- Emits a companion module (e.g.
<basename>.i18n.gen.ts) containing the typed accessor and the runtime BCP-47 fallback-chain wiring, built from the actual list of locale files that exist on disk (so runtime selection is a direct lookup, not a trial-and-error import-and-catch cascade).
All three trigger mechanisms (the CallSite-driven runtime path now, plus a future filesystem watcher and Vite plugin) should call into this one core β do not let generation logic diverge across triggers.
BCP-47 best-fit fallback
Given a requested locale (de-AT) and the known set of locale files for a module, walk: exact tag β strip region β strip script β configured default. E.g. de-AT β de-AT (miss) β de (hit). This is the same lookup algorithm CLDR/Intl.LocaleMatcher use.
Zero-dependency constraint
Do not bundle an ICU formatter. Accept one via injection (the app's existing i18n library formats the actual string; this package only resolves which file/locale to load and checks which keys exist).
Rejected / deferred approaches (why, briefly)
getI18n(import.meta) doing bundler-time file discovery internally β bundlers (Rollup/webpack) need a literal import specifier or a literal import.meta.glob/context call at the consumer's own call site to statically analyze; wrapping either inside a library function breaks static analysis because the literal call site ends up inside the library's file, not the caller's.
- Hand-authored per-locale loader maps β explicitly rejected by the user; conflicts with "runtime should select the best-fit chunk," not just load whatever the caller enumerated.
- Full ICU type-checking via TypeScript template-literal types β can express simple
{placeholder} holes but not real ICU (plural/select/nesting); the codegen approach handles this better since it parses real JSON with real code instead of type-level string parsing.
- Vite virtual modules / bundler-specific plugins as the only mechanism β bundler-specific, doesn't help native Node/Deno/Bun execution at all. Deferred to a later increment, layered on top of the same generated-file shape.
- A
vue-tsc-style custom tsc CompilerHost wrapper (to avoid any file ever touching disk, including for type-checking) β real, proven pattern, but the heaviest option; only worth it if writing real (gitignored) generated files to disk turns out to be an actual dealbreaker in practice.
Verification
Since this is a from-scratch design increment with no existing code to run yet, verification for this phase is about validating the two riskiest technical assumptions before committing further design/implementation time:
- Spike:
Error.prepareStackTrace/CallSite behavior across Node (current LTS), Deno (latest), and Bun (latest) β confirm caller file paths resolve correctly, including when run through at least one TS loader (e.g. tsx).
- Spike: BCP-47 fallback chain β small standalone test of the lookup algorithm against a handful of locale-tag scenarios (exact match, region-fallback, script-fallback, default-fallback).
Once implemented as an actual package, it should follow this repo's existing utility conventions (src/<name>/ folder, co-located .test.ts, own README.md, exports registered in package.json/jsr.json/.size-limit.json β see src/sleep/ and src/concurrency/once/ as reference examples found during this session's exploration).
Next step
Write up this spec as a GitHub issue (title + body drawn from this document) once the plan is approved.
Colocated i18n Library β Design Spec
Context
The goal is a small, opinionated i18n convention and runtime container for this toolkit β not a full i18n engine. The hard i18n work (ICU formatting, pluralization, number/date formatting) stays deferred to whatever library the consuming app already uses; this package only provides:
a.tshas a siblinga.i18n/folder containing one JSON file per locale (a.en.json,a.de.json,a.de-AT.json, β¦).This was arrived at through an extended design discussion that ruled out several simpler-looking approaches for concrete technical reasons (see "Rejected approaches" below) β worth reading before proposing alternatives, since the reasons are non-obvious.
Recommended architecture
Runtime API
Scope for this increment: native ESM runtimes only (Node, Deno, Bun), unbundled. Browser/bundler support (Vite plugin, generic file watcher for other bundlers) is an explicitly separate, later increment β the generated-file shape this produces is what a future bundler-side generator would also need to produce, so this isn't throwaway work.
Caller-location detection
Error.prepareStackTrace/CallSiteAPI. OverrideError.prepareStackTracetemporarily, capture anew Error()insidegetI18n, walk to the caller's frame, and readcallSite.getFileName(). Prefer this over regex-parsing.stackstrings β theCallSiteAPI is structured and is whatcallsites/error-stack-parser/Jest/Vitest use internally for the same purpose.import.metaargument. Always available, spec-guaranteed, no engine-specific behavior. Use this when the automatic trick can't be trusted.Error.prepareStackTrace(compatibility fixes landed as late as Deno 1.46 β pin a minimum version). Filesystem writes require explicit--allow-read/--allow-writepermissions β document this as a real user-facing requirement, not just a compatibility detail.source-map-support,prepareStackTraceundefined by default) β pin a minimum Bun version and test directly.tsx,ts-node,vite-node, Node's native type-stripping): verify theCallSite's file name resolves to the real.tssource path (via source maps) rather than a transformed/in-memory location, for each loader you want to support.import.metaexplicitly, rather than generating from a wrong path.NODE_ENV !== "production"). Production filesystems are often read-only (serverless cold starts, some containers) β attempting a write there is a reliability bug, not just unnecessary work. Production should always consume already-generated files.Shared codegen core
One plain function, e.g.
generate(sourceDir: string, basename: string): void, that:<basename>.i18n/*.json.en) β other locales are trusted at runtime, not statically checked against it.plural/select/nesting) anyway.<basename>.i18n.gen.ts) containing the typed accessor and the runtime BCP-47 fallback-chain wiring, built from the actual list of locale files that exist on disk (so runtime selection is a direct lookup, not a trial-and-error import-and-catch cascade).All three trigger mechanisms (the
CallSite-driven runtime path now, plus a future filesystem watcher and Vite plugin) should call into this one core β do not let generation logic diverge across triggers.BCP-47 best-fit fallback
Given a requested locale (
de-AT) and the known set of locale files for a module, walk: exact tag β strip region β strip script β configured default. E.g.de-ATβde-AT(miss) βde(hit). This is the same lookup algorithm CLDR/Intl.LocaleMatcheruse.Zero-dependency constraint
Do not bundle an ICU formatter. Accept one via injection (the app's existing i18n library formats the actual string; this package only resolves which file/locale to load and checks which keys exist).
Rejected / deferred approaches (why, briefly)
getI18n(import.meta)doing bundler-time file discovery internally β bundlers (Rollup/webpack) need a literal import specifier or a literalimport.meta.glob/context call at the consumer's own call site to statically analyze; wrapping either inside a library function breaks static analysis because the literal call site ends up inside the library's file, not the caller's.{placeholder}holes but not real ICU (plural/select/nesting); the codegen approach handles this better since it parses real JSON with real code instead of type-level string parsing.vue-tsc-style customtscCompilerHost wrapper (to avoid any file ever touching disk, including for type-checking) β real, proven pattern, but the heaviest option; only worth it if writing real (gitignored) generated files to disk turns out to be an actual dealbreaker in practice.Verification
Since this is a from-scratch design increment with no existing code to run yet, verification for this phase is about validating the two riskiest technical assumptions before committing further design/implementation time:
Error.prepareStackTrace/CallSitebehavior across Node (current LTS), Deno (latest), and Bun (latest) β confirm caller file paths resolve correctly, including when run through at least one TS loader (e.g.tsx).Once implemented as an actual package, it should follow this repo's existing utility conventions (
src/<name>/folder, co-located.test.ts, ownREADME.md, exports registered inpackage.json/jsr.json/.size-limit.jsonβ seesrc/sleep/andsrc/concurrency/once/as reference examples found during this session's exploration).Next step
Write up this spec as a GitHub issue (title + body drawn from this document) once the plan is approved.