diff --git a/README.md b/README.md index aaf34c3..c6e165a 100644 --- a/README.md +++ b/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 `` (fallback) and `-` (primary); +the resolver walks `-` → `` → +`-` → `` → 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({ + 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 ; +``` + +SSR-safe: pass `initialLocale` / `initialTone` from the server render. +Tree-shakeable: only the bundles you import ship in the bundle. diff --git a/package.json b/package.json index ad1bc13..5db8dc8 100644 --- a/package.json +++ b/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", diff --git a/src/i18n-react.ts b/src/i18n-react.ts new file mode 100644 index 0000000..bda74b4 --- /dev/null +++ b/src/i18n-react.ts @@ -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(i18n: I18n) { + useSyncExternalStore(i18n.subscribe, i18n.getSnapshot, i18n.getSnapshot); + return i18n; +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..1af96d0 --- /dev/null +++ b/src/i18n.ts @@ -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: +// +// - +// → +// → - +// → +// → 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; + +/** + * Partial bundles per `` or `-` 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 = Record>; + +export interface CreateI18nOptions { + bundles: I18nBundles; + 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 { + 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(opts: CreateI18nOptions): I18n { + const defTone: Tone = opts.defaultTone ?? 'formal'; + let locale: Locale = opts.initialLocale ?? opts.defaultLocale; + let tone: Tone = opts.initialTone ?? defTone; + const missed = new Set(); + 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}`, + }; +}