slicejs-web-framework 4.1.0 → 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.
- package/Slice/Components/Structural/Controller/Controller.js +1427 -1406
- package/Slice/Components/Structural/EventManager/EventManager.js +652 -651
- package/Slice/Components/Structural/Router/Router.js +982 -820
- package/Slice/Components/Structural/Router/applyRouteMetadata.js +151 -8
- package/Slice/Components/Structural/Translator/Translator.js +1111 -0
- package/Slice/Components/Structural/Translator/TranslatorDebugger.js +511 -0
- package/Slice/Components/Structural/Translator/content.js +188 -0
- package/Slice/Components/Structural/Translator/dom.js +253 -0
- package/Slice/Components/Structural/Translator/localePath.js +90 -0
- package/Slice/Components/Structural/Translator/localized.js +80 -0
- package/Slice/Components/Structural/Translator/markers.js +148 -0
- package/Slice/Components/Structural/Translator/messages.js +211 -0
- package/Slice/Slice.js +826 -711
- package/api/framework/content.js +73 -0
- package/api/framework/seo.js +394 -43
- package/api/framework/server.js +205 -28
- package/package.json +2 -1
- package/sliceConfig.schema.json +150 -122
- package/types/index.d.ts +347 -219
|
@@ -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
|
+
}
|