@worthy-ventures/metaglotta-runtime 1.0.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/README.md +44 -0
- package/dist/backend.d.ts +19 -0
- package/dist/backend.js +77 -0
- package/dist/backend.js.map +1 -0
- package/dist/cache.d.ts +36 -0
- package/dist/cache.js +77 -0
- package/dist/cache.js.map +1 -0
- package/dist/events.d.ts +6 -0
- package/dist/events.js +74 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +9 -0
- package/dist/format.js +60 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/instance.d.ts +38 -0
- package/dist/instance.js +223 -0
- package/dist/instance.js.map +1 -0
- package/dist/props.d.ts +5 -0
- package/dist/props.js +60 -0
- package/dist/props.js.map +1 -0
- package/dist/types.d.ts +168 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist-esm/backend.js +72 -0
- package/dist-esm/backend.js.map +1 -0
- package/dist-esm/cache.js +72 -0
- package/dist-esm/cache.js.map +1 -0
- package/dist-esm/events.js +71 -0
- package/dist-esm/events.js.map +1 -0
- package/dist-esm/format.js +55 -0
- package/dist-esm/format.js.map +1 -0
- package/dist-esm/index.js +6 -0
- package/dist-esm/index.js.map +1 -0
- package/dist-esm/instance.js +219 -0
- package/dist-esm/instance.js.map +1 -0
- package/dist-esm/package.json +1 -0
- package/dist-esm/props.js +55 -0
- package/dist-esm/props.js.map +1 -0
- package/dist-esm/types.js +8 -0
- package/dist-esm/types.js.map +1 -0
- package/package.json +37 -0
- package/src/backend.test.ts +117 -0
- package/src/backend.ts +81 -0
- package/src/cache.test.ts +99 -0
- package/src/cache.ts +80 -0
- package/src/events.test.ts +118 -0
- package/src/events.ts +77 -0
- package/src/format.test.ts +92 -0
- package/src/format.ts +59 -0
- package/src/index.ts +25 -0
- package/src/instance.test.ts +645 -0
- package/src/instance.ts +257 -0
- package/src/props.test.ts +79 -0
- package/src/props.ts +62 -0
- package/src/types.ts +165 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { forgetCompiled } from './format.js';
|
|
2
|
+
import { Metaglotta } from './instance.js';
|
|
3
|
+
import type { MetaglottaOptions, TranslationsInput } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The runtime, against the behaviours the eleven applications actually depend on.
|
|
7
|
+
*
|
|
8
|
+
* These are not a description of a design - they are the contract the replaced dependency
|
|
9
|
+
* had, read off its source and off the call sites: 3,709 uses of the pipe and 938 of
|
|
10
|
+
* instant(). Anything here that looks arbitrary is arbitrary in the same way the thing it
|
|
11
|
+
* replaces was, and changing it changes what renders on somebody's screen.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type Files = Record<string, TranslationsInput>;
|
|
15
|
+
|
|
16
|
+
function serving(files: Files) {
|
|
17
|
+
const asked: string[] = [];
|
|
18
|
+
const fetchFn = (async (url: string) => {
|
|
19
|
+
asked.push(url);
|
|
20
|
+
const body = files[url];
|
|
21
|
+
if (!body) return { ok: false, status: 404, json: async () => ({}) } as unknown as Response;
|
|
22
|
+
return { ok: true, status: 200, json: async () => body } as unknown as Response;
|
|
23
|
+
}) as unknown as typeof fetch;
|
|
24
|
+
return { asked, fetchFn };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
28
|
+
|
|
29
|
+
function build(files: Files, options: Partial<MetaglottaOptions> = {}) {
|
|
30
|
+
const server = serving(files);
|
|
31
|
+
const instance = Metaglotta({
|
|
32
|
+
language: 'el',
|
|
33
|
+
fallbackLanguage: 'en',
|
|
34
|
+
defaultNs: 'common',
|
|
35
|
+
ns: ['common', 'login'],
|
|
36
|
+
fallbackNs: ['common'],
|
|
37
|
+
backend: { prefix: '/langs', fetch: server.fetchFn },
|
|
38
|
+
...options,
|
|
39
|
+
});
|
|
40
|
+
return { instance, ...server };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => forgetCompiled());
|
|
44
|
+
|
|
45
|
+
describe('translating', () => {
|
|
46
|
+
const options: Partial<MetaglottaOptions> = {
|
|
47
|
+
backend: undefined,
|
|
48
|
+
staticData: {
|
|
49
|
+
'el:common': { greeting: 'Γεια', shared: 'κοινό' },
|
|
50
|
+
'en:common': { greeting: 'Hello', shared: 'shared', onlyEnglish: 'English only' },
|
|
51
|
+
'el:login': { title: 'Σύνδεση' },
|
|
52
|
+
'en:login': { title: 'Sign in', greeting: 'Hello from login' },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
it('returns the stored string', () => {
|
|
57
|
+
expect(build({}, options).instance.t('greeting')).toBe('Γεια');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The missing case, and it must ECHO THE KEY.
|
|
62
|
+
*
|
|
63
|
+
* 938 call sites are written against it - `instant(k) || fallback` reads as "no
|
|
64
|
+
* translation" only because the key is truthy - and a missing string is meant to be
|
|
65
|
+
* visible on the page rather than a silent gap.
|
|
66
|
+
*/
|
|
67
|
+
it('echoes the key when nothing is stored', () => {
|
|
68
|
+
expect(build({}, options).instance.t('nowhere')).toBe('nowhere');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** The pipe asks for this, which is why a template renders '' rather than the key. */
|
|
72
|
+
it('returns nothing instead when asked for orEmpty', () => {
|
|
73
|
+
expect(build({}, options).instance.t({ key: 'nowhere', orEmpty: true })).toBe('');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('prefers a default value to echoing the key', () => {
|
|
77
|
+
expect(build({}, options).instance.t('nowhere', 'Fallback text')).toBe('Fallback text');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('still prefers a default value when orEmpty was asked for', () => {
|
|
81
|
+
expect(build({}, options).instance.t({ key: 'nowhere', defaultValue: 'Fallback text', orEmpty: true })).toBe('Fallback text');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('prefers what is stored to the default value', () => {
|
|
85
|
+
expect(build({}, options).instance.t('greeting', 'Fallback text')).toBe('Γεια');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('falls back to another language when the current one has nothing', () => {
|
|
89
|
+
expect(build({}, options).instance.t('onlyEnglish')).toBe('English only');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('reads a namespace it was told to read', () => {
|
|
93
|
+
expect(build({}, options).instance.t({ key: 'title', ns: 'login' })).toBe('Σύνδεση');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('falls back to another namespace when the asked-for one has nothing', () => {
|
|
97
|
+
expect(build({}, options).instance.t({ key: 'shared', ns: 'login' })).toBe('κοινό');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Namespace before language. `greeting` exists in login/en and in common/el; asked for
|
|
102
|
+
* login in Greek, the answer is the English one from login - the namespace asked for wins
|
|
103
|
+
* over the language asked for. Reverse the loops and this silently returns 'Γεια'.
|
|
104
|
+
*/
|
|
105
|
+
it('exhausts the asked-for namespace before falling back to another', () => {
|
|
106
|
+
expect(build({}, options).instance.t({ key: 'greeting', ns: 'login' })).toBe('Hello from login');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('reads another language on request without changing the current one', () => {
|
|
110
|
+
const { instance } = build({}, options);
|
|
111
|
+
expect(instance.t({ key: 'greeting', language: 'en' })).toBe('Hello');
|
|
112
|
+
expect(instance.getLanguage()).toBe('el');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('formats ICU with the parameters it was given', () => {
|
|
116
|
+
const { instance } = build({}, { backend: undefined, language: 'en', staticData: { en: { hi: 'Hello {name}' } }, defaultNs: '', ns: [''], fallbackNs: undefined });
|
|
117
|
+
expect(instance.t('hi', { name: 'Ada' })).toBe('Hello Ada');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('formats in the current language, so plurals follow its rules', () => {
|
|
121
|
+
const { instance } = build(
|
|
122
|
+
{},
|
|
123
|
+
{
|
|
124
|
+
backend: undefined,
|
|
125
|
+
language: 'el',
|
|
126
|
+
staticData: { el: { files: '{count, plural, one {# αρχείο} other {# αρχεία}}' } },
|
|
127
|
+
defaultNs: '',
|
|
128
|
+
ns: [''],
|
|
129
|
+
fallbackNs: undefined,
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
expect(instance.t('files', { count: 1 })).toBe('1 αρχείο');
|
|
133
|
+
expect(instance.t('files', { count: 4 })).toBe('4 αρχεία');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/** A broken placeholder must not take the page down with it. */
|
|
137
|
+
it('renders an unformattable string as "invalid" rather than throwing', () => {
|
|
138
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
139
|
+
try {
|
|
140
|
+
const { instance } = build({}, { backend: undefined, language: 'en', staticData: { en: { broken: 'Hello {name' } }, defaultNs: '', ns: [''], fallbackNs: undefined });
|
|
141
|
+
expect(instance.t('broken')).toBe('invalid');
|
|
142
|
+
} finally {
|
|
143
|
+
spy.mockRestore();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('lets you say what an unformattable string should render as', () => {
|
|
148
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
149
|
+
try {
|
|
150
|
+
const { instance } = build(
|
|
151
|
+
{},
|
|
152
|
+
{ backend: undefined, language: 'en', staticData: { en: { broken: 'Hello {name' } }, defaultNs: '', ns: [''], fallbackNs: undefined, onFormatError: '???' }
|
|
153
|
+
);
|
|
154
|
+
expect(instance.t('broken')).toBe('???');
|
|
155
|
+
} finally {
|
|
156
|
+
spy.mockRestore();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('lets you say what a missing string should render as', () => {
|
|
161
|
+
const { instance } = build({}, { ...options, onMissing: props => `[[${props.key}]]` });
|
|
162
|
+
expect(instance.t('nowhere')).toBe('[[nowhere]]');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('reads a per-language fallback map', () => {
|
|
166
|
+
const { instance } = build(
|
|
167
|
+
{},
|
|
168
|
+
{
|
|
169
|
+
backend: undefined,
|
|
170
|
+
language: 'el',
|
|
171
|
+
fallbackLanguage: { el: 'de', de: 'en' },
|
|
172
|
+
defaultNs: '',
|
|
173
|
+
ns: [''],
|
|
174
|
+
fallbackNs: undefined,
|
|
175
|
+
staticData: { de: { greeting: 'Hallo' }, en: { greeting: 'Hello' } },
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
// One hop only: the map is consulted for the current language, not walked.
|
|
179
|
+
expect(instance.t('greeting')).toBe('Hallo');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('loading', () => {
|
|
184
|
+
const files: Files = {
|
|
185
|
+
'/langs/common/el.json': { greeting: 'Γεια' },
|
|
186
|
+
'/langs/common/en.json': { greeting: 'Hello' },
|
|
187
|
+
'/langs/login/el.json': { title: 'Σύνδεση' },
|
|
188
|
+
'/langs/login/en.json': { title: 'Sign in' },
|
|
189
|
+
'/langs/reception/el.json': { desk: 'Ρεσεψιόν' },
|
|
190
|
+
'/langs/reception/en.json': { desk: 'Reception' },
|
|
191
|
+
'/langs/common/de.json': { greeting: 'Hallo' },
|
|
192
|
+
'/langs/login/de.json': { title: 'Anmelden' },
|
|
193
|
+
'/langs/reception/de.json': { desk: 'Empfang' },
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
it('fetches every configured namespace in every language it may fall back to', async () => {
|
|
197
|
+
const { instance, asked } = build(files);
|
|
198
|
+
await instance.run();
|
|
199
|
+
|
|
200
|
+
expect(asked.sort()).toEqual([
|
|
201
|
+
'/langs/common/el.json',
|
|
202
|
+
'/langs/common/en.json',
|
|
203
|
+
'/langs/login/el.json',
|
|
204
|
+
'/langs/login/en.json',
|
|
205
|
+
]);
|
|
206
|
+
expect(instance.t('greeting')).toBe('Γεια');
|
|
207
|
+
expect(instance.t({ key: 'title', ns: 'login' })).toBe('Σύνδεση');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('runs once however many times it is asked', async () => {
|
|
211
|
+
const { instance, asked } = build(files);
|
|
212
|
+
await Promise.all([instance.run(), instance.run()]);
|
|
213
|
+
await instance.run();
|
|
214
|
+
|
|
215
|
+
expect(asked).toHaveLength(4);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('says when the first load is done', async () => {
|
|
219
|
+
const { instance } = build(files);
|
|
220
|
+
const seen: string[] = [];
|
|
221
|
+
instance.on('initialLoad', () => seen.push('loaded'));
|
|
222
|
+
|
|
223
|
+
await instance.run();
|
|
224
|
+
|
|
225
|
+
expect(seen).toEqual(['loaded']);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('tells the page to re-translate once for the whole load, not once per file', async () => {
|
|
229
|
+
const { instance } = build(files);
|
|
230
|
+
const updates: unknown[] = [];
|
|
231
|
+
instance.on('update', batch => updates.push(batch));
|
|
232
|
+
|
|
233
|
+
await instance.run();
|
|
234
|
+
await settle();
|
|
235
|
+
|
|
236
|
+
// One for the batched writes, one for initialLoad - not one per file.
|
|
237
|
+
expect(updates.length).toBeLessThanOrEqual(2);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Every pipe on the page activates its namespace as it subscribes. Some thousands of them
|
|
242
|
+
* render at once, and without in-flight de-duplication that is thousands of requests for
|
|
243
|
+
* the same file.
|
|
244
|
+
*/
|
|
245
|
+
it('fetches a record once even when everything asks for it at the same time', async () => {
|
|
246
|
+
const { instance, asked } = build(files);
|
|
247
|
+
await instance.run();
|
|
248
|
+
const before = asked.length;
|
|
249
|
+
|
|
250
|
+
await Promise.all(Array.from({ length: 50 }, () => instance.addActiveNs('reception')));
|
|
251
|
+
|
|
252
|
+
expect(asked.length - before).toBe(2);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('loads a namespace that is activated after it started', async () => {
|
|
256
|
+
const { instance } = build(files);
|
|
257
|
+
await instance.run();
|
|
258
|
+
expect(instance.t({ key: 'desk', ns: 'reception' })).toBe('desk');
|
|
259
|
+
|
|
260
|
+
await instance.addActiveNs('reception');
|
|
261
|
+
|
|
262
|
+
expect(instance.t({ key: 'desk', ns: 'reception' })).toBe('Ρεσεψιόν');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* No fallback language in these three, on purpose. With one configured, run() has already
|
|
267
|
+
* fetched every namespace in both languages and a language change fetches nothing at all
|
|
268
|
+
* - which would make each of these pass without testing anything.
|
|
269
|
+
*/
|
|
270
|
+
const oneLanguage = { fallbackLanguage: undefined };
|
|
271
|
+
|
|
272
|
+
it('keeps an activated namespace loaded for the next language too', async () => {
|
|
273
|
+
const { instance, asked } = build(files, oneLanguage);
|
|
274
|
+
await instance.run();
|
|
275
|
+
await instance.addActiveNs('reception');
|
|
276
|
+
asked.length = 0;
|
|
277
|
+
|
|
278
|
+
await instance.changeLanguage('en');
|
|
279
|
+
|
|
280
|
+
expect(asked).toContain('/langs/common/en.json');
|
|
281
|
+
expect(asked).toContain('/langs/reception/en.json');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('stops keeping it once the last holder lets go', async () => {
|
|
285
|
+
const { instance, asked } = build(files, oneLanguage);
|
|
286
|
+
await instance.run();
|
|
287
|
+
await instance.addActiveNs('reception');
|
|
288
|
+
await instance.addActiveNs('reception');
|
|
289
|
+
instance.removeActiveNs('reception');
|
|
290
|
+
asked.length = 0;
|
|
291
|
+
|
|
292
|
+
// One of the two holders is still holding it.
|
|
293
|
+
await instance.changeLanguage('en');
|
|
294
|
+
expect(asked).toContain('/langs/reception/en.json');
|
|
295
|
+
|
|
296
|
+
instance.removeActiveNs('reception');
|
|
297
|
+
asked.length = 0;
|
|
298
|
+
|
|
299
|
+
// A third language, so what it fetches is what it needs rather than what it has.
|
|
300
|
+
await instance.changeLanguage('de');
|
|
301
|
+
expect(asked).toContain('/langs/common/de.json');
|
|
302
|
+
expect(asked).not.toContain('/langs/reception/de.json');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/** The route resolver loads a namespace for one navigation without holding it open. */
|
|
306
|
+
it('can load a namespace without holding it', async () => {
|
|
307
|
+
const { instance, asked } = build(files, oneLanguage);
|
|
308
|
+
await instance.run();
|
|
309
|
+
await instance.addActiveNs('reception', true);
|
|
310
|
+
expect(instance.t({ key: 'desk', ns: 'reception' })).toBe('Ρεσεψιόν');
|
|
311
|
+
asked.length = 0;
|
|
312
|
+
|
|
313
|
+
await instance.changeLanguage('en');
|
|
314
|
+
|
|
315
|
+
expect(asked).toContain('/langs/common/en.json');
|
|
316
|
+
expect(asked).not.toContain('/langs/reception/en.json');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('does nothing before it is running, and catches up when it starts', async () => {
|
|
320
|
+
const { instance, asked } = build(files);
|
|
321
|
+
await instance.addActiveNs('reception');
|
|
322
|
+
expect(asked).toHaveLength(0);
|
|
323
|
+
|
|
324
|
+
await instance.run();
|
|
325
|
+
|
|
326
|
+
expect(asked).toContain('/langs/reception/el.json');
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('when a file cannot be read', () => {
|
|
331
|
+
const partial: Files = { '/langs/common/el.json': { greeting: 'Γεια' }, '/langs/common/en.json': { greeting: 'Hello' } };
|
|
332
|
+
let errors: jest.SpyInstance;
|
|
333
|
+
|
|
334
|
+
beforeEach(() => {
|
|
335
|
+
errors = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
336
|
+
});
|
|
337
|
+
afterEach(() => errors.mockRestore());
|
|
338
|
+
|
|
339
|
+
/** One bad path is a deploy mistake. It should cost that namespace, not the application. */
|
|
340
|
+
it('keeps the namespaces that did load', async () => {
|
|
341
|
+
const { instance } = build(partial);
|
|
342
|
+
await instance.run();
|
|
343
|
+
|
|
344
|
+
expect(instance.t('greeting')).toBe('Γεια');
|
|
345
|
+
expect(instance.t({ key: 'title', ns: 'login' })).toBe('title');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('reports it, so a broken deploy is visible rather than merely quiet', async () => {
|
|
349
|
+
const { instance } = build(partial);
|
|
350
|
+
const seen: unknown[] = [];
|
|
351
|
+
instance.on('error', event => seen.push(event));
|
|
352
|
+
|
|
353
|
+
await instance.run();
|
|
354
|
+
|
|
355
|
+
expect(seen.length).toBeGreaterThan(0);
|
|
356
|
+
expect(errors).toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A failed record is remembered as empty rather than left absent. Absent means "not
|
|
361
|
+
* loaded", and every pipe on the page re-triggers a load for what is not loaded - so a
|
|
362
|
+
* 404 would be re-requested once per pipe, for as long as the page is open.
|
|
363
|
+
*/
|
|
364
|
+
it('does not ask again for a file it already failed to read', async () => {
|
|
365
|
+
const { instance, asked } = build(partial);
|
|
366
|
+
await instance.run();
|
|
367
|
+
const before = asked.length;
|
|
368
|
+
|
|
369
|
+
await Promise.all(Array.from({ length: 20 }, () => instance.addActiveNs('login')));
|
|
370
|
+
|
|
371
|
+
expect(asked.length).toBe(before);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('can be told to fail the whole load instead', async () => {
|
|
375
|
+
const server = serving(partial);
|
|
376
|
+
const instance = Metaglotta({
|
|
377
|
+
language: 'el',
|
|
378
|
+
defaultNs: 'common',
|
|
379
|
+
ns: ['common', 'login'],
|
|
380
|
+
backend: { prefix: '/langs', fetch: server.fetchFn, fallbackOnFail: false },
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
await expect(instance.run()).rejects.toThrow();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('changing language', () => {
|
|
388
|
+
const files: Files = {
|
|
389
|
+
'/langs/common/el.json': { greeting: 'Γεια' },
|
|
390
|
+
'/langs/common/en.json': { greeting: 'Hello' },
|
|
391
|
+
'/langs/common/de.json': { greeting: 'Hallo' },
|
|
392
|
+
'/langs/login/el.json': {},
|
|
393
|
+
'/langs/login/en.json': {},
|
|
394
|
+
'/langs/login/de.json': {},
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
it('resolves only once the new language has loaded', async () => {
|
|
398
|
+
const { instance } = build(files, { language: 'el', fallbackLanguage: undefined });
|
|
399
|
+
await instance.run();
|
|
400
|
+
|
|
401
|
+
await instance.changeLanguage('de');
|
|
402
|
+
|
|
403
|
+
expect(instance.getLanguage()).toBe('de');
|
|
404
|
+
expect(instance.t('greeting')).toBe('Hallo');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('says so, so the page re-renders', async () => {
|
|
408
|
+
const { instance } = build(files, { fallbackLanguage: undefined });
|
|
409
|
+
await instance.run();
|
|
410
|
+
const seen: unknown[] = [];
|
|
411
|
+
instance.on('language', event => seen.push(event));
|
|
412
|
+
|
|
413
|
+
await instance.changeLanguage('en');
|
|
414
|
+
|
|
415
|
+
expect(seen).toEqual([{ type: 'language', value: 'en' }]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('has nothing to do when it is already that language', async () => {
|
|
419
|
+
const { instance, asked } = build(files, { fallbackLanguage: undefined });
|
|
420
|
+
await instance.run();
|
|
421
|
+
asked.length = 0;
|
|
422
|
+
|
|
423
|
+
await instance.changeLanguage('el');
|
|
424
|
+
|
|
425
|
+
expect(asked).toHaveLength(0);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Two changes can be in flight at once - a fast double click on a language switcher - and
|
|
430
|
+
* the one that finishes last is not necessarily the one asked for last. Only the latest
|
|
431
|
+
* request may apply, or the page settles on a language nobody chose.
|
|
432
|
+
*/
|
|
433
|
+
it('applies the language asked for last, whichever finishes first', async () => {
|
|
434
|
+
const server = serving(files);
|
|
435
|
+
const delays: Record<string, number> = { '/langs/common/de.json': 40, '/langs/common/en.json': 0 };
|
|
436
|
+
const instance = Metaglotta({
|
|
437
|
+
language: 'el',
|
|
438
|
+
defaultNs: 'common',
|
|
439
|
+
ns: ['common'],
|
|
440
|
+
backend: {
|
|
441
|
+
prefix: '/langs',
|
|
442
|
+
fetch: (async (url: string) => {
|
|
443
|
+
await new Promise(resolve => setTimeout(resolve, delays[url] ?? 0));
|
|
444
|
+
return server.fetchFn(url) as unknown as Response;
|
|
445
|
+
}) as unknown as typeof fetch,
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
await instance.run();
|
|
449
|
+
|
|
450
|
+
const slow = instance.changeLanguage('de');
|
|
451
|
+
const fast = instance.changeLanguage('en');
|
|
452
|
+
await Promise.all([slow, fast]);
|
|
453
|
+
|
|
454
|
+
expect(instance.getLanguage()).toBe('en');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('does not fetch anything before it is running', async () => {
|
|
458
|
+
const { instance, asked } = build(files, { fallbackLanguage: undefined });
|
|
459
|
+
|
|
460
|
+
await instance.changeLanguage('en');
|
|
461
|
+
|
|
462
|
+
expect(asked).toHaveLength(0);
|
|
463
|
+
expect(instance.getLanguage()).toBe('en');
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe('starting from data it was given', () => {
|
|
468
|
+
it('uses translations passed in, and asks for nothing', () => {
|
|
469
|
+
const { instance, asked } = build({}, { staticData: { 'el:common': { greeting: 'Γεια' } }, backend: undefined });
|
|
470
|
+
|
|
471
|
+
expect(instance.t('greeting')).toBe('Γεια');
|
|
472
|
+
expect(asked).toHaveLength(0);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('reads a key with no namespace as the default one', () => {
|
|
476
|
+
const { instance } = build({}, { staticData: { el: { greeting: 'Γεια' } }, backend: undefined, defaultNs: '', ns: [''], fallbackNs: undefined });
|
|
477
|
+
|
|
478
|
+
expect(instance.t('greeting')).toBe('Γεια');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('does not fetch what it was already given', async () => {
|
|
482
|
+
const { instance, asked } = build(
|
|
483
|
+
{ '/langs/login/el.json': {}, '/langs/login/en.json': {}, '/langs/common/en.json': {} },
|
|
484
|
+
{ staticData: { 'el:common': { greeting: 'Γεια' } } }
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
await instance.run();
|
|
488
|
+
|
|
489
|
+
expect(asked).not.toContain('/langs/common/el.json');
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe('the decorate pass', () => {
|
|
494
|
+
const options = { backend: undefined, staticData: { 'el:common': { greeting: 'Γεια {name}' } } };
|
|
495
|
+
|
|
496
|
+
it('sees the finished string, after formatting', () => {
|
|
497
|
+
const seen: string[] = [];
|
|
498
|
+
const { instance } = build({}, { ...options, decorate: (result: string) => { seen.push(result); return result; } });
|
|
499
|
+
|
|
500
|
+
instance.t('greeting', { name: 'Ada' });
|
|
501
|
+
|
|
502
|
+
expect(seen).toEqual(['Γεια Ada']);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('can append to it, which is how a key travels with its own text', () => {
|
|
506
|
+
const { instance } = build({}, { ...options, decorate: (result: string, props: { key: string }) => `${result}[${props.key}]` });
|
|
507
|
+
|
|
508
|
+
expect(instance.t('greeting', { name: 'Ada' })).toBe('Γεια Ada[greeting]');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* A key with nothing behind it yet is exactly the one somebody wants to click on, so the
|
|
513
|
+
* missing cases are decorated too - both the echoed key and the empty string.
|
|
514
|
+
*/
|
|
515
|
+
it('applies to a missing translation as well', () => {
|
|
516
|
+
const { instance } = build({}, { ...options, decorate: (result: string, props: { key: string }) => `${result}[${props.key}]` });
|
|
517
|
+
|
|
518
|
+
expect(instance.t('nowhere')).toBe('nowhere[nowhere]');
|
|
519
|
+
expect(instance.t({ key: 'nowhere', orEmpty: true })).toBe('[nowhere]');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('is absent unless asked for, so production pays nothing for it', () => {
|
|
523
|
+
const { instance } = build({}, options);
|
|
524
|
+
expect(instance.t('greeting', { name: 'Ada' })).toBe('Γεια Ada');
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Which namespace it is told about, and why it is not the one the caller asked with.
|
|
529
|
+
*
|
|
530
|
+
* A template writing `{{ 'greeting' | translate }}` names no namespace at all. Passing
|
|
531
|
+
* that nothing through is what sent the editing dialog to the default namespace - where
|
|
532
|
+
* the key is not - so it opened with no translations, no screenshots and an extra
|
|
533
|
+
* "(default)" in its namespace list. It has to be told where the string CAME FROM.
|
|
534
|
+
*/
|
|
535
|
+
describe('the namespace it reports', () => {
|
|
536
|
+
const seen: string[] = [];
|
|
537
|
+
const watching = (over: Partial<MetaglottaOptions> = {}) => {
|
|
538
|
+
seen.length = 0;
|
|
539
|
+
return build({}, {
|
|
540
|
+
backend: undefined,
|
|
541
|
+
defaultNs: 'common',
|
|
542
|
+
ns: ['common', 'login'],
|
|
543
|
+
fallbackNs: ['common'],
|
|
544
|
+
decorate: (result: string, _props: unknown, namespace: string) => {
|
|
545
|
+
seen.push(namespace);
|
|
546
|
+
return result;
|
|
547
|
+
},
|
|
548
|
+
...over,
|
|
549
|
+
}).instance;
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
it('is the namespace the string was found in, not the nothing that was asked for', () => {
|
|
553
|
+
watching({ staticData: { 'el:common': { greeting: 'Γεια' } } }).t('greeting');
|
|
554
|
+
expect(seen).toEqual(['common']);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('is the namespace that answered when the fallback answered', () => {
|
|
558
|
+
watching({ staticData: { 'el:common': { shared: 'κοινό' } } }).t({ key: 'shared', ns: 'login' });
|
|
559
|
+
expect(seen).toEqual(['common']);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('is the namespace asked for when that is the one holding it', () => {
|
|
563
|
+
watching({ staticData: { 'el:login': { title: 'Σύνδεση' } } }).t({ key: 'title', ns: 'login' });
|
|
564
|
+
expect(seen).toEqual(['login']);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
/** Nothing stored yet: the answer is where creating it would put it. */
|
|
568
|
+
it('is where a new key would go when there is nothing stored', () => {
|
|
569
|
+
watching({ staticData: {} }).t('nowhere');
|
|
570
|
+
expect(seen).toEqual(['common']);
|
|
571
|
+
|
|
572
|
+
watching({ staticData: {} }).t({ key: 'nowhere', ns: 'login' });
|
|
573
|
+
expect(seen).toEqual(['login']);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('is the default namespace when the default namespace really is the empty one', () => {
|
|
577
|
+
watching({ defaultNs: '', ns: [''], fallbackNs: undefined, staticData: { el: { greeting: 'Γεια' } } }).t('greeting');
|
|
578
|
+
expect(seen).toEqual(['']);
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe('changing a translation in memory', () => {
|
|
584
|
+
const options = { backend: undefined, staticData: { 'el:common': { greeting: 'Γεια' } } };
|
|
585
|
+
|
|
586
|
+
it('changes what the next lookup finds', () => {
|
|
587
|
+
const { instance } = build({}, options);
|
|
588
|
+
|
|
589
|
+
expect(instance.changeTranslation({ language: 'el', namespace: 'common' }, 'greeting', 'Χαίρετε')).toBe(true);
|
|
590
|
+
expect(instance.t('greeting')).toBe('Χαίρετε');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('says so, so the page re-renders', () => {
|
|
594
|
+
const { instance } = build({}, options);
|
|
595
|
+
const updates: unknown[] = [];
|
|
596
|
+
instance.on('update', batch => updates.push(batch));
|
|
597
|
+
|
|
598
|
+
instance.changeTranslation({ language: 'el', namespace: 'common' }, 'greeting', 'Χαίρετε');
|
|
599
|
+
jest.advanceTimersByTime?.(0);
|
|
600
|
+
|
|
601
|
+
// Batched with cache writes, so it lands on the next tick like a load does.
|
|
602
|
+
return new Promise<void>(resolve =>
|
|
603
|
+
setTimeout(() => {
|
|
604
|
+
expect(updates).toHaveLength(1);
|
|
605
|
+
resolve();
|
|
606
|
+
}, 0)
|
|
607
|
+
);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('assumes the current language and default namespace when not told', () => {
|
|
611
|
+
const { instance } = build({}, options);
|
|
612
|
+
|
|
613
|
+
expect(instance.changeTranslation({}, 'greeting', 'Χαίρετε')).toBe(true);
|
|
614
|
+
expect(instance.t('greeting')).toBe('Χαίρετε');
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
/** Writing into an unloaded record would invent a namespace no element reads. */
|
|
618
|
+
it('refuses a record nothing has loaded, and says so', () => {
|
|
619
|
+
const { instance } = build({}, options);
|
|
620
|
+
|
|
621
|
+
expect(instance.changeTranslation({ language: 'de', namespace: 'common' }, 'greeting', 'Hallo')).toBe(false);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe('stopping', () => {
|
|
626
|
+
it('stops loading for namespaces activated afterwards', async () => {
|
|
627
|
+
const files: Files = { '/langs/common/el.json': {}, '/langs/common/en.json': {}, '/langs/login/el.json': {}, '/langs/login/en.json': {} };
|
|
628
|
+
const { instance, asked } = build(files);
|
|
629
|
+
await instance.run();
|
|
630
|
+
instance.stop();
|
|
631
|
+
asked.length = 0;
|
|
632
|
+
|
|
633
|
+
await instance.addActiveNs('reception');
|
|
634
|
+
|
|
635
|
+
expect(asked).toHaveLength(0);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('still answers with what it already has', async () => {
|
|
639
|
+
const { instance } = build({}, { staticData: { 'el:common': { greeting: 'Γεια' } }, backend: undefined });
|
|
640
|
+
await instance.run();
|
|
641
|
+
instance.stop();
|
|
642
|
+
|
|
643
|
+
expect(instance.t('greeting')).toBe('Γεια');
|
|
644
|
+
});
|
|
645
|
+
});
|