@unbrained/pm-web 2026.7.17 → 2026.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // i18n — locale resolution, catalog lookup, DOM binding
3
+ // ═══════════════════════════════════════════════════════════════
4
+ //
5
+ // Flat JSON catalogs (one file per locale) live alongside this module in
6
+ // `public/src/i18n/{en,de}.json`. They are fetched at runtime so the browser
7
+ // bundle stays free of new build-time dependencies and the same JSON files
8
+ // can be audited by tests directly. The English catalog is the source of
9
+ // truth: every key must exist in `en.json`; other locales fall back to it.
10
+ //
11
+ // Scope: this module covers *package-owned* SPA UI strings (auth screen, nav,
12
+ // banners, settings incl. registration acknowledgement, consent UI). Server
13
+ // API error strings stay English on the wire and are translated at the
14
+ // display layer via `translateError()` (fallback: raw message).
15
+ //
16
+ // Legal pages (`public/*.html`) are operator-overlay templates
17
+ // (`PM_WEB_LEGAL_DIR`); their localization is an operator concern and is NOT
18
+ // handled here. The language selector deliberately does not promise
19
+ // translated legal pages — see `settings.languageHint` and the German
20
+ // disclaimer string `legal.disclaimer`.
21
+ /** localStorage key persisting the user's locale choice. */
22
+ export const LOCALE_STORAGE_KEY = 'pmLocale';
23
+ /** Locales shipped by this package. The first entry is the default/fallback. */
24
+ export const SUPPORTED_LOCALES = ['en', 'de', 'es', 'zh'];
25
+ const DEFAULT_LOCALE = 'en';
26
+ /**
27
+ * Resolve the active locale without touching the DOM, so it is unit-testable
28
+ * in Node. Resolution order:
29
+ * 1. `opts.storage[LOCALE_STORAGE_KEY]` (explicit user choice)
30
+ * 2. `opts.navLang` prefix match against SUPPORTED_LOCALES (e.g. `de-DE` → `de`)
31
+ * 3. default locale (`en`)
32
+ *
33
+ * `opts.storage` may be `null` to skip the localStorage step; `opts.navLang`
34
+ * may be omitted to skip navigator negotiation. When called with no
35
+ * arguments the real browser globals (`localStorage`, `navigator.language`)
36
+ * are used — but only inside this function, never at module top level, so
37
+ * importing this module in Node has no side effects.
38
+ */
39
+ export function resolveLocale(opts) {
40
+ // A property that is present (even if `null`) means the caller explicitly
41
+ // wants that value: `null` skips the step. A property that is absent falls
42
+ // back to the real browser global, so the no-argument browser path still
43
+ // works. This keeps the function deterministic and unit-testable in Node.
44
+ const hasStorage = opts != null && Object.prototype.hasOwnProperty.call(opts, 'storage');
45
+ const storage = hasStorage ? opts.storage : safeLocalStorage();
46
+ if (storage) {
47
+ let stored = null;
48
+ try {
49
+ stored = storage.getItem(LOCALE_STORAGE_KEY);
50
+ }
51
+ catch { /* privacy mode */ }
52
+ if (stored && isSupported(stored))
53
+ return stored;
54
+ }
55
+ const hasNav = opts != null && Object.prototype.hasOwnProperty.call(opts, 'navLang');
56
+ const navLang = hasNav ? opts.navLang : safeNavLang();
57
+ if (navLang) {
58
+ const prefix = navLang.toLowerCase().split('-')[0];
59
+ if (prefix && isSupported(prefix))
60
+ return prefix;
61
+ }
62
+ return DEFAULT_LOCALE;
63
+ }
64
+ function isSupported(value) {
65
+ return SUPPORTED_LOCALES.includes(value);
66
+ }
67
+ /** localStorage access guarded so Node imports/tests never crash.
68
+ * Resolving the storage reference itself can throw a `SecurityError` in
69
+ * privacy/incognito modes that disable storage, so the lookup is wrapped too. */
70
+ function safeLocalStorage() {
71
+ try {
72
+ const g = globalThis;
73
+ return g.localStorage ?? null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /** navigator.language access guarded so Node imports/tests never crash. */
80
+ function safeNavLang() {
81
+ const n = globalThis.navigator;
82
+ return n?.language;
83
+ }
84
+ /**
85
+ * Pure catalog lookup with fallback. Used by `t()` and directly by tests.
86
+ *
87
+ * @param catalog the active locale catalog (may be partial)
88
+ * @param fallback the fallback catalog (English — must contain every key)
89
+ * @param key dotted string id
90
+ * @param params optional `{name: value}` substitutions for `{name}` tokens
91
+ * @returns the translated string, or the key itself if missing from both
92
+ */
93
+ export function translate(catalog, fallback, key, params) {
94
+ let raw = catalog[key] ?? fallback[key] ?? key;
95
+ if (params) {
96
+ for (const [name, value] of Object.entries(params)) {
97
+ raw = raw.replaceAll(`{${name}}`, String(value));
98
+ }
99
+ }
100
+ return raw;
101
+ }
102
+ // ── Runtime state (populated by initI18n / setLocale) ───────────────
103
+ let currentLocale = DEFAULT_LOCALE;
104
+ let activeCatalog = {};
105
+ let enCatalog = {};
106
+ let initialized = false;
107
+ /**
108
+ * Monotonic request id for setLocale. Each invocation increments this; an
109
+ * in-flight request records its id and, after its awaited catalog fetch,
110
+ * commits state only if it is still the latest. This discards stale catalogs
111
+ * when language changes overlap (e.g. rapid de → en) so a slower earlier
112
+ * fetch can never overwrite a newer selection.
113
+ */
114
+ let localeReqId = 0;
115
+ /** Fetch a locale catalog JSON. Best-effort: returns {} on any failure. */
116
+ async function fetchCatalog(locale) {
117
+ try {
118
+ const res = await fetch(`/src/i18n/${locale}.json`, { credentials: 'same-origin' });
119
+ if (!res.ok)
120
+ return {};
121
+ const data = (await res.json());
122
+ return data && typeof data === 'object' ? data : {};
123
+ }
124
+ catch {
125
+ return {};
126
+ }
127
+ }
128
+ /** Apply the resolved locale to `<html lang>` so AT/browsers report it. */
129
+ function syncHtmlLang(locale) {
130
+ const el = globalThis.document?.documentElement;
131
+ if (el)
132
+ el.lang = locale;
133
+ }
134
+ /**
135
+ * Initialize i18n: resolve the locale, load the English fallback + the active
136
+ * catalog, set `<html lang>`, and apply `data-i18n` bindings in the document.
137
+ * Safe to call once at boot; subsequent calls re-apply translations.
138
+ */
139
+ export async function initI18n() {
140
+ if (!initialized) {
141
+ currentLocale = resolveLocale();
142
+ }
143
+ if (!Object.keys(enCatalog).length) {
144
+ enCatalog = await fetchCatalog('en');
145
+ }
146
+ if (currentLocale === 'en') {
147
+ activeCatalog = enCatalog;
148
+ }
149
+ else {
150
+ activeCatalog = await fetchCatalog(currentLocale);
151
+ }
152
+ initialized = true;
153
+ syncHtmlLang(currentLocale);
154
+ applyTranslations();
155
+ }
156
+ /** Current active locale. */
157
+ export function getLocale() {
158
+ return currentLocale;
159
+ }
160
+ /**
161
+ * Translate a key using the active locale with English fallback. Requires
162
+ * `initI18n()` to have completed; before that, returns the English value (or
163
+ * the key if English is also missing).
164
+ */
165
+ export function t(key, params) {
166
+ return translate(activeCatalog, enCatalog, key, params);
167
+ }
168
+ /**
169
+ * Persist a new locale choice, load its catalog, update `<html lang>`, and
170
+ * re-apply `data-i18n` bindings across the document. Falls back to `en` for
171
+ * unsupported values.
172
+ */
173
+ export async function setLocale(locale) {
174
+ const next = isSupported(locale) ? locale : DEFAULT_LOCALE;
175
+ const storage = safeLocalStorage();
176
+ if (storage) {
177
+ try {
178
+ storage.setItem(LOCALE_STORAGE_KEY, next);
179
+ }
180
+ catch { /* privacy mode */ }
181
+ }
182
+ currentLocale = next;
183
+ const myReqId = ++localeReqId;
184
+ if (!Object.keys(enCatalog).length) {
185
+ const en = await fetchCatalog('en');
186
+ // A newer setLocale superseded this one: do not assign enCatalog.
187
+ if (myReqId !== localeReqId)
188
+ return;
189
+ enCatalog = en;
190
+ }
191
+ const fetched = next === 'en' ? enCatalog : await fetchCatalog(next);
192
+ // Stale request: discard the fetched catalog so it can never overwrite a
193
+ // newer selection (e.g. a slow German fetch finishing after en was chosen).
194
+ if (myReqId !== localeReqId)
195
+ return;
196
+ activeCatalog = fetched;
197
+ initialized = true;
198
+ syncHtmlLang(next);
199
+ applyTranslations();
200
+ }
201
+ /**
202
+ * Walk the document for `data-i18n` attributes and bind translations. Three
203
+ * attribute flavors are supported:
204
+ * - `data-i18n="key"` → sets `textContent`
205
+ * - `data-i18n-html="key"` → sets `innerHTML` (for strings with markup)
206
+ * - `data-i18n-title="key"` → sets the `title` attribute
207
+ * - `data-i18n-placeholder="key"` → sets the `placeholder` attribute
208
+ * - `data-i18n-aria="key"` → sets the `aria-label` attribute
209
+ *
210
+ * `data-i18n-params` may carry a JSON object literal for parameterized keys.
211
+ */
212
+ export function applyTranslations(root) {
213
+ const doc = globalThis.document;
214
+ if (!doc)
215
+ return;
216
+ const scope = root ?? doc;
217
+ bindAttr(scope, '[data-i18n]', 'data-i18n', (el, v) => { el.textContent = v; });
218
+ bindAttr(scope, '[data-i18n-html]', 'data-i18n-html', (el, v) => { el.innerHTML = v; });
219
+ bindAttr(scope, '[data-i18n-title]', 'data-i18n-title', (el, v) => { el.setAttribute('title', v); });
220
+ bindAttr(scope, '[data-i18n-placeholder]', 'data-i18n-placeholder', (el, v) => { el.setAttribute('placeholder', v); });
221
+ bindAttr(scope, '[data-i18n-aria]', 'data-i18n-aria', (el, v) => { el.setAttribute('aria-label', v); });
222
+ }
223
+ function bindAttr(scope, selector, attr, setter) {
224
+ scope.querySelectorAll(selector).forEach((el) => {
225
+ const key = el.getAttribute(attr);
226
+ if (!key)
227
+ return;
228
+ const paramsJson = el.getAttribute('data-i18n-params');
229
+ const params = paramsJson ? safeParseParams(paramsJson) : undefined;
230
+ setter(el, t(key, params));
231
+ });
232
+ }
233
+ function safeParseParams(json) {
234
+ try {
235
+ const parsed = JSON.parse(json);
236
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
237
+ ? parsed
238
+ : undefined;
239
+ }
240
+ catch {
241
+ return undefined;
242
+ }
243
+ }
244
+ /**
245
+ * Map a known server error message (English, as sent on the wire) to the
246
+ * active locale's catalog. Unknown messages are returned unchanged so the
247
+ * user always sees something meaningful. This is the display-layer
248
+ * translation boundary for API/OIDC errors.
249
+ */
250
+ export function translateError(message) {
251
+ const reverse = errorReverseMap();
252
+ const key = reverse.get(message);
253
+ if (key)
254
+ return t(key);
255
+ return message;
256
+ }
257
+ // Lazy reverse map: English error text → catalog key. Built from enCatalog
258
+ // so it tracks the catalog without a hand-maintained second table.
259
+ let cachedErrorReverse = null;
260
+ function errorReverseMap() {
261
+ if (cachedErrorReverse)
262
+ return cachedErrorReverse;
263
+ const map = new Map();
264
+ for (const [key, value] of Object.entries(enCatalog)) {
265
+ if (key.startsWith('error.'))
266
+ map.set(value, key);
267
+ }
268
+ cachedErrorReverse = map;
269
+ return map;
270
+ }
271
+ /** Compile-time-complete mapping from supported locale → BCP 47 tag. */
272
+ const LOCALE_TAGS = {
273
+ en: 'en-US',
274
+ de: 'de-DE',
275
+ es: 'es-ES',
276
+ zh: 'zh-CN',
277
+ };
278
+ /**
279
+ * Format a date in the active locale (`de-DE` for de, `en-US` for en) using
280
+ * Intl.DateTimeFormat. Replaces hard-coded `toLocaleDateString('en-US', …)`.
281
+ */
282
+ export function localeDate(date, options) {
283
+ const localeTag = LOCALE_TAGS[currentLocale];
284
+ const d = date instanceof Date ? date : new Date(date);
285
+ return new Intl.DateTimeFormat(localeTag, options).format(d);
286
+ }
287
+ //# sourceMappingURL=i18n.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"i18n.js","sourceRoot":"","sources":["i18n.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,306 @@
1
+ // ═══════════════════════════════════════════════════════════════
2
+ // i18n — locale resolution, catalog lookup, DOM binding
3
+ // ═══════════════════════════════════════════════════════════════
4
+ //
5
+ // Flat JSON catalogs (one file per locale) live alongside this module in
6
+ // `public/src/i18n/{en,de}.json`. They are fetched at runtime so the browser
7
+ // bundle stays free of new build-time dependencies and the same JSON files
8
+ // can be audited by tests directly. The English catalog is the source of
9
+ // truth: every key must exist in `en.json`; other locales fall back to it.
10
+ //
11
+ // Scope: this module covers *package-owned* SPA UI strings (auth screen, nav,
12
+ // banners, settings incl. registration acknowledgement, consent UI). Server
13
+ // API error strings stay English on the wire and are translated at the
14
+ // display layer via `translateError()` (fallback: raw message).
15
+ //
16
+ // Legal pages (`public/*.html`) are operator-overlay templates
17
+ // (`PM_WEB_LEGAL_DIR`); their localization is an operator concern and is NOT
18
+ // handled here. The language selector deliberately does not promise
19
+ // translated legal pages — see `settings.languageHint` and the German
20
+ // disclaimer string `legal.disclaimer`.
21
+
22
+ /** localStorage key persisting the user's locale choice. */
23
+ export const LOCALE_STORAGE_KEY = 'pmLocale';
24
+
25
+ /** Locales shipped by this package. The first entry is the default/fallback. */
26
+ export const SUPPORTED_LOCALES = ['en', 'de', 'es', 'zh'] as const;
27
+ export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
28
+
29
+ const DEFAULT_LOCALE: SupportedLocale = 'en';
30
+
31
+ /**
32
+ * Resolve the active locale without touching the DOM, so it is unit-testable
33
+ * in Node. Resolution order:
34
+ * 1. `opts.storage[LOCALE_STORAGE_KEY]` (explicit user choice)
35
+ * 2. `opts.navLang` prefix match against SUPPORTED_LOCALES (e.g. `de-DE` → `de`)
36
+ * 3. default locale (`en`)
37
+ *
38
+ * `opts.storage` may be `null` to skip the localStorage step; `opts.navLang`
39
+ * may be omitted to skip navigator negotiation. When called with no
40
+ * arguments the real browser globals (`localStorage`, `navigator.language`)
41
+ * are used — but only inside this function, never at module top level, so
42
+ * importing this module in Node has no side effects.
43
+ */
44
+ export function resolveLocale(opts?: {
45
+ storage?: Storage | null;
46
+ navLang?: string;
47
+ }): SupportedLocale {
48
+ // A property that is present (even if `null`) means the caller explicitly
49
+ // wants that value: `null` skips the step. A property that is absent falls
50
+ // back to the real browser global, so the no-argument browser path still
51
+ // works. This keeps the function deterministic and unit-testable in Node.
52
+ const hasStorage = opts != null && Object.prototype.hasOwnProperty.call(opts, 'storage');
53
+ const storage = hasStorage ? opts!.storage : safeLocalStorage();
54
+ if (storage) {
55
+ let stored: string | null = null;
56
+ try { stored = storage.getItem(LOCALE_STORAGE_KEY); } catch { /* privacy mode */ }
57
+ if (stored && isSupported(stored)) return stored;
58
+ }
59
+ const hasNav = opts != null && Object.prototype.hasOwnProperty.call(opts, 'navLang');
60
+ const navLang = hasNav ? opts!.navLang : safeNavLang();
61
+ if (navLang) {
62
+ const prefix = navLang.toLowerCase().split('-')[0];
63
+ if (prefix && isSupported(prefix)) return prefix;
64
+ }
65
+ return DEFAULT_LOCALE;
66
+ }
67
+
68
+ function isSupported(value: string): value is SupportedLocale {
69
+ return (SUPPORTED_LOCALES as readonly string[]).includes(value);
70
+ }
71
+
72
+ /** localStorage access guarded so Node imports/tests never crash.
73
+ * Resolving the storage reference itself can throw a `SecurityError` in
74
+ * privacy/incognito modes that disable storage, so the lookup is wrapped too. */
75
+ function safeLocalStorage(): Storage | null {
76
+ try {
77
+ const g = globalThis as { localStorage?: Storage };
78
+ return g.localStorage ?? null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** navigator.language access guarded so Node imports/tests never crash. */
85
+ function safeNavLang(): string | undefined {
86
+ const n = (globalThis as { navigator?: { language?: string } }).navigator;
87
+ return n?.language;
88
+ }
89
+
90
+ /**
91
+ * Pure catalog lookup with fallback. Used by `t()` and directly by tests.
92
+ *
93
+ * @param catalog the active locale catalog (may be partial)
94
+ * @param fallback the fallback catalog (English — must contain every key)
95
+ * @param key dotted string id
96
+ * @param params optional `{name: value}` substitutions for `{name}` tokens
97
+ * @returns the translated string, or the key itself if missing from both
98
+ */
99
+ export function translate(
100
+ catalog: Record<string, string>,
101
+ fallback: Record<string, string>,
102
+ key: string,
103
+ params?: Record<string, string | number>,
104
+ ): string {
105
+ let raw = catalog[key] ?? fallback[key] ?? key;
106
+ if (params) {
107
+ for (const [name, value] of Object.entries(params)) {
108
+ raw = raw.replaceAll(`{${name}}`, String(value));
109
+ }
110
+ }
111
+ return raw;
112
+ }
113
+
114
+ // ── Runtime state (populated by initI18n / setLocale) ───────────────
115
+ let currentLocale: SupportedLocale = DEFAULT_LOCALE;
116
+ let activeCatalog: Record<string, string> = {};
117
+ let enCatalog: Record<string, string> = {};
118
+ let initialized = false;
119
+
120
+ /**
121
+ * Monotonic request id for setLocale. Each invocation increments this; an
122
+ * in-flight request records its id and, after its awaited catalog fetch,
123
+ * commits state only if it is still the latest. This discards stale catalogs
124
+ * when language changes overlap (e.g. rapid de → en) so a slower earlier
125
+ * fetch can never overwrite a newer selection.
126
+ */
127
+ let localeReqId = 0;
128
+
129
+ /** Fetch a locale catalog JSON. Best-effort: returns {} on any failure. */
130
+ async function fetchCatalog(locale: string): Promise<Record<string, string>> {
131
+ try {
132
+ const res = await fetch(`/src/i18n/${locale}.json`, { credentials: 'same-origin' });
133
+ if (!res.ok) return {};
134
+ const data = (await res.json()) as Record<string, string>;
135
+ return data && typeof data === 'object' ? data : {};
136
+ } catch {
137
+ return {};
138
+ }
139
+ }
140
+
141
+ /** Apply the resolved locale to `<html lang>` so AT/browsers report it. */
142
+ function syncHtmlLang(locale: string): void {
143
+ const el = (globalThis as { document?: Document }).document?.documentElement;
144
+ if (el) el.lang = locale;
145
+ }
146
+
147
+ /**
148
+ * Initialize i18n: resolve the locale, load the English fallback + the active
149
+ * catalog, set `<html lang>`, and apply `data-i18n` bindings in the document.
150
+ * Safe to call once at boot; subsequent calls re-apply translations.
151
+ */
152
+ export async function initI18n(): Promise<void> {
153
+ if (!initialized) {
154
+ currentLocale = resolveLocale();
155
+ }
156
+ if (!Object.keys(enCatalog).length) {
157
+ enCatalog = await fetchCatalog('en');
158
+ }
159
+ if (currentLocale === 'en') {
160
+ activeCatalog = enCatalog;
161
+ } else {
162
+ activeCatalog = await fetchCatalog(currentLocale);
163
+ }
164
+ initialized = true;
165
+ syncHtmlLang(currentLocale);
166
+ applyTranslations();
167
+ }
168
+
169
+ /** Current active locale. */
170
+ export function getLocale(): SupportedLocale {
171
+ return currentLocale;
172
+ }
173
+
174
+ /**
175
+ * Translate a key using the active locale with English fallback. Requires
176
+ * `initI18n()` to have completed; before that, returns the English value (or
177
+ * the key if English is also missing).
178
+ */
179
+ export function t(
180
+ key: string,
181
+ params?: Record<string, string | number>,
182
+ ): string {
183
+ return translate(activeCatalog, enCatalog, key, params);
184
+ }
185
+
186
+ /**
187
+ * Persist a new locale choice, load its catalog, update `<html lang>`, and
188
+ * re-apply `data-i18n` bindings across the document. Falls back to `en` for
189
+ * unsupported values.
190
+ */
191
+ export async function setLocale(locale: string): Promise<void> {
192
+ const next: SupportedLocale = isSupported(locale) ? locale : DEFAULT_LOCALE;
193
+ const storage = safeLocalStorage();
194
+ if (storage) { try { storage.setItem(LOCALE_STORAGE_KEY, next); } catch { /* privacy mode */ } }
195
+ currentLocale = next;
196
+ const myReqId = ++localeReqId;
197
+ if (!Object.keys(enCatalog).length) {
198
+ const en = await fetchCatalog('en');
199
+ // A newer setLocale superseded this one: do not assign enCatalog.
200
+ if (myReqId !== localeReqId) return;
201
+ enCatalog = en;
202
+ }
203
+ const fetched = next === 'en' ? enCatalog : await fetchCatalog(next);
204
+ // Stale request: discard the fetched catalog so it can never overwrite a
205
+ // newer selection (e.g. a slow German fetch finishing after en was chosen).
206
+ if (myReqId !== localeReqId) return;
207
+ activeCatalog = fetched;
208
+ initialized = true;
209
+ syncHtmlLang(next);
210
+ applyTranslations();
211
+ }
212
+
213
+ /**
214
+ * Walk the document for `data-i18n` attributes and bind translations. Three
215
+ * attribute flavors are supported:
216
+ * - `data-i18n="key"` → sets `textContent`
217
+ * - `data-i18n-html="key"` → sets `innerHTML` (for strings with markup)
218
+ * - `data-i18n-title="key"` → sets the `title` attribute
219
+ * - `data-i18n-placeholder="key"` → sets the `placeholder` attribute
220
+ * - `data-i18n-aria="key"` → sets the `aria-label` attribute
221
+ *
222
+ * `data-i18n-params` may carry a JSON object literal for parameterized keys.
223
+ */
224
+ export function applyTranslations(root?: ParentNode): void {
225
+ const doc = (globalThis as { document?: Document }).document;
226
+ if (!doc) return;
227
+ const scope = root ?? doc;
228
+ bindAttr(scope, '[data-i18n]', 'data-i18n', (el, v) => { el.textContent = v; });
229
+ bindAttr(scope, '[data-i18n-html]', 'data-i18n-html', (el, v) => { el.innerHTML = v; });
230
+ bindAttr(scope, '[data-i18n-title]', 'data-i18n-title', (el, v) => { el.setAttribute('title', v); });
231
+ bindAttr(scope, '[data-i18n-placeholder]', 'data-i18n-placeholder', (el, v) => { el.setAttribute('placeholder', v); });
232
+ bindAttr(scope, '[data-i18n-aria]', 'data-i18n-aria', (el, v) => { el.setAttribute('aria-label', v); });
233
+ }
234
+
235
+ function bindAttr(
236
+ scope: ParentNode,
237
+ selector: string,
238
+ attr: string,
239
+ setter: (el: Element, value: string) => void,
240
+ ): void {
241
+ scope.querySelectorAll<Element>(selector).forEach((el) => {
242
+ const key = el.getAttribute(attr);
243
+ if (!key) return;
244
+ const paramsJson = el.getAttribute('data-i18n-params');
245
+ const params = paramsJson ? safeParseParams(paramsJson) : undefined;
246
+ setter(el, t(key, params));
247
+ });
248
+ }
249
+
250
+ function safeParseParams(json: string): Record<string, string | number> | undefined {
251
+ try {
252
+ const parsed = JSON.parse(json) as unknown;
253
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
254
+ ? (parsed as Record<string, string | number>)
255
+ : undefined;
256
+ } catch {
257
+ return undefined;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Map a known server error message (English, as sent on the wire) to the
263
+ * active locale's catalog. Unknown messages are returned unchanged so the
264
+ * user always sees something meaningful. This is the display-layer
265
+ * translation boundary for API/OIDC errors.
266
+ */
267
+ export function translateError(message: string): string {
268
+ const reverse = errorReverseMap();
269
+ const key = reverse.get(message);
270
+ if (key) return t(key);
271
+ return message;
272
+ }
273
+
274
+ // Lazy reverse map: English error text → catalog key. Built from enCatalog
275
+ // so it tracks the catalog without a hand-maintained second table.
276
+ let cachedErrorReverse: Map<string, string> | null = null;
277
+ function errorReverseMap(): Map<string, string> {
278
+ if (cachedErrorReverse) return cachedErrorReverse;
279
+ const map = new Map<string, string>();
280
+ for (const [key, value] of Object.entries(enCatalog)) {
281
+ if (key.startsWith('error.')) map.set(value, key);
282
+ }
283
+ cachedErrorReverse = map;
284
+ return map;
285
+ }
286
+
287
+ /** Compile-time-complete mapping from supported locale → BCP 47 tag. */
288
+ const LOCALE_TAGS: Record<SupportedLocale, string> = {
289
+ en: 'en-US',
290
+ de: 'de-DE',
291
+ es: 'es-ES',
292
+ zh: 'zh-CN',
293
+ };
294
+
295
+ /**
296
+ * Format a date in the active locale (`de-DE` for de, `en-US` for en) using
297
+ * Intl.DateTimeFormat. Replaces hard-coded `toLocaleDateString('en-US', …)`.
298
+ */
299
+ export function localeDate(
300
+ date: Date | string | number,
301
+ options?: Intl.DateTimeFormatOptions,
302
+ ): string {
303
+ const localeTag = LOCALE_TAGS[currentLocale];
304
+ const d = date instanceof Date ? date : new Date(date);
305
+ return new Intl.DateTimeFormat(localeTag, options).format(d);
306
+ }
package/public/src/sw.ts CHANGED
@@ -37,6 +37,9 @@ const STATIC_ASSETS: readonly string[] = [
37
37
  '/src/components/toast.js',
38
38
  '/src/constants.js',
39
39
  '/src/filters.js',
40
+ '/src/i18n.js',
41
+ '/src/i18n/de.json',
42
+ '/src/i18n/en.json',
40
43
  '/src/state.js',
41
44
  '/src/theme.js',
42
45
  '/src/types.js',
@@ -4,6 +4,7 @@
4
4
  import { state } from '../state.js';
5
5
  import { api } from '../api.js';
6
6
  import { bootApp } from '../app.js';
7
+ import { t, translateError } from '../i18n.js';
7
8
  async function configureOidcLogin() {
8
9
  const button = document.getElementById('oidc-login');
9
10
  const divider = document.getElementById('oidc-divider');
@@ -14,7 +15,7 @@ async function configureOidcLogin() {
14
15
  button.hidden = !config.enabled;
15
16
  if (divider)
16
17
  divider.hidden = !config.enabled;
17
- button.textContent = `Continue with ${config.label}`;
18
+ button.textContent = t('auth.oidc.template', { label: config.label });
18
19
  }
19
20
  catch {
20
21
  button.hidden = true;
@@ -34,13 +35,13 @@ export function switchAuthTab(tab) {
34
35
  fieldName.style.display = tab === 'register' ? '' : 'none';
35
36
  const authTitle = document.getElementById('auth-title');
36
37
  if (authTitle)
37
- authTitle.textContent = tab === 'login' ? 'Welcome back' : 'Create account';
38
+ authTitle.textContent = tab === 'login' ? t('auth.title.login') : t('auth.title.register');
38
39
  const authSub = document.getElementById('auth-sub');
39
40
  if (authSub)
40
- authSub.textContent = tab === 'login' ? 'Sign in to your account to continue' : 'Join pm-web and start managing projects';
41
+ authSub.textContent = tab === 'login' ? t('auth.sub.login') : t('auth.sub.register');
41
42
  const authBtnText = document.getElementById('auth-btn-text');
42
43
  if (authBtnText)
43
- authBtnText.textContent = tab === 'login' ? 'Sign In' : 'Create Account';
44
+ authBtnText.textContent = tab === 'login' ? t('auth.button.login') : t('auth.button.register');
44
45
  const authError = document.getElementById('auth-error');
45
46
  if (authError)
46
47
  authError.style.display = 'none';
@@ -61,7 +62,7 @@ export async function submitAuth(e) {
61
62
  btn.disabled = true;
62
63
  const span = btn.querySelector('span');
63
64
  if (span)
64
- span.textContent = 'Please wait…';
65
+ span.textContent = t('auth.loading');
65
66
  try {
66
67
  let data;
67
68
  if (state.authTab === 'login') {
@@ -74,11 +75,11 @@ export async function submitAuth(e) {
74
75
  await bootApp();
75
76
  }
76
77
  catch (err) {
77
- errEl.textContent = err instanceof Error ? err.message : String(err);
78
+ errEl.textContent = translateError(err instanceof Error ? err.message : String(err));
78
79
  errEl.style.display = 'block';
79
80
  btn.disabled = false;
80
81
  if (span)
81
- span.textContent = state.authTab === 'login' ? 'Sign In' : 'Create Account';
82
+ span.textContent = state.authTab === 'login' ? t('auth.button.login') : t('auth.button.register');
82
83
  }
83
84
  }
84
85
  export async function logout() {