v0.3.0: i18n system (locale × tone) with fallback chain

createI18n({ bundles, defaultLocale, defaultTone, initialLocale,
initialTone, onMiss }) → { t, locale, tone, setLocale, setTone,
subscribe, getSnapshot }

Resolution order:
    <cur-locale>-<cur-tone>
  → <cur-locale>
  → <default-locale>-<default-tone>
  → <default-locale>
  → raw key (onMiss logs first occurrence)

Typed keys via keyof on the caller-supplied Bundle generic. Tone
bundles are Partial<B> so overrides only need the strings that differ
from the locale bundle — the defaultLocale bundle is the only one
expected to be complete.

React bindings isolated in src/i18n-react.ts so the core ships without
a React peer dep. SSR-safe: constructor takes initial locale/tone
rather than reading from window.

Typecheck clean under strict tsc (ES2020/DOM).

Closes fewo-webapp#416

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-21 22:12:18 +02:00
parent 69aec0f86c
commit ed9863c037
4 changed files with 156 additions and 6 deletions

View file

@ -62,6 +62,41 @@ export const coreFetch = createCoreFetch({
});
```
## Roadmap
## i18n (v0.3)
- **v0.3** — i18n system (de/en × formal/informal with fallback chain).
Two axes: **locale** (`de`, `en`, …) × **tone** (`formal`, `informal`).
Bundles are keyed by `<locale>` (fallback) and `<locale>-<tone>` (primary);
the resolver walks `<cur-locale>-<cur-tone>``<cur-locale>`
`<default-locale>-<default-tone>``<default-locale>` → the raw key
(missing keys log via an `onMiss` hook instead of breaking render).
```ts
// Define bundles — only strings that differ between tones need a
// tone-specific entry. Everything else lives in the locale bundle.
import { createI18n } from '@uschuster/webapp-scaffold/i18n';
import { useI18nStore } from '@uschuster/webapp-scaffold/i18n-react'; // React-only
const bundles = {
'de': { greeting: 'Willkommen', see_more: 'Mehr anzeigen' },
'de-informal': { greeting: 'Hi!' },
'en': { greeting: 'Welcome', see_more: 'See more' },
} as const;
export const i18n = createI18n<typeof bundles['de']>({
bundles,
defaultLocale: 'de',
defaultTone: 'formal',
onMiss: (key, loc, tone) =>
console.warn(`[i18n] missing ${String(key)} @ ${loc}-${tone}`),
});
// React — components re-render on setLocale / setTone:
export const useI18n = () => useI18nStore(i18n);
// In a component:
const { t, setTone } = useI18n();
return <button onClick={() => setTone('informal')}>{t('greeting')}</button>;
```
SSR-safe: pass `initialLocale` / `initialTone` from the server render.
Tree-shakeable: only the bundles you import ship in the bundle.

View file

@ -1,6 +1,6 @@
{
"name": "@uschuster/webapp-scaffold",
"version": "0.2.0",
"version": "0.3.0",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module",
"bin": {
@ -12,12 +12,18 @@
"exports": {
".": "./src/vite-config.ts",
"./core-fetch": "./src/core-fetch.ts",
"./i18n": "./src/i18n.ts",
"./i18n-react": "./src/i18n-react.ts",
"./orval-template": "./templates/orval.config.template.ts"
},
"files": ["bin/", "src/", "templates/", "README.md", "LICENSE"],
"peerDependencies": {
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0"
"@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0"
},
"peerDependenciesMeta": {
"react": { "optional": true }
},
"repository": {
"type": "git",

17
src/i18n-react.ts Normal file
View file

@ -0,0 +1,17 @@
// React bindings for the i18n store — kept separate so `src/i18n.ts` has
// no React peer dependency. Import from `@uschuster/webapp-scaffold/i18n-react`.
import { useSyncExternalStore } from 'react';
import type { Bundle, I18n } from './i18n.js';
/**
* Subscribe a component to locale/tone changes. Returns the same `i18n`
* store you passed in `t`, `setLocale`, etc. are stable across renders.
*
* const i18n = createI18n({ ... });
* export const useI18n = () => useI18nStore(i18n);
*/
export function useI18nStore<B extends Bundle>(i18n: I18n<B>) {
useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot);
return i18n;
}

92
src/i18n.ts Normal file
View file

@ -0,0 +1,92 @@
// i18n for webapp-template-derived apps.
//
// Axes: locale ('de' | 'en') × tone ('formal' | 'informal').
// A translation bundle is a map of keys → strings. Consumers provide
// bundles keyed by `locale` (fallback) and `locale-tone` (primary),
// e.g. { 'de': ..., 'de-informal': ..., 'en': ... }. The resolver walks:
//
// <cur-locale>-<cur-tone>
// → <cur-locale>
// → <default-locale>-<default-tone>
// → <default-locale>
// → raw key (so missing keys log instead of breaking rendering)
//
// Tree-shakeable: consumers import only the bundles they ship. SSR-safe:
// the store starts from whatever locale/tone the server passes, no
// window access at construction time. React bits live in a separate
// entry — `@uschuster/webapp-scaffold/i18n-react` — so this file has no
// React peer dependency.
export type Locale = string; // e.g. 'de', 'en'
export type Tone = 'formal' | 'informal';
export type Bundle = Record<string, string>;
/**
* Partial bundles per `<locale>` or `<locale>-<tone>` key. Only the
* defaultLocale bundle is required to be complete at runtime others
* fall through to it. The type is Partial so tone overrides only need
* the strings that actually differ from the tone-less locale bundle.
*/
export type I18nBundles<B extends Bundle> = Record<string, Partial<B>>;
export interface CreateI18nOptions<B extends Bundle> {
bundles: I18nBundles<B>;
defaultLocale: Locale;
defaultTone?: Tone;
initialLocale?: Locale;
initialTone?: Tone;
/** Called once for each first-time miss, to surface gaps during dev. */
onMiss?: (key: keyof B, locale: Locale, tone: Tone) => void;
}
export interface I18n<B extends Bundle> {
t: (key: keyof B) => string;
locale: () => Locale;
tone: () => Tone;
setLocale: (l: Locale) => void;
setTone: (t: Tone) => void;
subscribe: (fn: () => void) => () => void;
getSnapshot: () => string;
}
export function createI18n<B extends Bundle>(opts: CreateI18nOptions<B>): I18n<B> {
const defTone: Tone = opts.defaultTone ?? 'formal';
let locale: Locale = opts.initialLocale ?? opts.defaultLocale;
let tone: Tone = opts.initialTone ?? defTone;
const missed = new Set<string>();
const listeners = new Set<() => void>();
const notify = () => listeners.forEach(fn => fn());
const chain = (): string[] => [
`${locale}-${tone}`,
locale,
`${opts.defaultLocale}-${defTone}`,
opts.defaultLocale,
];
function t(key: keyof B): string {
for (const b of chain()) {
const bundle = opts.bundles[b];
if (bundle && (key as string) in bundle) {
return (bundle as B)[key as string];
}
}
const miss = `${String(key)}@${locale}-${tone}`;
if (!missed.has(miss)) {
missed.add(miss);
opts.onMiss?.(key, locale, tone);
}
return String(key);
}
return {
t,
locale: () => locale,
tone: () => tone,
setLocale: (l) => { if (l !== locale) { locale = l; notify(); } },
setTone: (t) => { if (t !== tone) { tone = t; notify(); } },
subscribe: (fn) => { listeners.add(fn); return () => { listeners.delete(fn); }; },
getSnapshot: () => `${locale}-${tone}`,
};
}