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:
parent
69aec0f86c
commit
ed9863c037
4 changed files with 156 additions and 6 deletions
39
README.md
39
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
14
package.json
14
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@uschuster/webapp-scaffold",
|
"name": "@uschuster/webapp-scaffold",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
|
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -10,14 +10,20 @@
|
||||||
},
|
},
|
||||||
"main": "src/vite-config.ts",
|
"main": "src/vite-config.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/vite-config.ts",
|
".": "./src/vite-config.ts",
|
||||||
"./core-fetch": "./src/core-fetch.ts",
|
"./core-fetch": "./src/core-fetch.ts",
|
||||||
|
"./i18n": "./src/i18n.ts",
|
||||||
|
"./i18n-react": "./src/i18n-react.ts",
|
||||||
"./orval-template": "./templates/orval.config.template.ts"
|
"./orval-template": "./templates/orval.config.template.ts"
|
||||||
},
|
},
|
||||||
"files": ["bin/", "src/", "templates/", "README.md", "LICENSE"],
|
"files": ["bin/", "src/", "templates/", "README.md", "LICENSE"],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vite": "^6.0.0",
|
"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": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
17
src/i18n-react.ts
Normal file
17
src/i18n-react.ts
Normal 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
92
src/i18n.ts
Normal 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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue