slicejs-web-framework 4.1.1 → 4.2.0

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,1111 @@
1
+ import {
2
+ IntlCache,
3
+ directionOf,
4
+ formatMessage,
5
+ isPlainObject,
6
+ isPluralMessage,
7
+ lookup,
8
+ matchLocale,
9
+ mergeMessages,
10
+ } from './messages.js';
11
+ import { TranslationListRef, TranslationRef, containsMarker, resolveMarkers } from './markers.js';
12
+ import { localize } from './localized.js';
13
+ import { resolveContent } from './content.js';
14
+ import { translateTree } from './dom.js';
15
+ import { isExternalHref, localeOfUrl, localizePath, normalizeLocalePrefix, splitLocalePath } from './localePath.js';
16
+
17
+ const STORAGE_KEY = 'slice:locale';
18
+ const WRAPPED = Symbol('slice.translator.wrapped');
19
+
20
+ /**
21
+ * @typedef {Object} TranslatorConfig
22
+ * @property {boolean} [enabled]
23
+ * @property {string} [defaultLocale]
24
+ * @property {string|null} [fallbackLocale]
25
+ * @property {string[]} [locales]
26
+ * @property {string} [path]
27
+ * @property {boolean} [saveLocally]
28
+ * @property {boolean} [useBrowserLocale]
29
+ * @property {false|'always'|'except-default'} [localePrefix]
30
+ */
31
+
32
+ /**
33
+ * slice.translator — translations and locale formatting as part of the framework.
34
+ *
35
+ * slice.t('orders.count', { count: 3 }) → '3 pedidos'
36
+ * <h1 data-t="home.title"></h1> → translated, and kept translated
37
+ * slice.build('Button', { value: slice.t.ref('orders.save') })
38
+ *
39
+ * Texts in the DOM are *marked*, not written: any element with `data-t` (in a template, in
40
+ * App/index.html, or created later from JS) is translated when it enters the document and
41
+ * again on every locale change. Changing a `data-t` or `data-t-*` attribute re-translates
42
+ * that element. Props passed as `key()` markers are resolved before the component sees them
43
+ * and assigned again on a locale change. `watch()` covers what lives outside the DOM.
44
+ *
45
+ * With `enabled: false` nothing is loaded or observed: `t()` returns the key, markers resolve
46
+ * to their key, `format.*` uses the browser's language.
47
+ */
48
+ export default class Translator {
49
+ /**
50
+ * @param {TranslatorConfig} [config]
51
+ */
52
+ constructor(config = {}) {
53
+ const options = config && typeof config === 'object' ? config : {};
54
+ const browserLocale = Translator.browserLocales()[0] || 'en';
55
+ const clean = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
56
+ const wantsEnabled = options.enabled === true;
57
+ const configuredDefault = clean(options.defaultLocale);
58
+
59
+ /** @type {boolean} */
60
+ this.enabled = wantsEnabled && configuredDefault !== '';
61
+ if (wantsEnabled && !this.enabled) {
62
+ // The schema says enabled needs defaultLocale, but the runtime must say so too:
63
+ // silently booting with keys everywhere is the worst failure mode.
64
+ console.error('[slice.translator] "enabled": true requires a non-empty "defaultLocale"; the translator stays disabled.');
65
+ }
66
+ /** @type {string} */
67
+ this.defaultLocale = this.enabled ? configuredDefault : browserLocale;
68
+
69
+ const declared = Array.isArray(options.locales) ? options.locales.map(clean).filter(Boolean) : [];
70
+ // Dedupe case-insensitively: "ES" and "es" are the same language, and a duplicate
71
+ // would fetch a file that does not exist while the real one is fine.
72
+ const seen = new Map();
73
+ for (const locale of [this.defaultLocale, ...declared]) {
74
+ const key = locale.toLowerCase();
75
+ if (!seen.has(key)) seen.set(key, locale);
76
+ }
77
+ /** @type {readonly string[]} */
78
+ this.locales = Object.freeze([...seen.values()]);
79
+ const configuredFallback = clean(options.fallbackLocale);
80
+ /** @type {string|null} */
81
+ this.fallbackLocale =
82
+ this.enabled && configuredFallback && configuredFallback.toLowerCase() !== this.defaultLocale.toLowerCase()
83
+ ? configuredFallback
84
+ : null;
85
+ if (this.fallbackLocale && !this.locales.some((locale) => locale.toLowerCase() === this.fallbackLocale.toLowerCase())) {
86
+ // The fallback is loaded like any locale; listing it keeps setLocale() consistent.
87
+ this.locales = Object.freeze([...this.locales, this.fallbackLocale]);
88
+ }
89
+
90
+ const rawPath = clean(options.path) || '/locales';
91
+ const cleanedPath = rawPath.replace(/^\/+|\/+$/g, '');
92
+ // A relative path would resolve against the current URL (`/en/locales/...`), so the
93
+ // leading slash is not cosmetic.
94
+ this.path = cleanedPath ? `/${cleanedPath}` : '/locales';
95
+ this.saveLocally = options.saveLocally === true;
96
+ this.useBrowserLocale = options.useBrowserLocale === true;
97
+ /** @type {false|'always'|'except-default'} the locale in the URL (§7) */
98
+ this.localePrefix = this.enabled ? normalizeLocalePrefix(options.localePrefix) : false;
99
+
100
+ /** @type {string} */
101
+ this.locale = this.defaultLocale;
102
+
103
+ /** @private @type {Map<string, Record<string, any>>} */
104
+ this._app = new Map();
105
+ /** @private @type {Map<string, Record<string, any>>} */
106
+ this._defaults = new Map();
107
+ /** @private @type {Set<string>} files merged, as `locale` or `locale/namespace` */
108
+ this._loaded = new Set();
109
+ /** @private @type {Map<string, Promise<void>>} */
110
+ this._requests = new Map();
111
+ /** @private @type {Set<string>} */
112
+ this._namespaces = new Set();
113
+ /** @private @type {Map<string, Promise<Record<string, any>|undefined>>} content document → load */
114
+ this._content = new Map();
115
+ /** @private @type {Map<string, any>} `${document}|${locale}` → resolved content */
116
+ this._contentCache = new Map();
117
+ /** @private */
118
+ this._version = 0;
119
+ /** @private texts are not written (nor missing keys reported) before the first load */
120
+ this._ready = !this.enabled;
121
+
122
+ /** @private */
123
+ this._intl = new IntlCache();
124
+ /** @private @type {Set<string>} */
125
+ this._warned = new Set();
126
+ /** @type {boolean} debugger switch: `t()` returns the key instead of the text */
127
+ this.showKeys = false;
128
+
129
+ // DOM
130
+ /** @private @type {WeakMap<Element, string>} */
131
+ this._applied = new WeakMap();
132
+ /** @private @type {WeakMap<Element, Map<string, Element>>} */
133
+ this._slots = new WeakMap();
134
+ /** @private @type {MutationObserver|null} */
135
+ this._observer = null;
136
+ /** @private */
137
+ this._dom = {
138
+ t: (key, params) => this.t(key, params),
139
+ signature: () => `${this.locale}#${this._version}#${this.showKeys ? 'keys' : 'text'}`,
140
+ applied: this._applied,
141
+ slots: this._slots,
142
+ warn: (message) => this._warnOnce(message, message),
143
+ };
144
+
145
+ // Props
146
+ /** @private @type {WeakMap<object, Map<string, any>>} component → prop → raw value with markers */
147
+ this._markerProps = new WeakMap();
148
+ /** @private @type {WeakMap<Function, Set<string>>} class → props whose setter resolves markers */
149
+ this._wrappedProps = new WeakMap();
150
+ /** @private */
151
+ this._refreshing = false;
152
+
153
+ // watch()
154
+ /** @private @type {Map<object, Set<(locale: string, previous: string) => void>>} */
155
+ this._watchers = new Map();
156
+ /** @private @type {Set<(locale: string, previous: string) => void>} */
157
+ this._globalWatchers = new Set();
158
+
159
+ /** @private */
160
+ this._refreshScheduled = false;
161
+ /** @private */
162
+ this._switchId = 0;
163
+
164
+ this.format = this._createFormat();
165
+ }
166
+
167
+ /**
168
+ * The browser's preferred languages, most preferred first.
169
+ * @returns {string[]}
170
+ */
171
+ static browserLocales() {
172
+ if (typeof navigator === 'undefined') return [];
173
+ const list = Array.isArray(navigator.languages) && navigator.languages.length ? navigator.languages : [navigator.language];
174
+ return list.filter((l) => typeof l === 'string' && l);
175
+ }
176
+
177
+ // ───────────────────────────── Start ─────────────────────────────
178
+
179
+ /**
180
+ * Pick the starting locale, load its dictionaries (and the fallback's), translate the
181
+ * document and start watching it. A failed load is logged, not thrown: the app boots
182
+ * showing keys rather than not booting.
183
+ * @returns {Promise<void>}
184
+ */
185
+ async init() {
186
+ if (!this.enabled) return;
187
+ this.locale = this.initialLocale();
188
+ try {
189
+ await this._loadLocale(this.locale);
190
+ } catch (error) {
191
+ this._log('error', `Could not load the dictionary for "${this.locale}"`, error);
192
+ }
193
+ this._ready = true;
194
+ this._applyDocumentLocale();
195
+ // With "always", `/pricing` becomes `/es/pricing`; with "except-default",
196
+ // `/es/pricing` (the default locale) becomes `/pricing`. Before the router starts.
197
+ this.syncUrl();
198
+ this.apply();
199
+ this._observe();
200
+ }
201
+
202
+ /**
203
+ * The locale to start with. With `localePrefix`, the URL decides: its prefix, or — with
204
+ * "except-default" — the default locale when there is none. Otherwise (and for
205
+ * "prefix" at an unprefixed URL): the saved choice (`saveLocally`) → the browser's
206
+ * language (`useBrowserLocale`) → `defaultLocale`.
207
+ * @returns {string}
208
+ */
209
+ initialLocale() {
210
+ if (!this.enabled) return this.defaultLocale;
211
+ if (this.localePrefix && typeof location !== 'undefined') {
212
+ // Only an explicit prefix decides on the first load. Under "except-default" an
213
+ // unprefixed URL means "no choice yet", so saveLocally/useBrowserLocale still run.
214
+ const { locale: fromUrl } = splitLocalePath(location.pathname, this.locales);
215
+ if (fromUrl) return fromUrl;
216
+ }
217
+ if (this.saveLocally) {
218
+ const saved = matchLocale(this._readSaved(), /** @type {string[]} */ ([...this.locales]));
219
+ if (saved) return saved;
220
+ }
221
+ if (this.useBrowserLocale) {
222
+ for (const candidate of Translator.browserLocales()) {
223
+ const match = matchLocale(candidate, /** @type {string[]} */ ([...this.locales]));
224
+ if (match) return match;
225
+ }
226
+ }
227
+ return this.defaultLocale;
228
+ }
229
+
230
+ /**
231
+ * Stop observing the document. Used by tests and by an app tearing Slice down.
232
+ * @returns {void}
233
+ */
234
+ destroy() {
235
+ this._observer?.disconnect();
236
+ this._observer = null;
237
+ this._watchers.clear();
238
+ this._globalWatchers.clear();
239
+ }
240
+
241
+ // ───────────────────────────── Messages ─────────────────────────────
242
+
243
+ /**
244
+ * Translate a key. Lookup: the app's dictionaries → the library defaults, for the active
245
+ * locale and then for `fallbackLocale`; when nothing matches, the key itself.
246
+ * @param {string} key
247
+ * @param {Record<string, any>} [params]
248
+ * @returns {string}
249
+ */
250
+ t(key, params) {
251
+ if (typeof key !== 'string' || key === '') return String(key ?? '');
252
+ if (!this.enabled) return key;
253
+ if (this.showKeys) return key;
254
+
255
+ for (const locale of this._chain()) {
256
+ const entry = this._entry(key, locale);
257
+ if (entry === undefined) continue;
258
+ const text = formatMessage(entry, params, locale, this._intl, {
259
+ onMissingParam: (name) => this._warnOnce(`param:${locale}:${key}:${name}`, `"${key}" (${locale}) uses {${name}} but no "${name}" param was given`),
260
+ onMissingCount: () => this._warnOnce(`count:${locale}:${key}`, `"${key}" (${locale}) is a plural message: pass { count }`),
261
+ });
262
+ if (text === null) {
263
+ this._warnOnce(`group:${key}`, `"${key}" is a group of messages, not a message`);
264
+ continue;
265
+ }
266
+ return text;
267
+ }
268
+
269
+ if (this._ready) {
270
+ this._warnOnce(`missing:${this.locale}:${key}`, `Missing translation "${key}" for "${this.locale}"${this.fallbackLocale ? ` (and "${this.fallbackLocale}")` : ''}`);
271
+ }
272
+ return key;
273
+ }
274
+
275
+ /**
276
+ * Translate a list value: an array of messages (strings or plurals) or records.
277
+ * String items are formatted with `params`; objects are returned as-is (content lists
278
+ * carry their own localized records). A missing entry or a non-array returns `[]` and
279
+ * warns once. With `showKeys`, string items become `key.<index>`.
280
+ * @param {string} key
281
+ * @param {Record<string, any>} [params]
282
+ * @returns {any[]}
283
+ */
284
+ list(key, params) {
285
+ if (typeof key !== 'string' || key === '') return [];
286
+ if (!this.enabled) return [];
287
+
288
+ for (const locale of this._chain()) {
289
+ const entry = this._entry(key, locale);
290
+ if (entry === undefined) continue;
291
+ if (!Array.isArray(entry)) {
292
+ this._warnOnce(`list-shape:${key}`, `"${key}" is not a list: slice.t.list() expects an array`);
293
+ return [];
294
+ }
295
+ return entry.map((item, index) => {
296
+ if (this.showKeys) {
297
+ return typeof item === 'string' || isPluralMessage(item) ? `${key}.${index}` : item;
298
+ }
299
+ if (typeof item === 'string' || isPluralMessage(item)) {
300
+ const text = formatMessage(item, params, locale, this._intl, {
301
+ onMissingParam: (name) => this._warnOnce(`param:${locale}:${key}:${index}:${name}`, `"${key}.${index}" (${locale}) uses {${name}} but no "${name}" param was given`),
302
+ onMissingCount: () => this._warnOnce(`count:${locale}:${key}:${index}`, `"${key}.${index}" (${locale}) is a plural message: pass { count }`),
303
+ });
304
+ return text === null ? item : text;
305
+ }
306
+ return item;
307
+ });
308
+ }
309
+
310
+ if (this._ready) {
311
+ this._warnOnce(`list-missing:${this.locale}:${key}`, `Missing list "${key}" for "${this.locale}"`);
312
+ }
313
+ return [];
314
+ }
315
+
316
+ /**
317
+ * Resolve a localized content value (`{ en: …, es: … }`) for the active locale.
318
+ * Arrays and plain objects are walked recursively; it never looks up dictionary keys.
319
+ * @param {any} value
320
+ * @returns {any}
321
+ */
322
+ localized(value) {
323
+ return localize(value, {
324
+ locale: this.locale,
325
+ fallbackLocale: this.fallbackLocale,
326
+ defaultLocale: this.defaultLocale,
327
+ locales: this.locales,
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Load a content document (`<path>/content/<name>.json`) and resolve it for the active
333
+ * locale: every `copy` becomes its branch, nested nodes are flattened. The document is
334
+ * fetched once; the resolution is cached per locale. Returns `null` when it cannot load.
335
+ * @param {string} name
336
+ * @returns {Promise<any|null>}
337
+ */
338
+ async content(name) {
339
+ const id = String(name ?? '').trim().replace(/^\/+|\/+$/g, '');
340
+ const valid = id && /^[a-z0-9][a-z0-9/_-]*$/i.test(id) && !id.split('/').some((segment) => !segment || segment === '.' || segment === '..');
341
+ if (!valid) {
342
+ this._log('warn', `slice.t.content() needs a document name such as "experience" (got "${name}")`);
343
+ return null;
344
+ }
345
+ if (!this.enabled) {
346
+ this._warnOnce('content-disabled', 'slice.t.content() needs the translator enabled');
347
+ return null;
348
+ }
349
+
350
+ const document = await this._loadContent(id);
351
+ if (document === undefined) return null;
352
+
353
+ const locale = this.locale;
354
+ const cacheKey = `${id}|${locale}`;
355
+ if (this._contentCache.has(cacheKey)) return this._contentCache.get(cacheKey);
356
+
357
+ const options = {
358
+ locale,
359
+ fallbackLocale: this.fallbackLocale,
360
+ defaultLocale: this.defaultLocale,
361
+ locales: this.locales,
362
+ };
363
+ const resolved = resolveContent(document, (value) => localize(value, options), {
364
+ onMissing: (path) => this._warnOnce(
365
+ `content-copy:${id}:${locale}:${path}`,
366
+ `Content "${id}" has a "copy" at "${path}" without "${locale}" (or its fallback)`
367
+ ),
368
+ onInvalid: (path) => this._warnOnce(
369
+ `content-invalid:${id}:${path}`,
370
+ `Content "${id}" has a scalar/array "copy" at "${path}" next to structural fields; use an object branch (e.g. { "title": … })`
371
+ ),
372
+ });
373
+ this._contentCache.set(cacheKey, resolved);
374
+ return resolved;
375
+ }
376
+
377
+ /**
378
+ * Debugger switch: show keys instead of texts, everywhere, without changing the locale.
379
+ * Re-translates the document and marker props on both transitions.
380
+ * @param {boolean} enabled
381
+ * @returns {boolean} the new state
382
+ */
383
+ setShowKeys(enabled) {
384
+ const next = enabled === true;
385
+ if (next === this.showKeys) return this.showKeys;
386
+ this.showKeys = next;
387
+ this._version++;
388
+ this._refresh();
389
+ return this.showKeys;
390
+ }
391
+
392
+ /**
393
+ * Whether `key` is a message in `locale` (default: the active one), app or defaults layer.
394
+ * The fallback is not consulted.
395
+ * @param {string} key
396
+ * @param {string} [locale]
397
+ * @returns {boolean}
398
+ */
399
+ has(key, locale = this.locale) {
400
+ const entry = this._entry(key, locale);
401
+ return typeof entry === 'string' || isPluralMessage(entry);
402
+ }
403
+
404
+ /**
405
+ * Merge messages at runtime. With `{ defaults: true }` they go to a lower layer that never
406
+ * overrides the app's dictionaries — how a component library ships its own texts.
407
+ * @param {string} locale
408
+ * @param {Record<string, any>} messages
409
+ * @param {{ defaults?: boolean }} [options]
410
+ * @returns {void}
411
+ */
412
+ add(locale, messages, options = {}) {
413
+ if (typeof locale !== 'string' || !isPlainObject(messages)) return;
414
+ const layer = options.defaults ? this._defaults : this._app;
415
+ if (!layer.has(locale)) layer.set(locale, {});
416
+ mergeMessages(/** @type {Record<string, any>} */ (layer.get(locale)), messages);
417
+ this._version++;
418
+ this._scheduleRefresh();
419
+ }
420
+
421
+ /**
422
+ * Load a namespace (`/locales/<locale>/<namespace>.json`) for the active locale and the
423
+ * fallback. It stays loaded: later locale changes load it too. Elements and props that
424
+ * use its keys update when it arrives.
425
+ * @param {string} namespace
426
+ * @returns {Promise<void>}
427
+ */
428
+ async load(namespace) {
429
+ if (typeof namespace !== 'string' || !namespace) return;
430
+ this._namespaces.add(namespace);
431
+ if (!this.enabled) return;
432
+ const switchId = this._switchId;
433
+ const localeAtStart = this.locale;
434
+ const [active, fallback] = await Promise.allSettled([
435
+ this._fetch(this.locale, namespace),
436
+ this.fallbackLocale ? this._fetch(this.fallbackLocale, namespace) : Promise.resolve(),
437
+ ]);
438
+ if (fallback.status === 'rejected') {
439
+ this._log('warn', `Could not load "${namespace}" for the fallback "${this.fallbackLocale}"`, fallback.reason);
440
+ }
441
+ // A locale switch finished while the namespace was in flight: fetch it for the new
442
+ // locale too, or the screen would keep the previous language until the next switch.
443
+ if (switchId !== this._switchId && this.locale !== localeAtStart) {
444
+ await this._fetch(this.locale, namespace).catch((error) =>
445
+ this._log('warn', `Could not load "${namespace}" for "${this.locale}"`, error)
446
+ );
447
+ if (this.fallbackLocale && this.fallbackLocale !== this.locale) {
448
+ await this._fetch(this.fallbackLocale, namespace).catch(() => {});
449
+ }
450
+ }
451
+ this._refresh();
452
+ if (active.status === 'rejected') throw active.reason;
453
+ }
454
+
455
+ /**
456
+ * Change the active locale. Loads what it needs first: on failure the locale does not
457
+ * change and the promise rejects. Then updates `<html lang|dir>`, saves the choice,
458
+ * re-translates the document, re-assigns marker props, re-applies the route's metadata,
459
+ * calls `watch()` callbacks and emits `translator:change`.
460
+ * @param {string} locale
461
+ * @returns {Promise<string>} the active locale
462
+ */
463
+ async setLocale(locale) {
464
+ if (!this.enabled) {
465
+ this._log('warn', 'setLocale() needs "translator": { "enabled": true } in sliceConfig.json');
466
+ return this.locale;
467
+ }
468
+ const next = matchLocale(locale, /** @type {string[]} */ ([...this.locales]));
469
+ if (!next) {
470
+ throw new Error(`[slice.translator] "${locale}" is not one of the configured locales (${this.locales.join(', ')})`);
471
+ }
472
+ const switchId = ++this._switchId;
473
+ if (next === this.locale) return this.locale;
474
+
475
+ await this._loadLocale(next);
476
+ // A later setLocale() started while this one was loading: that one wins.
477
+ if (switchId !== this._switchId) return this.locale;
478
+
479
+ const previous = this.locale;
480
+ this.locale = next;
481
+ this._version++;
482
+ this._applyDocumentLocale();
483
+ if (this.saveLocally) this._save(next);
484
+ this.syncUrl();
485
+
486
+ this._refresh();
487
+ this._notify(previous);
488
+ return this.locale;
489
+ }
490
+
491
+ /**
492
+ * Load what `locale` needs without switching to it, so a navigation can push the new URL
493
+ * only once the dictionaries are in (setLocale() then switches without waiting).
494
+ * Rejects for a locale that is not configured, or when its main file fails.
495
+ * @param {string} locale
496
+ * @returns {Promise<string>} the configured locale matched
497
+ */
498
+ async preload(locale) {
499
+ const match = matchLocale(locale, /** @type {string[]} */ ([...this.locales]));
500
+ if (!match) {
501
+ throw new Error(`[slice.translator] "${locale}" is not one of the configured locales (${this.locales.join(', ')})`);
502
+ }
503
+ if (this.enabled) await this._loadLocale(match);
504
+ return match;
505
+ }
506
+
507
+ // ───────────────────────────── Props ─────────────────────────────
508
+
509
+ /**
510
+ * A reference to a key, for a component prop. The component receives the translated
511
+ * string, and receives it again when the locale changes. With the translator off it is
512
+ * the plain key: a marker stored in a prop would leak into comparisons and JSON.
513
+ * @param {string} key
514
+ * @param {Record<string, any>} [params]
515
+ * @returns {TranslationRef|string}
516
+ */
517
+ ref(key, params) {
518
+ if (!this.enabled) return String(key ?? '');
519
+ return new TranslationRef(key, params, () =>
520
+ this._warnOnce(`marker:${key}`, `slice.t.ref("${key}") was used as a string; pass it as a prop value, or use slice.t() for text`)
521
+ );
522
+ }
523
+
524
+ /**
525
+ * A reference to a *list* of messages, for a component prop. The component receives the
526
+ * array, and receives a fresh one when the locale changes. With the translator off it is
527
+ * the plain key, like `ref()`.
528
+ * @param {string} key
529
+ * @param {Record<string, any>} [params]
530
+ * @returns {TranslationListRef|string}
531
+ */
532
+ refList(key, params) {
533
+ if (!this.enabled) return String(key ?? '');
534
+ return new TranslationListRef(key, params, () =>
535
+ this._warnOnce(`marker-list:${key}`, `slice.t.ref.list("${key}") was used as a string; pass it as a prop value`)
536
+ );
537
+ }
538
+
539
+ /**
540
+ * Whether a value is (or contains) a `key()` marker.
541
+ * @param {unknown} value
542
+ * @returns {boolean}
543
+ */
544
+ isRef(value) {
545
+ return containsMarker(value);
546
+ }
547
+
548
+ /**
549
+ * Resolve every marker in a value (deep, through arrays and plain objects).
550
+ * @param {any} value
551
+ * @returns {any}
552
+ */
553
+ resolve(value) {
554
+ return resolveMarkers(
555
+ value,
556
+ (key, params) => this.t(key, params),
557
+ 0,
558
+ (key, params) => this.list(key, params)
559
+ );
560
+ }
561
+
562
+ /**
563
+ * Wrap the setters of a component class's `static props` so that a later assignment of a
564
+ * marker (`this.$count.label = slice.t.ref(…)`) resolves and refreshes too.
565
+ * Once per class; invisible for any value that is not a marker. Called by the Controller.
566
+ * @param {Function} ComponentClass
567
+ * @returns {void}
568
+ */
569
+ prepareClass(ComponentClass) {
570
+ if (!this.enabled || typeof ComponentClass !== 'function' || this._wrappedProps.has(ComponentClass)) return;
571
+ /** @type {Set<string>} */
572
+ const wrapped = new Set();
573
+ this._wrappedProps.set(ComponentClass, wrapped);
574
+
575
+ const staticProps = /** @type {any} */ (ComponentClass).props;
576
+ if (!isPlainObject(staticProps)) return;
577
+
578
+ const translator = this;
579
+ for (const prop of Object.keys(staticProps)) {
580
+ const found = findAccessor(ComponentClass.prototype, prop);
581
+ if (!found || typeof found.descriptor.set !== 'function') continue;
582
+ const { descriptor } = found;
583
+ if (/** @type {any} */ (descriptor.set)[WRAPPED]) {
584
+ wrapped.add(prop);
585
+ continue;
586
+ }
587
+ const originalSet = descriptor.set;
588
+ const set = function (/** @type {any} */ value) {
589
+ // The runtime has one translator; prefer the live one if it was replaced.
590
+ const active = globalThis.slice?.translator instanceof Translator ? globalThis.slice.translator : translator;
591
+ return originalSet.call(this, active._track(this, prop, value));
592
+ };
593
+ Object.defineProperty(set, WRAPPED, { value: true });
594
+ Object.defineProperty(ComponentClass.prototype, prop, {
595
+ get: descriptor.get,
596
+ set,
597
+ enumerable: descriptor.enumerable,
598
+ configurable: true,
599
+ });
600
+ wrapped.add(prop);
601
+ }
602
+ }
603
+
604
+ /**
605
+ * The value the Controller assigns for `prop`. A prop whose setter is wrapped gets the raw
606
+ * value (the setter resolves it); any other prop gets the value resolved here, and its
607
+ * markers are remembered for the next locale change.
608
+ * @param {object} component
609
+ * @param {string} prop
610
+ * @param {any} value
611
+ * @returns {any}
612
+ */
613
+ propValue(component, prop, value) {
614
+ if (this._isWrapped(component, prop)) return value;
615
+ return this._track(component, prop, value);
616
+ }
617
+
618
+ /**
619
+ * Validation sees what the component will receive: strings, not markers.
620
+ * @param {Record<string, any>} props
621
+ * @returns {Record<string, any>}
622
+ */
623
+ resolveProps(props) {
624
+ return containsMarker(props) ? this.resolve(props) : props;
625
+ }
626
+
627
+ // ───────────────────────────── URLs (§7) ─────────────────────────────
628
+
629
+ /**
630
+ * Split a known locale prefix off a URL: `/en/pricing` → `{ locale: 'en', path: '/pricing' }`.
631
+ * Without `localePrefix`, the URL is returned whole with `locale: null`.
632
+ * @param {string} url
633
+ * @returns {{ locale: string|null, path: string }}
634
+ */
635
+ splitPath(url) {
636
+ if (!this.localePrefix) return { locale: null, path: typeof url === 'string' && url ? url : '/' };
637
+ return splitLocalePath(url, this.locales);
638
+ }
639
+
640
+ /**
641
+ * The URL of an app path in a locale (default: the active one). Leaves alone what is not
642
+ * an app path (`https://…`, `mailto:`, `#top`, relative paths) and paths that already
643
+ * carry a locale.
644
+ * @param {string} path
645
+ * @param {string} [locale]
646
+ * @returns {string}
647
+ */
648
+ localizePath(path, locale = this.locale) {
649
+ if (!this.localePrefix || isExternalHref(path)) return path;
650
+ if (splitLocalePath(path, this.locales).locale) return path;
651
+ return localizePath(path, locale, { localePrefix: this.localePrefix, defaultLocale: this.defaultLocale });
652
+ }
653
+
654
+ /**
655
+ * The locale a URL stands for (see `initialLocale()`); null without `localePrefix`.
656
+ * @param {string} url
657
+ * @returns {string|null}
658
+ */
659
+ localeOfUrl(url) {
660
+ return localeOfUrl(url, { localePrefix: this.localePrefix, defaultLocale: this.defaultLocale, locales: this.locales });
661
+ }
662
+
663
+ // ───────────────────────────── DOM ─────────────────────────────
664
+
665
+ /**
666
+ * Translate every `data-t` element under `root` (default: the document) now, instead of
667
+ * when the observer runs — for code that reads a text right after creating it.
668
+ * @param {ParentNode} [root]
669
+ * @returns {number} elements written
670
+ */
671
+ apply(root) {
672
+ if (!this.enabled || !this._ready) return 0;
673
+ const target = root || (typeof document !== 'undefined' ? document.documentElement : null);
674
+ if (!target) return 0;
675
+ return translateTree(target, this._dom);
676
+ }
677
+
678
+ // ───────────────────────────── Outside the DOM ─────────────────────────────
679
+
680
+ /**
681
+ * Run `callback(locale, previous)` after every locale change, for texts that live outside
682
+ * the DOM (a chart drawn on a canvas, a library that copied a string). With a component,
683
+ * the subscription ends when the component is destroyed. Returns an unsubscribe function.
684
+ * @param {object|((locale: string, previous: string) => void)} componentOrCallback
685
+ * @param {(locale: string, previous: string) => void} [callback]
686
+ * @returns {() => void}
687
+ */
688
+ watch(componentOrCallback, callback) {
689
+ if (typeof componentOrCallback === 'function') {
690
+ const fn = componentOrCallback;
691
+ this._globalWatchers.add(fn);
692
+ return () => this._globalWatchers.delete(fn);
693
+ }
694
+ const component = componentOrCallback;
695
+ if (!component || typeof callback !== 'function') return () => {};
696
+ let set = this._watchers.get(component);
697
+ if (!set) {
698
+ set = new Set();
699
+ this._watchers.set(component, set);
700
+ }
701
+ set.add(callback);
702
+ return () => {
703
+ const current = this._watchers.get(component);
704
+ current?.delete(callback);
705
+ if (current && current.size === 0) this._watchers.delete(component);
706
+ };
707
+ }
708
+
709
+ /**
710
+ * Drop everything held for a component. Called by the Controller when it is destroyed.
711
+ * @param {object} component
712
+ * @returns {void}
713
+ */
714
+ forget(component) {
715
+ this._watchers.delete(component);
716
+ this._markerProps.delete(component);
717
+ }
718
+
719
+ // ───────────────────────────── Formatting ─────────────────────────────
720
+
721
+ /**
722
+ * @private
723
+ */
724
+ _createFormat() {
725
+ const intl = this._intl;
726
+ const locale = () => this.locale;
727
+ const toDate = (/** @type {any} */ value) => (value instanceof Date ? value : new Date(value));
728
+ // A bad locale, an invalid date or a missing currency must not throw from a formatter:
729
+ // fall back to a readable value.
730
+ const safe = (format, fallback) => (...args) => {
731
+ try {
732
+ return format(...args);
733
+ } catch {
734
+ return fallback(...args);
735
+ }
736
+ };
737
+ return Object.freeze({
738
+ /** @param {number} value @param {Intl.NumberFormatOptions} [options] */
739
+ number: safe(
740
+ (value, options = {}) => intl.get(Intl.NumberFormat, locale(), options).format(value),
741
+ (value) => String(value)
742
+ ),
743
+ /** @param {number} value @param {string} currency @param {Intl.NumberFormatOptions} [options] */
744
+ currency: safe(
745
+ (value, currency, options = {}) =>
746
+ intl.get(Intl.NumberFormat, locale(), { style: 'currency', currency, ...options }).format(value),
747
+ (value, currency) => `${value} ${currency || ''}`.trim()
748
+ ),
749
+ /** @param {number} value 0.25 → 25 % @param {Intl.NumberFormatOptions} [options] */
750
+ percent: safe(
751
+ (value, options = {}) => intl.get(Intl.NumberFormat, locale(), { style: 'percent', ...options }).format(value),
752
+ (value, options = {}) => `${Math.round(Number(value) * 100)}%`
753
+ ),
754
+ /** @param {Date|number|string} value @param {Intl.DateTimeFormatOptions} [options] */
755
+ date: safe(
756
+ (value, options = { dateStyle: 'medium' }) => intl.get(Intl.DateTimeFormat, locale(), options).format(toDate(value)),
757
+ (value) => String(value)
758
+ ),
759
+ /** @param {Date|number|string} value @param {Intl.DateTimeFormatOptions} [options] */
760
+ time: safe(
761
+ (value, options = { timeStyle: 'short' }) => intl.get(Intl.DateTimeFormat, locale(), options).format(toDate(value)),
762
+ (value) => String(value)
763
+ ),
764
+ /** @param {number} value @param {Intl.RelativeTimeFormatUnit} unit @param {Intl.RelativeTimeFormatOptions} [options] */
765
+ relative: safe(
766
+ (value, unit, options = { numeric: 'auto' }) => intl.get(Intl.RelativeTimeFormat, locale(), options).format(value, unit),
767
+ (value, unit) => `${value} ${unit}`
768
+ ),
769
+ /** @param {string[]} items @param {Intl.ListFormatOptions} [options] */
770
+ list: safe(
771
+ (items, options = { type: 'conjunction' }) => intl.get(Intl.ListFormat, locale(), options).format(items),
772
+ (items) => (Array.isArray(items) ? items.join(', ') : String(items))
773
+ ),
774
+ });
775
+ }
776
+
777
+ // ───────────────────────────── Internals ─────────────────────────────
778
+
779
+ /** @private @returns {string[]} */
780
+ _chain() {
781
+ return this.fallbackLocale && this.fallbackLocale !== this.locale ? [this.locale, this.fallbackLocale] : [this.locale];
782
+ }
783
+
784
+ /** @private */
785
+ _entry(key, locale) {
786
+ const app = lookup(this._app.get(locale), key);
787
+ if (app !== undefined) return app;
788
+ return lookup(this._defaults.get(locale), key);
789
+ }
790
+
791
+ /**
792
+ * Load the main file of `locale` and every namespace in use, plus the fallback's.
793
+ * Rejects when the locale's main file fails; a failing fallback only warns.
794
+ * @private
795
+ * @param {string} locale
796
+ */
797
+ async _loadLocale(locale) {
798
+ const namespaces = [...this._namespaces];
799
+ // Only the main file is fatal. One broken optional namespace must not block every
800
+ // future setLocale() (load() adds it to `_namespaces` before the fetch).
801
+ const main = this._fetch(locale);
802
+ const activeNamespaces = Promise.allSettled(namespaces.map((ns) => this._fetch(locale, ns)));
803
+ const fallbackFiles =
804
+ this.fallbackLocale && this.fallbackLocale !== locale
805
+ ? [this._fetch(this.fallbackLocale), ...namespaces.map((ns) => this._fetch(this.fallbackLocale, ns))]
806
+ : [];
807
+ const [activeResults, fallbackResults] = await Promise.all([
808
+ activeNamespaces,
809
+ fallbackFiles.length ? Promise.allSettled(fallbackFiles) : Promise.resolve([])
810
+ ]);
811
+ await main;
812
+ for (const result of /** @type {PromiseSettledResult<void>[]} */ (activeResults)) {
813
+ if (result.status === 'rejected') this._log('warn', `Namespace dictionary failed (${locale})`, result.reason);
814
+ }
815
+ for (const result of /** @type {PromiseSettledResult<void>[]} */ (fallbackResults)) {
816
+ if (result.status === 'rejected') this._log('warn', `Fallback dictionary failed (${this.fallbackLocale})`, result.reason);
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Fetch one content document once (`<path>/content/<name>.json`). A failure warns and
822
+ * resolves to `undefined`; it is removed from the cache so a later call can retry.
823
+ * @private
824
+ * @param {string} name
825
+ * @returns {Promise<Record<string, any>|undefined>}
826
+ */
827
+ _loadContent(name) {
828
+ const cached = this._content.get(name);
829
+ if (cached) return cached;
830
+
831
+ const url = `${this.path}/content/${name}.json`;
832
+ const request = (async () => {
833
+ const response = await fetch(url);
834
+ if (!response.ok) throw new Error(`${url}: ${response.status} ${response.statusText}`);
835
+ const json = await response.json();
836
+ if (!isPlainObject(json)) throw new Error(`${url} is not a JSON object`);
837
+ this._version++;
838
+ return json;
839
+ })()
840
+ .catch((error) => {
841
+ this._log('warn', `Could not load content "${name}"`, error);
842
+ this._content.delete(name);
843
+ return undefined;
844
+ });
845
+
846
+ this._content.set(name, request);
847
+ return request;
848
+ }
849
+
850
+ /**
851
+ * Fetch one dictionary file once and merge it into the app layer.
852
+ * @private
853
+ * @param {string} locale
854
+ * @param {string} [namespace]
855
+ * @returns {Promise<void>}
856
+ */
857
+ _fetch(locale, namespace) {
858
+ const id = namespace ? `${locale}/${namespace}` : locale;
859
+ if (this._loaded.has(id)) return Promise.resolve();
860
+ const pending = this._requests.get(id);
861
+ if (pending) return pending;
862
+
863
+ const url = `${this.path}/${id}.json`;
864
+ const request = (async () => {
865
+ const response = await fetch(url);
866
+ if (!response.ok) throw new Error(`${url}: ${response.status} ${response.statusText}`);
867
+ const json = await response.json();
868
+ if (!isPlainObject(json)) throw new Error(`${url} is not a JSON object`);
869
+ if (!this._app.has(locale)) this._app.set(locale, {});
870
+ mergeMessages(/** @type {Record<string, any>} */ (this._app.get(locale)), namespace ? { [namespace]: json } : json);
871
+ this._loaded.add(id);
872
+ this._version++;
873
+ })().finally(() => this._requests.delete(id));
874
+
875
+ this._requests.set(id, request);
876
+ return request;
877
+ }
878
+
879
+ /** @private */
880
+ _isWrapped(component, prop) {
881
+ const Class = component?.constructor;
882
+ return !!Class && !!this._wrappedProps.get(Class)?.has(prop);
883
+ }
884
+
885
+ /**
886
+ * Remember (or forget) a prop's markers, and return what the component receives.
887
+ * @private
888
+ */
889
+ _track(component, prop, value) {
890
+ if (containsMarker(value)) {
891
+ let record = this._markerProps.get(component);
892
+ if (!record) {
893
+ record = new Map();
894
+ this._markerProps.set(component, record);
895
+ }
896
+ record.set(prop, value);
897
+ return this.resolve(value);
898
+ }
899
+ // A plain value replaces a marker: stop refreshing that prop.
900
+ if (!this._refreshing) this._markerProps.get(component)?.delete(prop);
901
+ return value;
902
+ }
903
+
904
+ /**
905
+ * Re-translate the document, re-assign marker props and re-apply the route metadata.
906
+ * @private
907
+ */
908
+ _refresh() {
909
+ if (!this.enabled || !this._ready) return;
910
+ this.apply();
911
+ this._refreshProps();
912
+ this._refreshRouteMetadata();
913
+ }
914
+
915
+ /** @private Coalesce several add() calls in the same tick into one refresh. */
916
+ _scheduleRefresh() {
917
+ if (!this._ready || this._refreshScheduled) return;
918
+ this._refreshScheduled = true;
919
+ queueMicrotask(() => {
920
+ this._refreshScheduled = false;
921
+ this._refresh();
922
+ });
923
+ }
924
+
925
+ /** @private Parents before children, so a parent that rebuilds its children goes first. */
926
+ _refreshProps() {
927
+ const active = /** @type {Map<string, any>|undefined} */ (globalThis.slice?.controller?.activeComponents);
928
+ if (!active) return;
929
+ const components = [...active.values()]
930
+ .filter((component) => this._markerProps.has(component))
931
+ .sort((a, b) => (a._depth || 0) - (b._depth || 0));
932
+
933
+ for (const component of components) {
934
+ const record = this._markerProps.get(component);
935
+ if (!record) continue;
936
+ for (const [prop, raw] of [...record]) {
937
+ this._refreshing = true;
938
+ try {
939
+ component[prop] = this._isWrapped(component, prop) ? raw : this.resolve(raw);
940
+ } catch (error) {
941
+ this._log('error', `Could not refresh "${prop}" of ${component.sliceId || component.constructor?.name}`, error);
942
+ } finally {
943
+ this._refreshing = false;
944
+ }
945
+ }
946
+ }
947
+ }
948
+
949
+ /** @private */
950
+ _refreshRouteMetadata() {
951
+ const s = globalThis.slice;
952
+ if (!s?.router || s.routerConfig?.updateTitles === false) return;
953
+ const metadata = s.router.activeMetadata;
954
+ if (metadata && typeof s.router.applyMetadata === 'function') s.router.applyMetadata(metadata);
955
+ }
956
+
957
+ /**
958
+ * @private
959
+ * @param {string} previous
960
+ */
961
+ _notify(previous) {
962
+ const s = globalThis.slice;
963
+ const alive = (/** @type {any} */ component) =>
964
+ typeof s?.isComponentAlive === 'function' ? s.isComponentAlive(component) : true;
965
+
966
+ const components = [...this._watchers.keys()].sort((a, b) => (a._depth || 0) - (b._depth || 0));
967
+ for (const component of components) {
968
+ if (!alive(component)) {
969
+ this._watchers.delete(component);
970
+ continue;
971
+ }
972
+ for (const callback of [...(this._watchers.get(component) || [])]) {
973
+ this._safeCall(callback, previous);
974
+ }
975
+ }
976
+ for (const callback of [...this._globalWatchers]) this._safeCall(callback, previous);
977
+
978
+ const payload = { locale: this.locale, previous };
979
+ if (s?.eventsConfig?.enabled && typeof s.events?.emit === 'function') {
980
+ s.events.emit('translator:change', payload);
981
+ } else if (typeof window !== 'undefined' && typeof CustomEvent === 'function') {
982
+ window.dispatchEvent(new CustomEvent('translator:change', { detail: payload }));
983
+ }
984
+ }
985
+
986
+ /**
987
+ * @private
988
+ * @param {(locale: string, previous: string) => void} callback
989
+ * @param {string} previous
990
+ */
991
+ _safeCall(callback, previous) {
992
+ try {
993
+ callback(this.locale, previous);
994
+ } catch (error) {
995
+ this._log('error', 'A translator.watch() callback failed', error);
996
+ }
997
+ }
998
+
999
+ /** @private */
1000
+ _observe() {
1001
+ if (this._observer || typeof MutationObserver === 'undefined' || typeof document === 'undefined') return;
1002
+ this._observer = new MutationObserver((records) => {
1003
+ try {
1004
+ for (const record of records) {
1005
+ if (record.type === 'childList') {
1006
+ for (const node of record.addedNodes) {
1007
+ if (node.nodeType === 1) translateTree(/** @type {Element} */ (node), this._dom);
1008
+ }
1009
+ } else if (record.attributeName && (record.attributeName === 'data-t' || record.attributeName.startsWith('data-t-')) && record.target.isConnected) {
1010
+ translateTree(/** @type {Element} */ (record.target), this._dom);
1011
+ }
1012
+ }
1013
+ } catch (error) {
1014
+ this._log('error', 'Error translating new or changed elements', error);
1015
+ }
1016
+ });
1017
+ // No attributeFilter: params are open-ended (`data-t-count`, `data-t-name`…). The
1018
+ // callback ignores every other attribute with one string check.
1019
+ this._observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true });
1020
+ }
1021
+
1022
+ /**
1023
+ * Make the address bar show the active locale (replaceState: no navigation, no history
1024
+ * entry). A no-op when it already does, so the router can call setLocale() after pushing
1025
+ * the target URL. Called by the router after every route change.
1026
+ * @returns {void}
1027
+ */
1028
+ syncUrl() {
1029
+ if (!this.localePrefix || typeof location === 'undefined' || typeof history === 'undefined') return;
1030
+ const current = `${location.pathname}${location.search}${location.hash}`;
1031
+ // Never rewrite a URL the router cannot resolve: `/fr/x` must stay a 404, not become
1032
+ // `/es/fr/x` (and lose the miss).
1033
+ const router = globalThis.slice?.router;
1034
+ if (typeof router?.matchRoute === 'function') {
1035
+ const match = router.matchRoute(current);
1036
+ if (!match?.route || match.route.path === '/404') return;
1037
+ }
1038
+ const { path } = splitLocalePath(current, this.locales);
1039
+ const target = localizePath(path, this.locale, { localePrefix: this.localePrefix, defaultLocale: this.defaultLocale });
1040
+ if (target !== current) history.replaceState(history.state, '', target);
1041
+ }
1042
+
1043
+ /** @private */
1044
+ _applyDocumentLocale() {
1045
+ if (typeof document === 'undefined') return;
1046
+ const html = document.documentElement;
1047
+ if (html.lang !== this.locale) html.lang = this.locale;
1048
+ const dir = directionOf(this.locale);
1049
+ if (html.dir !== dir) html.dir = dir;
1050
+ }
1051
+
1052
+ /** @private @returns {string|null} */
1053
+ _readSaved() {
1054
+ try {
1055
+ return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null;
1056
+ } catch {
1057
+ return null;
1058
+ }
1059
+ }
1060
+
1061
+ /** @private */
1062
+ _save(locale) {
1063
+ try {
1064
+ globalThis.localStorage?.setItem(STORAGE_KEY, locale);
1065
+ } catch {
1066
+ // storage refused (private mode, quota): the choice lasts for this page only
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Development warning, once per id.
1072
+ * @private
1073
+ */
1074
+ _warnOnce(id, message) {
1075
+ const s = globalThis.slice;
1076
+ if (typeof s?.isProduction === 'function' && s.isProduction()) return;
1077
+ if (this._warned.has(id)) return;
1078
+ this._warned.add(id);
1079
+ this._log('warn', message);
1080
+ }
1081
+
1082
+ /** @private */
1083
+ _log(level, message, error) {
1084
+ const logger = globalThis.slice?.logger;
1085
+ if (level === 'error') {
1086
+ if (logger?.logError) logger.logError('Translator', message, error);
1087
+ else console.error(`[slice.translator] ${message}`, error ?? '');
1088
+ } else if (logger?.logWarning) {
1089
+ logger.logWarning('Translator', message, error);
1090
+ } else {
1091
+ console.warn(`[slice.translator] ${message}`, error ?? '');
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ /**
1097
+ * Find an accessor along the prototype chain (stops before HTMLElement and friends).
1098
+ * @param {object} proto
1099
+ * @param {string} prop
1100
+ * @returns {{ owner: object, descriptor: PropertyDescriptor }|null}
1101
+ */
1102
+ function findAccessor(proto, prop) {
1103
+ let current = proto;
1104
+ while (current && current !== Object.prototype) {
1105
+ if (typeof HTMLElement !== 'undefined' && current === HTMLElement.prototype) return null;
1106
+ const descriptor = Object.getOwnPropertyDescriptor(current, prop);
1107
+ if (descriptor) return descriptor.get || descriptor.set ? { owner: current, descriptor } : null;
1108
+ current = Object.getPrototypeOf(current);
1109
+ }
1110
+ return null;
1111
+ }