@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.
- package/CHANGELOG.md +6 -0
- package/dist/app.d.ts +15 -1
- package/dist/app.js +15 -1
- package/dist/app.js.map +1 -1
- package/dist/index.js +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/public/index.html +32 -28
- package/public/src/app.js +14 -0
- package/public/src/app.js.map +1 -1
- package/public/src/app.ts +15 -0
- package/public/src/components/modals.js +5 -3
- package/public/src/components/modals.js.map +1 -1
- package/public/src/components/modals.ts +6 -3
- package/public/src/i18n/de.json +117 -0
- package/public/src/i18n/en.json +117 -0
- package/public/src/i18n/es.json +117 -0
- package/public/src/i18n/zh.json +117 -0
- package/public/src/i18n.js +287 -0
- package/public/src/i18n.js.map +1 -0
- package/public/src/i18n.ts +306 -0
- package/public/src/sw.ts +3 -0
- package/public/src/views/auth.js +8 -7
- package/public/src/views/auth.js.map +1 -1
- package/public/src/views/auth.ts +8 -7
- package/public/src/views/settings.js +73 -48
- package/public/src/views/settings.js.map +1 -1
- package/public/src/views/settings.ts +77 -48
- package/public/sw.js +3 -0
|
@@ -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
package/public/src/views/auth.js
CHANGED
|
@@ -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 =
|
|
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' ? '
|
|
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' ? '
|
|
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' ? '
|
|
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 = '
|
|
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' ? '
|
|
82
|
+
span.textContent = state.authTab === 'login' ? t('auth.button.login') : t('auth.button.register');
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
export async function logout() {
|