@dokebi/template-inertia 0.4.1 → 0.4.4
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/AI.md +138 -25
- package/README.md +23 -7
- package/dist/boot-fallback.d.ts +61 -0
- package/dist/boot-fallback.js +131 -0
- package/dist/built-files.d.ts +9 -0
- package/dist/built-files.js +44 -0
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +3 -1
- package/dist/i18n-store.d.ts +64 -0
- package/dist/i18n-store.js +368 -0
- package/dist/i18n.d.ts +216 -0
- package/dist/i18n.js +901 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +458 -61
- package/dist/options.d.ts +28 -0
- package/dist/options.js +97 -1
- package/dist/prune.d.ts +30 -0
- package/dist/prune.js +159 -0
- package/dist/responder.d.ts +14 -4
- package/dist/responder.js +166 -51
- package/dist/vite-deps.d.ts +76 -0
- package/dist/vite-deps.js +249 -0
- package/dist/vite-dev.d.ts +42 -4
- package/dist/vite-dev.js +162 -16
- package/dist/vite-files.d.ts +129 -0
- package/dist/vite-files.js +633 -0
- package/dist/vite-host.js +67 -10
- package/dist/vite-plugin.d.ts +69 -0
- package/dist/vite-plugin.js +173 -0
- package/dist/vite-watch.d.ts +44 -0
- package/dist/vite-watch.js +176 -0
- package/options.schema.json +127 -1
- package/package.json +38 -11
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import { computed, inject, shallowRef, toRaw, } from 'vue';
|
|
2
|
+
import { BundleStore } from './i18n-store.js';
|
|
3
|
+
/** A coded error of the browser runtime; the codes are the server translator's. */
|
|
4
|
+
export class PageI18nError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
plugin = 'dokebi-template-inertia';
|
|
7
|
+
fix;
|
|
8
|
+
constructor(code, message, fix, cause) {
|
|
9
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
10
|
+
this.name = 'PageI18nError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.fix =
|
|
13
|
+
fix ??
|
|
14
|
+
(code === 'UNAVAILABLE'
|
|
15
|
+
? 'Install @dokebi/i18n and set options.dokebi-i18n.http.enabled: true; views/app.js installs pageI18n(props.initialPage, { router }).'
|
|
16
|
+
: code === 'RECOVERY_FAILED'
|
|
17
|
+
? 'The page asked the server for the messages the browser lacked and got no Inertia answer; the document is loaded again without the cookie claim.'
|
|
18
|
+
: 'Check the key, the catalogs under src/shared/locales, the message parameters and the named formats.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Request header naming what this browser holds: `v=<version>; h=<token><token>…`. */
|
|
22
|
+
export const PAGE_I18N_HEADER = 'X-Dokebi-I18n';
|
|
23
|
+
/** Where SSR finds the bundles a full load left out (filled by dokebi-i18n in the same process). */
|
|
24
|
+
const BUNDLES = Symbol.for('dokebi.i18n.page-bundles');
|
|
25
|
+
const TOKEN = 8;
|
|
26
|
+
const RELOADED = 'dokebi-i18n:reloaded';
|
|
27
|
+
const NAME = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/;
|
|
28
|
+
const CATEGORIES = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
|
29
|
+
const MAX_DATE = 8640000000000000;
|
|
30
|
+
/**
|
|
31
|
+
* The defaults of dokebi-i18n's maxKeyLength and maxBranchLength, for a server that does not send
|
|
32
|
+
* them (an older @dokebi/i18n) and for createI18n without them; the page's settings carry the values
|
|
33
|
+
* the server uses, so a catalog the server takes is taken here too.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_KEY_LENGTH = 256;
|
|
36
|
+
const MAX_BRANCH_LENGTH = 16384;
|
|
37
|
+
/** The server's message syntax (dokebi-i18n message.ts), parsed once per message. */
|
|
38
|
+
function parse(text, maxLength) {
|
|
39
|
+
if (typeof text !== 'string' || text.length > maxLength)
|
|
40
|
+
throw new PageI18nError('INVALID_CATALOG', `Message branches must be strings of at most ${maxLength} characters (maxBranchLength)`);
|
|
41
|
+
const parts = [];
|
|
42
|
+
let literal = '';
|
|
43
|
+
for (let index = 0; index < text.length;) {
|
|
44
|
+
const char = text[index];
|
|
45
|
+
if ((char === '{' || char === '}') && text[index + 1] === char) {
|
|
46
|
+
literal += char;
|
|
47
|
+
index += 2;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (char === '{') {
|
|
51
|
+
const end = text.indexOf('}', index + 1);
|
|
52
|
+
const name = text.slice(index + 1, end);
|
|
53
|
+
if (end < 0 || !NAME.test(name))
|
|
54
|
+
throw new PageI18nError('INVALID_CATALOG', 'Invalid named placeholder');
|
|
55
|
+
if (literal) {
|
|
56
|
+
parts.push(literal);
|
|
57
|
+
literal = '';
|
|
58
|
+
}
|
|
59
|
+
parts.push({ name });
|
|
60
|
+
index = end + 1;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
if (char === '}')
|
|
64
|
+
throw new PageI18nError('INVALID_CATALOG', 'Unescaped closing brace');
|
|
65
|
+
literal += char;
|
|
66
|
+
index++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (literal)
|
|
70
|
+
parts.push(literal);
|
|
71
|
+
return parts;
|
|
72
|
+
}
|
|
73
|
+
function compile(input, maxLength) {
|
|
74
|
+
if (typeof input === 'string')
|
|
75
|
+
return { plural: false, branches: new Map([['other', parse(input, maxLength)]]) };
|
|
76
|
+
if (!input ||
|
|
77
|
+
typeof input !== 'object' ||
|
|
78
|
+
Array.isArray(input) ||
|
|
79
|
+
typeof input.other !== 'string' ||
|
|
80
|
+
Object.keys(input).some((key) => !CATEGORIES.has(key)))
|
|
81
|
+
throw new PageI18nError('INVALID_CATALOG', 'Plural messages require other and only cardinal categories');
|
|
82
|
+
return {
|
|
83
|
+
plural: true,
|
|
84
|
+
branches: new Map(Object.entries(input).map(([key, value]) => [key, parse(value, maxLength)])),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function parameter(parameters, name) {
|
|
88
|
+
if (!parameters || typeof parameters !== 'object')
|
|
89
|
+
throw new PageI18nError('INVALID_PARAMETER', 'Missing or invalid message parameters');
|
|
90
|
+
// Own data properties only (a Vue reactive object answers through its proxy, as data).
|
|
91
|
+
const descriptor = Object.getOwnPropertyDescriptor(parameters, name);
|
|
92
|
+
const value = descriptor && 'value' in descriptor ? descriptor.value : undefined;
|
|
93
|
+
if (typeof value !== 'string' && (typeof value !== 'number' || !Number.isFinite(value)))
|
|
94
|
+
throw new PageI18nError('INVALID_PARAMETER', 'Message parameters must be own strings or finite numbers');
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
/** Flat keys from nested namespace objects: { auth: { password: { minimum } } } → auth.password.minimum. */
|
|
98
|
+
function flatten(tree, prefix, target, depth) {
|
|
99
|
+
if (depth > 16 || !tree || typeof tree !== 'object' || Array.isArray(tree))
|
|
100
|
+
throw new PageI18nError('INVALID_CATALOG', 'Messages must be objects of at most 16 levels');
|
|
101
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
102
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
103
|
+
if (typeof value === 'string' ||
|
|
104
|
+
(value !== null && typeof value === 'object' && Object.hasOwn(value, 'other')))
|
|
105
|
+
target.set(path, value);
|
|
106
|
+
else
|
|
107
|
+
flatten(value, path, target, depth + 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function canonical(locale) {
|
|
111
|
+
if (typeof locale !== 'string' || locale.length < 2 || locale.length > 128)
|
|
112
|
+
throw new PageI18nError('INVALID_LOCALE', 'Expected a BCP 47 locale');
|
|
113
|
+
try {
|
|
114
|
+
return new Intl.Locale(locale).baseName;
|
|
115
|
+
}
|
|
116
|
+
catch (cause) {
|
|
117
|
+
throw new PageI18nError('INVALID_LOCALE', 'Invalid BCP 47 locale', undefined, cause);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function chainOf(locale, known, defaultLocale) {
|
|
121
|
+
const chain = [locale];
|
|
122
|
+
let parent = locale;
|
|
123
|
+
while (parent.includes('-')) {
|
|
124
|
+
parent = parent.slice(0, parent.lastIndexOf('-'));
|
|
125
|
+
if (known(parent))
|
|
126
|
+
chain.push(parent);
|
|
127
|
+
}
|
|
128
|
+
if (!chain.includes(defaultLocale))
|
|
129
|
+
chain.push(defaultLocale);
|
|
130
|
+
return chain;
|
|
131
|
+
}
|
|
132
|
+
function namespaceOf(key) {
|
|
133
|
+
const dot = key.indexOf('.');
|
|
134
|
+
return dot < 0 ? key : key.slice(0, dot);
|
|
135
|
+
}
|
|
136
|
+
function describe(error) {
|
|
137
|
+
return error instanceof Error
|
|
138
|
+
? {
|
|
139
|
+
name: error.name,
|
|
140
|
+
message: error.message,
|
|
141
|
+
stack: error.stack,
|
|
142
|
+
...(error.code ? { code: error.code } : {}),
|
|
143
|
+
}
|
|
144
|
+
: { message: String(error) };
|
|
145
|
+
}
|
|
146
|
+
function bundleOf(token, messages) {
|
|
147
|
+
const entries = new Map();
|
|
148
|
+
for (const [source, byKey] of Object.entries(messages))
|
|
149
|
+
for (const [key, raw] of Object.entries(byKey))
|
|
150
|
+
entries.set(key, { source, raw: raw });
|
|
151
|
+
return { token, entries };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* One app's translations: `t`, `n`, `d` and `has` are bound (destructure them from useI18n()),
|
|
155
|
+
* `locale` and `locales` are read-only refs, so templates re-render when the language changes.
|
|
156
|
+
*/
|
|
157
|
+
export class I18n {
|
|
158
|
+
locale;
|
|
159
|
+
locales;
|
|
160
|
+
/** The language of an I18n made by createI18n (a page's comes from its payload). */
|
|
161
|
+
#locale = shallowRef('');
|
|
162
|
+
#locales;
|
|
163
|
+
/** page locale → namespace → bundle: what this document holds. */
|
|
164
|
+
#bundles = new Map();
|
|
165
|
+
/** createI18n: the given messages per source locale, joined into bundles per locale on use. */
|
|
166
|
+
#sources = new Map();
|
|
167
|
+
#fallback;
|
|
168
|
+
#formats = new Map();
|
|
169
|
+
#defaults = new Map();
|
|
170
|
+
#defaultCharacters = 0;
|
|
171
|
+
#settings;
|
|
172
|
+
#logger;
|
|
173
|
+
#feed;
|
|
174
|
+
/** The last page payload taken in and the catalog version this document holds. */
|
|
175
|
+
#absorbed;
|
|
176
|
+
#version;
|
|
177
|
+
/** The shown page lacks messages; they are being fetched. */
|
|
178
|
+
#pending = false;
|
|
179
|
+
/** Set by pageI18n: how a language switch reaches the server. */
|
|
180
|
+
#switcher;
|
|
181
|
+
/** D-0126: the kept bundles (a browser page whose server allows it). */
|
|
182
|
+
#storeHost;
|
|
183
|
+
#store;
|
|
184
|
+
/** The locale whose tokens the header names first (a language switch in progress). */
|
|
185
|
+
#preferred;
|
|
186
|
+
constructor(options) {
|
|
187
|
+
this.#logger = options.logger;
|
|
188
|
+
const locale = canonical(options.locale);
|
|
189
|
+
this.#settings = {
|
|
190
|
+
defaultLocale: canonical(options.defaultLocale ?? locale),
|
|
191
|
+
missing: options.missing ?? 'error',
|
|
192
|
+
timeZone: options.timeZone ?? 'UTC',
|
|
193
|
+
maxOutputLength: options.maxOutputLength ?? 65536,
|
|
194
|
+
...(options.maxKeyLength === undefined ? {} : { maxKeyLength: options.maxKeyLength }),
|
|
195
|
+
...(options.maxBranchLength === undefined
|
|
196
|
+
? {}
|
|
197
|
+
: { maxBranchLength: options.maxBranchLength }),
|
|
198
|
+
numberFormats: options.numberFormats ?? {},
|
|
199
|
+
dateFormats: options.dateFormats ?? {},
|
|
200
|
+
};
|
|
201
|
+
for (const [name, tree] of Object.entries(options.messages ?? {})) {
|
|
202
|
+
const raw = new Map();
|
|
203
|
+
flatten(tree, '', raw, 0);
|
|
204
|
+
const source = canonical(name);
|
|
205
|
+
const merged = new Map(this.#sources.get(source) ?? []);
|
|
206
|
+
for (const [key, message] of raw)
|
|
207
|
+
merged.set(key, message);
|
|
208
|
+
this.#sources.set(source, merged);
|
|
209
|
+
}
|
|
210
|
+
this.#fallback = options.fallback
|
|
211
|
+
? { locale, chain: Object.freeze(options.fallback.map(canonical)) }
|
|
212
|
+
: undefined;
|
|
213
|
+
this.#locales = Object.freeze([...this.#sources.keys()]);
|
|
214
|
+
this.#locale.value = locale;
|
|
215
|
+
this.locale = computed(() => this.#now());
|
|
216
|
+
this.locales = computed(() => {
|
|
217
|
+
this.#now();
|
|
218
|
+
return this.#locales;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/** Translates `key` with the lookup chain; with `defaultValue`, only the locale's own message wins. */
|
|
222
|
+
t = (key, value, parameters) => {
|
|
223
|
+
const locale = this.#now();
|
|
224
|
+
if (typeof key !== 'string' ||
|
|
225
|
+
key.length === 0 ||
|
|
226
|
+
key.length > (this.#settings.maxKeyLength ?? MAX_KEY_LENGTH))
|
|
227
|
+
throw new PageI18nError('MISSING_MESSAGE', 'Invalid message key');
|
|
228
|
+
const explicitDefault = typeof value === 'string';
|
|
229
|
+
let entry = this.#entry(locale, key);
|
|
230
|
+
// An explicit default wins over every locale but this one.
|
|
231
|
+
if (explicitDefault && entry && entry.source !== locale)
|
|
232
|
+
entry = undefined;
|
|
233
|
+
if (!entry && explicitDefault) {
|
|
234
|
+
let compiled = this.#defaults.get(value);
|
|
235
|
+
if (!compiled) {
|
|
236
|
+
compiled = compile(value, this.#settings.maxBranchLength ?? MAX_BRANCH_LENGTH);
|
|
237
|
+
// Defaults are application literals; bounded like the server's (64 templates, 64 Ki).
|
|
238
|
+
if (this.#defaults.size < 64 && this.#defaultCharacters + value.length <= 65536) {
|
|
239
|
+
this.#defaults.set(value, compiled);
|
|
240
|
+
this.#defaultCharacters += value.length;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return this.#render(compiled, this.#rules(locale), parameters);
|
|
244
|
+
}
|
|
245
|
+
if (!entry) {
|
|
246
|
+
if (this.#pending) {
|
|
247
|
+
// The page's messages are on their way (pageI18n fetches them): the key until then.
|
|
248
|
+
this.#debug('Translation pending, key shown', { code: 'I18N_PAGE_PENDING', locale });
|
|
249
|
+
return key;
|
|
250
|
+
}
|
|
251
|
+
if (this.#settings.missing === 'error')
|
|
252
|
+
throw new PageI18nError('MISSING_MESSAGE', `No message ${JSON.stringify(key)} in the locale fallback chain of ${locale}`, `Add the key to src/shared/locales/<locale>/${key.split('.', 1)[0]}.json, or send that namespace to the page (options.dokebi-i18n.http.pageMessages, or pageMessages(request, '${key.split('.', 1)[0]}') in the route).`);
|
|
253
|
+
if (key.length > this.#settings.maxOutputLength)
|
|
254
|
+
throw new PageI18nError('OUTPUT_LIMIT', 'Missing key exceeds maxOutputLength');
|
|
255
|
+
this.#debug('Missing translation returned as key', { code: 'I18N_MISSING_KEY', locale });
|
|
256
|
+
return key;
|
|
257
|
+
}
|
|
258
|
+
return this.#render(this.#compiled(entry, key), this.#rules(entry.source), explicitDefault ? parameters : value);
|
|
259
|
+
};
|
|
260
|
+
/** True when the lookup chain of the current locale has `key`. */
|
|
261
|
+
has = (key) => this.#entry(this.#now(), key) !== undefined;
|
|
262
|
+
/** A finite number with a named format of the dokebi-i18n entry (`default`: Intl's). */
|
|
263
|
+
n = (value, format = 'default') => {
|
|
264
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
265
|
+
throw new PageI18nError('INVALID_PARAMETER', 'Expected a finite number');
|
|
266
|
+
const locale = this.#now();
|
|
267
|
+
const formats = this.#formatsOf(locale);
|
|
268
|
+
let formatter = formats.numbers.get(format);
|
|
269
|
+
if (!formatter) {
|
|
270
|
+
const preset = format === 'default' ? {} : this.#settings.numberFormats[format];
|
|
271
|
+
if (!preset)
|
|
272
|
+
throw new PageI18nError('INVALID_FORMAT', 'Unknown number format');
|
|
273
|
+
try {
|
|
274
|
+
formatter = new Intl.NumberFormat(locale, preset);
|
|
275
|
+
}
|
|
276
|
+
catch (cause) {
|
|
277
|
+
throw new PageI18nError('INVALID_FORMAT', 'Invalid Intl number format', undefined, cause);
|
|
278
|
+
}
|
|
279
|
+
formats.numbers.set(format, formatter);
|
|
280
|
+
}
|
|
281
|
+
return formatter.format(value);
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* The application's time zone of this page's settings (D-0178: it follows i18n setTimeZone on
|
|
285
|
+
* the server): pass it to the @dokebi/i18n/calendar helpers so pages count days as the server does.
|
|
286
|
+
*/
|
|
287
|
+
timeZone = () => this.#settings.timeZone;
|
|
288
|
+
/** A Date or epoch milliseconds with a named format, in the configured time zone. */
|
|
289
|
+
d = (value, format = 'default') => {
|
|
290
|
+
const time = value instanceof Date ? value.getTime() : value;
|
|
291
|
+
if (typeof time !== 'number' || !Number.isFinite(time) || Math.abs(time) > MAX_DATE)
|
|
292
|
+
throw new PageI18nError('INVALID_PARAMETER', 'Expected a valid Date or epoch milliseconds');
|
|
293
|
+
const locale = this.#now();
|
|
294
|
+
const formats = this.#formatsOf(locale);
|
|
295
|
+
let formatter = formats.dates.get(format);
|
|
296
|
+
if (!formatter) {
|
|
297
|
+
const preset = format === 'default' ? {} : this.#settings.dateFormats[format];
|
|
298
|
+
if (!preset)
|
|
299
|
+
throw new PageI18nError('INVALID_FORMAT', 'Unknown date format');
|
|
300
|
+
try {
|
|
301
|
+
formatter = new Intl.DateTimeFormat(locale, {
|
|
302
|
+
timeZone: this.#settings.timeZone,
|
|
303
|
+
...preset,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch (cause) {
|
|
307
|
+
throw new PageI18nError('INVALID_FORMAT', 'Invalid Intl date format', undefined, cause);
|
|
308
|
+
}
|
|
309
|
+
formats.dates.set(format, formatter);
|
|
310
|
+
}
|
|
311
|
+
return formatter.format(time);
|
|
312
|
+
};
|
|
313
|
+
/** The language's own name (`English`, `Français`) for a language switch. */
|
|
314
|
+
localeName = (locale) => new Intl.DisplayNames([locale], { type: 'language' }).of(locale) ?? locale;
|
|
315
|
+
/**
|
|
316
|
+
* Shows another language. On an Inertia page (pageI18n) it writes the preference cookie and
|
|
317
|
+
* reloads the page's props from the server (no document reload); otherwise the messages of
|
|
318
|
+
* `locale` must be loaded already.
|
|
319
|
+
*/
|
|
320
|
+
switchLocale = async (locale) => {
|
|
321
|
+
const target = canonical(locale);
|
|
322
|
+
if (this.#switcher) {
|
|
323
|
+
// The bundles this browser kept for that language are named in the reload's header.
|
|
324
|
+
this.#preload(target);
|
|
325
|
+
this.#preferred = target;
|
|
326
|
+
return this.#switcher(target);
|
|
327
|
+
}
|
|
328
|
+
if (!this.#sources.has(target))
|
|
329
|
+
throw new PageI18nError('INVALID_LOCALE', `No messages for ${target}`);
|
|
330
|
+
this.#locale.value = target;
|
|
331
|
+
};
|
|
332
|
+
/** Vue plugin: provides this instance to useI18n() and `$t`, `$n`, `$d` to templates. */
|
|
333
|
+
install(app) {
|
|
334
|
+
app.provide(KEY, this);
|
|
335
|
+
const globals = app.config.globalProperties;
|
|
336
|
+
globals.$t = this.t;
|
|
337
|
+
globals.$n = this.n;
|
|
338
|
+
globals.$d = this.d;
|
|
339
|
+
globals.$i18n = this;
|
|
340
|
+
this.#feed?.attach(app);
|
|
341
|
+
}
|
|
342
|
+
/** @internal pageI18n's connection: the page payloads, the language switch and the store. */
|
|
343
|
+
connect(feed, switcher, store) {
|
|
344
|
+
this.#feed = feed;
|
|
345
|
+
this.#switcher = switcher;
|
|
346
|
+
this.#storeHost = store;
|
|
347
|
+
this.#now();
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* @internal The value of X-Dokebi-I18n: the catalog version and the tokens of the bundles this
|
|
351
|
+
* document holds (the shown locale's first), within the server's header limit.
|
|
352
|
+
*/
|
|
353
|
+
heldHeader() {
|
|
354
|
+
const shown = this.#now();
|
|
355
|
+
const head = `v=${this.#version ?? ''}; h=`;
|
|
356
|
+
const room = Math.floor(((this.#settings.maxHeaderBytes ?? 4096) - head.length) / TOKEN);
|
|
357
|
+
const first = this.#preferred ?? shown;
|
|
358
|
+
const tokens = [];
|
|
359
|
+
const add = (locale) => {
|
|
360
|
+
for (const bundle of this.#bundles.get(locale)?.values() ?? [])
|
|
361
|
+
if (bundle.token !== undefined && tokens.length < room)
|
|
362
|
+
tokens.push(bundle.token);
|
|
363
|
+
};
|
|
364
|
+
add(first);
|
|
365
|
+
for (const locale of this.#bundles.keys())
|
|
366
|
+
if (locale !== first)
|
|
367
|
+
add(locale);
|
|
368
|
+
return head + tokens.join('');
|
|
369
|
+
}
|
|
370
|
+
/** @internal */
|
|
371
|
+
get cookie() {
|
|
372
|
+
return this.#settings.cookie;
|
|
373
|
+
}
|
|
374
|
+
/** @internal The claim cookie of the store (settings.cache.cookie), if bundles are kept. */
|
|
375
|
+
get claimCookie() {
|
|
376
|
+
return this.#store ? this.#settings.cache?.cookie : undefined;
|
|
377
|
+
}
|
|
378
|
+
/** @internal The store, for pageI18n and tests (undefined when nothing is kept). */
|
|
379
|
+
get store() {
|
|
380
|
+
return this.#store;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* @internal Takes in the `i18n` of a recovery answer (pageI18n's fetch) and checks the shown
|
|
384
|
+
* page again; true when nothing is missing now.
|
|
385
|
+
*/
|
|
386
|
+
recovered(payload) {
|
|
387
|
+
const shown = this.#absorbed;
|
|
388
|
+
this.#absorb(payload, false);
|
|
389
|
+
if (!shown)
|
|
390
|
+
return !this.#pending;
|
|
391
|
+
return this.#absorb(shown, true);
|
|
392
|
+
}
|
|
393
|
+
/** The current language; takes in the shown page's payload first (pageI18n). */
|
|
394
|
+
#now() {
|
|
395
|
+
const payload = this.#feed?.current();
|
|
396
|
+
if (!payload)
|
|
397
|
+
return this.#locale.value;
|
|
398
|
+
// Vue hands the page through a reactive proxy: read the plain data, track only the page.
|
|
399
|
+
const raw = toRaw(payload);
|
|
400
|
+
if (raw !== this.#absorbed)
|
|
401
|
+
this.#absorb(raw, true);
|
|
402
|
+
return raw.locale;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The messages of one page payload: the bundles it carries replace those of the same locale and
|
|
406
|
+
* namespace (and are kept), and every namespace it needs must then be held: by token for the
|
|
407
|
+
* bundles the server left out because of the cookie (`held`: memory, SSR registry, store), as
|
|
408
|
+
* held for the others (left out because this document named them, or a page from history whose
|
|
409
|
+
* bundles a reload of the document lost: memory, then the newest kept one). When some are not
|
|
410
|
+
* there, pageI18n fetches them and keys show until they arrive (it does not mount before).
|
|
411
|
+
*/
|
|
412
|
+
#absorb(payload, shown) {
|
|
413
|
+
shapeOf(payload);
|
|
414
|
+
if (shown) {
|
|
415
|
+
this.#absorbed = payload;
|
|
416
|
+
// A switch's preference lasts until a page is shown (its answer, or any later page): from
|
|
417
|
+
// then on the shown locale's tokens come first again in a header that may be too short.
|
|
418
|
+
this.#preferred = undefined;
|
|
419
|
+
}
|
|
420
|
+
if (payload.settings && (payload.settings !== this.#appliedSettings || !this.#version))
|
|
421
|
+
this.#apply(payload.settings);
|
|
422
|
+
if (payload.version && payload.settings)
|
|
423
|
+
this.#version = payload.version;
|
|
424
|
+
const locale = payload.locale;
|
|
425
|
+
let held = this.#bundles.get(locale);
|
|
426
|
+
if (!held) {
|
|
427
|
+
held = new Map();
|
|
428
|
+
this.#bundles.set(locale, held);
|
|
429
|
+
}
|
|
430
|
+
// The carried bundles, grouped by namespace (each key's namespace is its first part).
|
|
431
|
+
const carried = new Map();
|
|
432
|
+
for (const namespace of Object.keys(payload.bundles ?? {}))
|
|
433
|
+
carried.set(namespace, {});
|
|
434
|
+
for (const [source, messages] of Object.entries(payload.messages ?? {}))
|
|
435
|
+
for (const [key, message] of Object.entries(messages)) {
|
|
436
|
+
const namespace = namespaceOf(key);
|
|
437
|
+
let fragment = carried.get(namespace);
|
|
438
|
+
if (!fragment) {
|
|
439
|
+
fragment = {};
|
|
440
|
+
carried.set(namespace, fragment);
|
|
441
|
+
}
|
|
442
|
+
let byKey = fragment[source];
|
|
443
|
+
if (!byKey) {
|
|
444
|
+
byKey = {};
|
|
445
|
+
fragment[source] = byKey;
|
|
446
|
+
}
|
|
447
|
+
byKey[key] = message;
|
|
448
|
+
}
|
|
449
|
+
for (const [namespace, fragment] of carried) {
|
|
450
|
+
const token = payload.bundles?.[namespace];
|
|
451
|
+
held.set(namespace, bundleOf(token, fragment));
|
|
452
|
+
if (token !== undefined)
|
|
453
|
+
this.#store?.keep(locale, namespace, token, fragment);
|
|
454
|
+
}
|
|
455
|
+
this.#store?.show(locale);
|
|
456
|
+
let index;
|
|
457
|
+
const missing = [];
|
|
458
|
+
for (const namespace of payload.namespaces) {
|
|
459
|
+
if (carried.has(namespace))
|
|
460
|
+
continue;
|
|
461
|
+
const wanted = payload.held?.[namespace];
|
|
462
|
+
const have = held.get(namespace);
|
|
463
|
+
if (have && (wanted === undefined || have.token === wanted))
|
|
464
|
+
continue;
|
|
465
|
+
const fromServer = wanted === undefined ? undefined : this.#registered(locale, namespace, wanted);
|
|
466
|
+
if (fromServer) {
|
|
467
|
+
held.set(namespace, fromServer);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (this.#store) {
|
|
471
|
+
index ??= this.#store.index();
|
|
472
|
+
const kept = this.#store.read(locale, namespace, wanted, index);
|
|
473
|
+
if (kept) {
|
|
474
|
+
held.set(namespace, bundleOf(kept.token, kept.messages));
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
missing.push(namespace);
|
|
479
|
+
}
|
|
480
|
+
const complete = missing.length === 0;
|
|
481
|
+
if (!complete)
|
|
482
|
+
this.#debug('Page messages missing from this browser', {
|
|
483
|
+
code: 'I18N_PAGE_INCOMPLETE',
|
|
484
|
+
locale,
|
|
485
|
+
missing: missing.length,
|
|
486
|
+
claimed: Object.keys(payload.held ?? {}).length,
|
|
487
|
+
});
|
|
488
|
+
const fetching = this.#feed?.absorbed(payload, complete) ?? false;
|
|
489
|
+
this.#pending = !complete && fetching;
|
|
490
|
+
return complete;
|
|
491
|
+
}
|
|
492
|
+
#appliedSettings;
|
|
493
|
+
#apply(settings) {
|
|
494
|
+
this.#appliedSettings = settings;
|
|
495
|
+
this.#settings = {
|
|
496
|
+
defaultLocale: settings.defaultLocale,
|
|
497
|
+
missing: settings.missing,
|
|
498
|
+
timeZone: settings.timeZone,
|
|
499
|
+
maxOutputLength: settings.maxOutputLength,
|
|
500
|
+
...(settings.maxKeyLength === undefined ? {} : { maxKeyLength: settings.maxKeyLength }),
|
|
501
|
+
...(settings.maxBranchLength === undefined
|
|
502
|
+
? {}
|
|
503
|
+
: { maxBranchLength: settings.maxBranchLength }),
|
|
504
|
+
cookie: settings.cookie,
|
|
505
|
+
numberFormats: settings.numberFormats,
|
|
506
|
+
dateFormats: settings.dateFormats,
|
|
507
|
+
...(settings.maxHeaderBytes === undefined ? {} : { maxHeaderBytes: settings.maxHeaderBytes }),
|
|
508
|
+
...(settings.cache ? { cache: settings.cache } : {}),
|
|
509
|
+
};
|
|
510
|
+
this.#locales = Object.freeze([...settings.locales]);
|
|
511
|
+
// Formats and the time zone may have changed with the version.
|
|
512
|
+
this.#formats.clear();
|
|
513
|
+
const cache = settings.cache;
|
|
514
|
+
if (cache && this.#storeHost) {
|
|
515
|
+
if (this.#store)
|
|
516
|
+
this.#store.limits = cache;
|
|
517
|
+
else {
|
|
518
|
+
this.#store = new BundleStore(this.#storeHost, cache, this.#logger);
|
|
519
|
+
// After the first page is drawn: what this browser kept for the shown locale, so visits
|
|
520
|
+
// name it too.
|
|
521
|
+
this.#store.whenIdle(() => {
|
|
522
|
+
const shown = this.#absorbed?.locale;
|
|
523
|
+
if (shown)
|
|
524
|
+
this.#preload(shown);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
else if (this.#store) {
|
|
529
|
+
this.#debug('The server no longer lets this browser keep page bundles', {
|
|
530
|
+
code: 'I18N_STORE_OFF',
|
|
531
|
+
});
|
|
532
|
+
this.#store = undefined;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/** SSR: the bundle the server left out, from dokebi-i18n in this process. */
|
|
536
|
+
#registered(locale, namespace, token) {
|
|
537
|
+
const registry = globalThis[BUNDLES];
|
|
538
|
+
const slot = registry?.get(`${token}:${locale}:${namespace}`);
|
|
539
|
+
return slot ? bundleOf(token, slot.messages) : undefined;
|
|
540
|
+
}
|
|
541
|
+
/** The kept bundles of `locale` this document does not hold yet, into memory (no network). */
|
|
542
|
+
#preload(locale) {
|
|
543
|
+
const store = this.#store;
|
|
544
|
+
if (!store)
|
|
545
|
+
return;
|
|
546
|
+
let held = this.#bundles.get(locale);
|
|
547
|
+
const index = store.index();
|
|
548
|
+
let loaded = 0;
|
|
549
|
+
for (const kept of index) {
|
|
550
|
+
if (kept.locale !== locale || held?.has(kept.namespace))
|
|
551
|
+
continue;
|
|
552
|
+
const found = store.read(locale, kept.namespace, kept.token, index);
|
|
553
|
+
if (!found)
|
|
554
|
+
continue;
|
|
555
|
+
if (!held) {
|
|
556
|
+
held = new Map();
|
|
557
|
+
this.#bundles.set(locale, held);
|
|
558
|
+
}
|
|
559
|
+
held.set(kept.namespace, bundleOf(found.token, found.messages));
|
|
560
|
+
loaded++;
|
|
561
|
+
}
|
|
562
|
+
if (loaded)
|
|
563
|
+
this.#debug('Kept page bundles loaded', { code: 'I18N_STORE_LOADED', locale, loaded });
|
|
564
|
+
}
|
|
565
|
+
#entry(locale, key) {
|
|
566
|
+
let byNamespace = this.#bundles.get(locale);
|
|
567
|
+
if (!byNamespace && this.#sources.size)
|
|
568
|
+
byNamespace = this.#plain(locale);
|
|
569
|
+
return byNamespace?.get(namespaceOf(key))?.entries.get(key);
|
|
570
|
+
}
|
|
571
|
+
/** createI18n: the bundles of `locale` from the given messages, joined as the server does. */
|
|
572
|
+
#plain(locale) {
|
|
573
|
+
const chain = this.#fallback?.locale === locale
|
|
574
|
+
? this.#fallback.chain
|
|
575
|
+
: chainOf(locale, (name) => this.#sources.has(name), this.#settings.defaultLocale);
|
|
576
|
+
const fragments = new Map();
|
|
577
|
+
const seen = new Set();
|
|
578
|
+
for (const source of chain)
|
|
579
|
+
for (const [key, raw] of this.#sources.get(source) ?? []) {
|
|
580
|
+
if (seen.has(key))
|
|
581
|
+
continue;
|
|
582
|
+
seen.add(key);
|
|
583
|
+
const namespace = namespaceOf(key);
|
|
584
|
+
let entries = fragments.get(namespace);
|
|
585
|
+
if (!entries) {
|
|
586
|
+
entries = new Map();
|
|
587
|
+
fragments.set(namespace, entries);
|
|
588
|
+
}
|
|
589
|
+
entries.set(key, { source, raw });
|
|
590
|
+
}
|
|
591
|
+
const bundles = new Map();
|
|
592
|
+
for (const [namespace, entries] of fragments)
|
|
593
|
+
bundles.set(namespace, { token: undefined, entries });
|
|
594
|
+
this.#bundles.set(locale, bundles);
|
|
595
|
+
return bundles;
|
|
596
|
+
}
|
|
597
|
+
#compiled(entry, key) {
|
|
598
|
+
if (entry.compiled)
|
|
599
|
+
return entry.compiled;
|
|
600
|
+
try {
|
|
601
|
+
entry.compiled = compile(entry.raw, this.#settings.maxBranchLength ?? MAX_BRANCH_LENGTH);
|
|
602
|
+
}
|
|
603
|
+
catch (cause) {
|
|
604
|
+
// Name the message: a syntax error is otherwise hard to find among thousands.
|
|
605
|
+
throw new PageI18nError('INVALID_CATALOG', `Invalid message ${JSON.stringify(key)} in locale ${entry.source}`, undefined, cause);
|
|
606
|
+
}
|
|
607
|
+
return entry.compiled;
|
|
608
|
+
}
|
|
609
|
+
#formatsOf(locale) {
|
|
610
|
+
let formats = this.#formats.get(locale);
|
|
611
|
+
if (!formats) {
|
|
612
|
+
formats = { numbers: new Map(), dates: new Map() };
|
|
613
|
+
this.#formats.set(locale, formats);
|
|
614
|
+
}
|
|
615
|
+
return formats;
|
|
616
|
+
}
|
|
617
|
+
#rules(locale) {
|
|
618
|
+
const formats = this.#formatsOf(locale);
|
|
619
|
+
formats.rules ??= new Intl.PluralRules(locale);
|
|
620
|
+
return formats.rules;
|
|
621
|
+
}
|
|
622
|
+
#render(message, rules, parameters) {
|
|
623
|
+
let category = 'other';
|
|
624
|
+
if (message.plural) {
|
|
625
|
+
const count = parameter(parameters, 'count');
|
|
626
|
+
if (typeof count !== 'number')
|
|
627
|
+
throw new PageI18nError('INVALID_PARAMETER', 'Plural count must be a finite number');
|
|
628
|
+
category = rules.select(count);
|
|
629
|
+
}
|
|
630
|
+
const parts = message.branches.get(category) ?? message.branches.get('other');
|
|
631
|
+
const limit = this.#settings.maxOutputLength;
|
|
632
|
+
let output = '';
|
|
633
|
+
for (const part of parts ?? []) {
|
|
634
|
+
const value = typeof part === 'string' ? part : String(parameter(parameters, part.name));
|
|
635
|
+
if (output.length + value.length > limit)
|
|
636
|
+
throw new PageI18nError('OUTPUT_LIMIT', 'Rendered message exceeds maxOutputLength');
|
|
637
|
+
output += value;
|
|
638
|
+
}
|
|
639
|
+
return output;
|
|
640
|
+
}
|
|
641
|
+
#debug(message, fields) {
|
|
642
|
+
debug(this.#logger, message, fields);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function debug(logger, message, fields) {
|
|
646
|
+
if (logger && (logger.isEnabled?.('debug') ?? true))
|
|
647
|
+
logger.log('debug', message, fields);
|
|
648
|
+
}
|
|
649
|
+
const KEY = Symbol.for('dokebi.template-inertia.i18n');
|
|
650
|
+
/** An I18n for plain Vue apps and tests: the messages are given, nothing is fetched. */
|
|
651
|
+
export function createI18n(options) {
|
|
652
|
+
return new I18n(options);
|
|
653
|
+
}
|
|
654
|
+
export function useI18n(options) {
|
|
655
|
+
const i18n = inject(KEY, undefined);
|
|
656
|
+
if (!i18n && !options?.optional)
|
|
657
|
+
throw new PageI18nError('UNAVAILABLE', 'This page has no translations (the i18n prop is missing)');
|
|
658
|
+
return i18n;
|
|
659
|
+
}
|
|
660
|
+
/** The `i18n` prop of a page, or undefined; a value of another shape is INVALID_CATALOG. */
|
|
661
|
+
function payloadOf(page) {
|
|
662
|
+
const value = page?.props?.i18n;
|
|
663
|
+
if (value === undefined || value === null)
|
|
664
|
+
return undefined;
|
|
665
|
+
return value;
|
|
666
|
+
}
|
|
667
|
+
function tokens(value) {
|
|
668
|
+
if (value === undefined)
|
|
669
|
+
return true;
|
|
670
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
671
|
+
return false;
|
|
672
|
+
for (const token of Object.values(value))
|
|
673
|
+
if (typeof token !== 'string')
|
|
674
|
+
return false;
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
function shapeOf(payload) {
|
|
678
|
+
if (typeof payload !== 'object' ||
|
|
679
|
+
typeof payload.locale !== 'string' ||
|
|
680
|
+
typeof payload.version !== 'string' ||
|
|
681
|
+
!Array.isArray(payload.fallback) ||
|
|
682
|
+
!Array.isArray(payload.namespaces) ||
|
|
683
|
+
!tokens(payload.bundles) ||
|
|
684
|
+
!tokens(payload.held))
|
|
685
|
+
throw new PageI18nError('INVALID_CATALOG', 'The i18n page prop does not have the expected shape');
|
|
686
|
+
}
|
|
687
|
+
/** The browser's localStorage, or undefined where there is none or it is refused. */
|
|
688
|
+
function localStore(browser, logger) {
|
|
689
|
+
try {
|
|
690
|
+
return browser.localStorage ?? undefined;
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
// Reading the property throws where the user blocks site data: nothing is kept.
|
|
694
|
+
debug(logger, 'localStorage refused; page bundles are not kept', {
|
|
695
|
+
code: 'I18N_STORE_UNAVAILABLE',
|
|
696
|
+
error: describe(error),
|
|
697
|
+
});
|
|
698
|
+
return undefined;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* The Vue plugin of an Inertia app (views/app.js and views/ssr.js install it): translations from
|
|
703
|
+
* the `i18n` prop of the page being shown (Inertia's `$page`, from `page` until Inertia's plugin
|
|
704
|
+
* is installed). A new page's messages are taken in where the page first translates, before it
|
|
705
|
+
* is drawn. With `router` (the browser), every visit names what this document holds (header
|
|
706
|
+
* X-Dokebi-I18n) so the server sends only what is new, a page whose messages this document never
|
|
707
|
+
* received is completed with `router.reload({ only: ['i18n'] })`, and switchLocale writes the
|
|
708
|
+
* preference cookie and reloads the props. In a browser it keeps the bundles it received
|
|
709
|
+
* (D-0126) when the server allows it, and when a first page lacks some (the server trusted a
|
|
710
|
+
* cookie the store does not match) it fetches them before `app.mount` mounts the app. Without
|
|
711
|
+
* the prop it installs nothing, so the same entry works before @dokebi/i18n is added.
|
|
712
|
+
*/
|
|
713
|
+
export function pageI18n(page, options = {}) {
|
|
714
|
+
const first = payloadOf(page);
|
|
715
|
+
const logger = options.logger;
|
|
716
|
+
if (!first) {
|
|
717
|
+
debug(logger, 'Page translations are off', { code: 'I18N_PAGE_OFF' });
|
|
718
|
+
// Nothing to provide: useI18n() explains what is missing (UNAVAILABLE).
|
|
719
|
+
return { install(_app) { } };
|
|
720
|
+
}
|
|
721
|
+
shapeOf(first);
|
|
722
|
+
const i18n = new I18n({ locale: first.locale, messages: {}, ...(logger ? { logger } : {}) });
|
|
723
|
+
const browser = globalThis;
|
|
724
|
+
const router = options.router;
|
|
725
|
+
const inBrowser = browser.document !== undefined && browser.location !== undefined;
|
|
726
|
+
const storage = options.storage === false
|
|
727
|
+
? undefined
|
|
728
|
+
: (options.storage ?? (inBrowser ? localStore(browser, logger) : undefined));
|
|
729
|
+
const store = storage
|
|
730
|
+
? {
|
|
731
|
+
storage,
|
|
732
|
+
...(browser.document ? { document: browser.document } : {}),
|
|
733
|
+
secure: browser.location?.protocol === 'https:',
|
|
734
|
+
later: (work) => browser.requestIdleCallback
|
|
735
|
+
? browser.requestIdleCallback(work, { timeout: 2000 })
|
|
736
|
+
: setTimeout(work, 0),
|
|
737
|
+
}
|
|
738
|
+
: undefined;
|
|
739
|
+
let globals;
|
|
740
|
+
let reloading;
|
|
741
|
+
let mounted = false;
|
|
742
|
+
/** The fetch of a first page's missing messages; the app mounts when it settles. */
|
|
743
|
+
let recovery;
|
|
744
|
+
const feed = {
|
|
745
|
+
attach(app) {
|
|
746
|
+
globals = app.config.globalProperties;
|
|
747
|
+
const mount = app.mount.bind(app);
|
|
748
|
+
app.mount = ((...args) => {
|
|
749
|
+
if (!recovery) {
|
|
750
|
+
mounted = true;
|
|
751
|
+
return mount(...args);
|
|
752
|
+
}
|
|
753
|
+
// Mounted (or hydrated) once the messages are there; the server's HTML stays meanwhile.
|
|
754
|
+
recovery.then((ready) => {
|
|
755
|
+
if (!ready)
|
|
756
|
+
return;
|
|
757
|
+
mounted = true;
|
|
758
|
+
mount(...args);
|
|
759
|
+
}, (error) => {
|
|
760
|
+
// recover() handles its own failures; anything here is a bug worth seeing.
|
|
761
|
+
debug(logger, 'Page mount after recovery failed', {
|
|
762
|
+
code: 'I18N_PAGE_RECOVERY_FAILED',
|
|
763
|
+
error: describe(error),
|
|
764
|
+
});
|
|
765
|
+
throw error;
|
|
766
|
+
});
|
|
767
|
+
return undefined;
|
|
768
|
+
});
|
|
769
|
+
},
|
|
770
|
+
// `$page` is Inertia's reactive page (its plugin defines the getter): reading it here makes
|
|
771
|
+
// every template that translates follow page changes, reloads included.
|
|
772
|
+
current: () => (globals ? payloadOf(globals.$page) : undefined) ?? first,
|
|
773
|
+
absorbed(payload, complete) {
|
|
774
|
+
const root = browser.document?.documentElement;
|
|
775
|
+
if (root && root.lang !== payload.locale)
|
|
776
|
+
root.lang = payload.locale;
|
|
777
|
+
if (complete)
|
|
778
|
+
return false;
|
|
779
|
+
if (!mounted && inBrowser && browser.fetch && payload === first) {
|
|
780
|
+
recovery ??= recover();
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
if (!router)
|
|
784
|
+
return false;
|
|
785
|
+
if (reloading === payload)
|
|
786
|
+
return true;
|
|
787
|
+
reloading = payload;
|
|
788
|
+
debug(logger, 'Page translations reloaded', {
|
|
789
|
+
code: 'I18N_PAGE_RELOAD',
|
|
790
|
+
locale: payload.locale,
|
|
791
|
+
});
|
|
792
|
+
// Not from inside the render that found the gap: the router starts a visit.
|
|
793
|
+
queueMicrotask(() => router.reload({ only: ['i18n'] }));
|
|
794
|
+
return true;
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
/**
|
|
798
|
+
* D-0126: the first page lacks bundles the cookie claimed. The cookie is rewritten from what is
|
|
799
|
+
* really kept, then the missing messages come as an Inertia partial answer of just `i18n`.
|
|
800
|
+
* Resolves true to mount; false when the document is being loaded again instead.
|
|
801
|
+
*/
|
|
802
|
+
async function recover() {
|
|
803
|
+
await Promise.resolve();
|
|
804
|
+
debug(logger, 'Page messages fetched before mounting', {
|
|
805
|
+
code: 'I18N_PAGE_RECOVER',
|
|
806
|
+
locale: first?.locale,
|
|
807
|
+
});
|
|
808
|
+
// What is really kept; without a store (storage refused here) the claim goes away entirely.
|
|
809
|
+
if (i18n.store)
|
|
810
|
+
i18n.store.writeCookie();
|
|
811
|
+
else
|
|
812
|
+
dropClaim();
|
|
813
|
+
const url = page?.url ?? browser.location.href;
|
|
814
|
+
try {
|
|
815
|
+
const response = await browser.fetch(url, {
|
|
816
|
+
credentials: 'same-origin',
|
|
817
|
+
headers: {
|
|
818
|
+
Accept: 'text/html, application/xhtml+xml',
|
|
819
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
820
|
+
'X-Inertia': 'true',
|
|
821
|
+
...(page?.version ? { 'X-Inertia-Version': page.version } : {}),
|
|
822
|
+
...(page?.component
|
|
823
|
+
? { 'X-Inertia-Partial-Component': page.component, 'X-Inertia-Partial-Data': 'i18n' }
|
|
824
|
+
: {}),
|
|
825
|
+
[PAGE_I18N_HEADER]: i18n.heldHeader(),
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
if (!response.ok || response.headers.get('x-inertia') !== 'true')
|
|
829
|
+
throw new PageI18nError('RECOVERY_FAILED', `The page's messages could not be fetched (status ${response.status})`);
|
|
830
|
+
const body = (await response.json());
|
|
831
|
+
const payload = payloadOf(body);
|
|
832
|
+
if (!payload)
|
|
833
|
+
throw new PageI18nError('RECOVERY_FAILED', 'The recovery answer has no i18n prop');
|
|
834
|
+
if (!i18n.recovered(payload))
|
|
835
|
+
throw new PageI18nError('RECOVERY_FAILED', 'The recovery answer lacks messages too');
|
|
836
|
+
debug(logger, 'Page messages recovered', { code: 'I18N_PAGE_RECOVERED' });
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
return reloadWithoutClaim(error);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* The last resort: the cookie is deleted, so the server sends every message with the HTML. Once
|
|
845
|
+
* per minute and tab at most; a second failure mounts the page and logs the error.
|
|
846
|
+
*/
|
|
847
|
+
function dropClaim() {
|
|
848
|
+
const name = i18n.claimCookie ?? first?.settings?.cache?.cookie;
|
|
849
|
+
if (name && browser.document)
|
|
850
|
+
browser.document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax${browser.location?.protocol === 'https:' ? '; Secure' : ''}`;
|
|
851
|
+
}
|
|
852
|
+
function reloadWithoutClaim(error) {
|
|
853
|
+
dropClaim();
|
|
854
|
+
let recent = false;
|
|
855
|
+
try {
|
|
856
|
+
const at = Number(browser.sessionStorage?.getItem(RELOADED) ?? 0);
|
|
857
|
+
recent = Date.now() - at < 60_000;
|
|
858
|
+
if (!recent)
|
|
859
|
+
browser.sessionStorage?.setItem(RELOADED, String(Date.now()));
|
|
860
|
+
}
|
|
861
|
+
catch (storageError) {
|
|
862
|
+
// Without sessionStorage there is no loop guard: mount instead of reloading.
|
|
863
|
+
debug(logger, 'sessionStorage refused; the page is mounted without reloading', {
|
|
864
|
+
code: 'I18N_STORE_UNAVAILABLE',
|
|
865
|
+
error: describe(storageError),
|
|
866
|
+
});
|
|
867
|
+
recent = true;
|
|
868
|
+
}
|
|
869
|
+
if (!recent) {
|
|
870
|
+
debug(logger, 'Page loaded again without the bundle cookie', {
|
|
871
|
+
code: 'I18N_PAGE_RECOVERY_RELOAD',
|
|
872
|
+
error: describe(error),
|
|
873
|
+
});
|
|
874
|
+
browser.location?.reload();
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
const failure = { code: 'I18N_PAGE_RECOVERY_FAILED', error: describe(error) };
|
|
878
|
+
if (logger)
|
|
879
|
+
logger.log('error', 'Page messages could not be recovered', failure);
|
|
880
|
+
// dokebi-check: allow console-output browser code without a logger: the console is the page's log
|
|
881
|
+
else
|
|
882
|
+
console.error('Page messages could not be recovered', failure);
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
i18n.connect(feed, router
|
|
886
|
+
? (locale) => new Promise((resolve) => {
|
|
887
|
+
const document = browser.document;
|
|
888
|
+
const cookie = i18n.cookie;
|
|
889
|
+
if (document && cookie)
|
|
890
|
+
document.cookie = `${cookie}=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax${browser.location?.protocol === 'https:' ? '; Secure' : ''}`;
|
|
891
|
+
debug(logger, 'Page language switch', { code: 'I18N_PAGE_SWITCH', locale });
|
|
892
|
+
// Every prop again, translated by the server in the new language; the page stays.
|
|
893
|
+
router.reload({ onFinish: () => resolve() });
|
|
894
|
+
})
|
|
895
|
+
: undefined, store);
|
|
896
|
+
router?.on('before', (event) => {
|
|
897
|
+
const visit = event.detail.visit;
|
|
898
|
+
visit.headers = { ...visit.headers, [PAGE_I18N_HEADER]: i18n.heldHeader() };
|
|
899
|
+
});
|
|
900
|
+
return i18n;
|
|
901
|
+
}
|