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",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
|
@ -10,14 +10,20 @@
|
|||
},
|
||||
"main": "src/vite-config.ts",
|
||||
"exports": {
|
||||
".": "./src/vite-config.ts",
|
||||
"./core-fetch": "./src/core-fetch.ts",
|
||||
".": "./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
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