@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.
Files changed (57) hide show
  1. package/README.md +44 -0
  2. package/dist/backend.d.ts +19 -0
  3. package/dist/backend.js +77 -0
  4. package/dist/backend.js.map +1 -0
  5. package/dist/cache.d.ts +36 -0
  6. package/dist/cache.js +77 -0
  7. package/dist/cache.js.map +1 -0
  8. package/dist/events.d.ts +6 -0
  9. package/dist/events.js +74 -0
  10. package/dist/events.js.map +1 -0
  11. package/dist/format.d.ts +9 -0
  12. package/dist/format.js +60 -0
  13. package/dist/format.js.map +1 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.js +19 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/instance.d.ts +38 -0
  18. package/dist/instance.js +223 -0
  19. package/dist/instance.js.map +1 -0
  20. package/dist/props.d.ts +5 -0
  21. package/dist/props.js +60 -0
  22. package/dist/props.js.map +1 -0
  23. package/dist/types.d.ts +168 -0
  24. package/dist/types.js +9 -0
  25. package/dist/types.js.map +1 -0
  26. package/dist-esm/backend.js +72 -0
  27. package/dist-esm/backend.js.map +1 -0
  28. package/dist-esm/cache.js +72 -0
  29. package/dist-esm/cache.js.map +1 -0
  30. package/dist-esm/events.js +71 -0
  31. package/dist-esm/events.js.map +1 -0
  32. package/dist-esm/format.js +55 -0
  33. package/dist-esm/format.js.map +1 -0
  34. package/dist-esm/index.js +6 -0
  35. package/dist-esm/index.js.map +1 -0
  36. package/dist-esm/instance.js +219 -0
  37. package/dist-esm/instance.js.map +1 -0
  38. package/dist-esm/package.json +1 -0
  39. package/dist-esm/props.js +55 -0
  40. package/dist-esm/props.js.map +1 -0
  41. package/dist-esm/types.js +8 -0
  42. package/dist-esm/types.js.map +1 -0
  43. package/package.json +37 -0
  44. package/src/backend.test.ts +117 -0
  45. package/src/backend.ts +81 -0
  46. package/src/cache.test.ts +99 -0
  47. package/src/cache.ts +80 -0
  48. package/src/events.test.ts +118 -0
  49. package/src/events.ts +77 -0
  50. package/src/format.test.ts +92 -0
  51. package/src/format.ts +59 -0
  52. package/src/index.ts +25 -0
  53. package/src/instance.test.ts +645 -0
  54. package/src/instance.ts +257 -0
  55. package/src/props.test.ts +79 -0
  56. package/src/props.ts +62 -0
  57. package/src/types.ts +165 -0
package/src/backend.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { BackendOptions, GetPath, RecordDescriptor, TranslationsInput } from './types.js';
2
+
3
+ /**
4
+ * Loading one `{namespace}/{language}.json` from wherever the translations are published.
5
+ *
6
+ * Deliberately not a plugin. There is exactly one backend in these applications - a
7
+ * CloudFront prefix - and a plugin system for a single implementation is machinery with
8
+ * nothing to choose between.
9
+ */
10
+
11
+ const defaultGetPath: GetPath = ({ namespace, language, prefix }) => {
12
+ const base = prefix.replace(/\/+$/, '');
13
+ return namespace ? `${base}/${namespace}/${language}.json` : `${base}/${language}.json`;
14
+ };
15
+
16
+ /**
17
+ * Carries which record failed, because the url alone does not say it.
18
+ *
19
+ * The caller decides whether one unreadable file costs a namespace or the whole load, and it
20
+ * cannot decide that without knowing which namespace it was.
21
+ */
22
+ export class RecordLoadError extends Error {
23
+ constructor(
24
+ readonly descriptor: RecordDescriptor,
25
+ readonly url: string,
26
+ readonly cause: unknown
27
+ ) {
28
+ super(`Metaglotta: could not load ${descriptor.namespace || '(default)'}/${descriptor.language} from ${url}`);
29
+ this.name = 'RecordLoadError';
30
+ }
31
+ }
32
+
33
+ export type Backend = {
34
+ load: (descriptor: RecordDescriptor) => Promise<TranslationsInput | undefined>;
35
+ /** Whether a failed record is survivable. Read by the caller, not here. */
36
+ fallbackOnFail: boolean;
37
+ };
38
+
39
+ export function createBackend(options: BackendOptions): Backend {
40
+ const prefix = options.prefix ?? '/i18n';
41
+ const getPath = options.getPath ?? defaultGetPath;
42
+ const getData = options.getData ?? ((response: Response) => response.json() as Promise<unknown>);
43
+ const headers = { Accept: 'application/json', ...options.headers };
44
+ const fetchFn = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
45
+
46
+ return {
47
+ fallbackOnFail: options.fallbackOnFail !== false,
48
+ async load(descriptor: RecordDescriptor): Promise<TranslationsInput | undefined> {
49
+ const url = getPath({ ...descriptor, prefix });
50
+ try {
51
+ const response = await withTimeout(() => fetchFn(url, { headers }), options.timeout, url);
52
+ if (!response.ok) throw new Error(`responded ${response.status}`);
53
+ return (await getData(response, descriptor)) as TranslationsInput;
54
+ } catch (error) {
55
+ throw new RecordLoadError(descriptor, url, error);
56
+ }
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * A fetch that gives up.
63
+ *
64
+ * Without this a hung connection holds the initial load open for as long as the browser's own
65
+ * timeout, which is minutes - and an application that awaits run() waits with it.
66
+ */
67
+ async function withTimeout(run: () => Promise<Response>, ms: number | undefined, url: string): Promise<Response> {
68
+ if (ms === undefined) return run();
69
+
70
+ let timer: ReturnType<typeof setTimeout> | undefined;
71
+ try {
72
+ return await Promise.race([
73
+ run(),
74
+ new Promise<never>((_, reject) => {
75
+ timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms: ${url}`)), ms);
76
+ }),
77
+ ]);
78
+ } finally {
79
+ if (timer) clearTimeout(timer);
80
+ }
81
+ }
@@ -0,0 +1,99 @@
1
+ import { createCache, flatten, recordKey } from './cache.js';
2
+
3
+ describe('flatten', () => {
4
+ it('joins nested keys with a dot, which is how templates ask for them', () => {
5
+ expect(flatten({ menu: { file: { save: 'Save' } }, ok: 'OK' })).toEqual({ 'menu.file.save': 'Save', ok: 'OK' });
6
+ });
7
+
8
+ /**
9
+ * A null in the file means the string has no translation yet. Storing it would count as a
10
+ * hit and stop the language fallback from ever running, so the key would render empty in
11
+ * every language instead of falling back to English.
12
+ */
13
+ it('drops empty values rather than storing them as hits', () => {
14
+ expect(flatten({ a: null, b: undefined, c: 'here' })).toEqual({ c: 'here' });
15
+ });
16
+
17
+ it('keeps numbers and booleans as the strings they render as', () => {
18
+ expect(flatten({ count: 3, on: true })).toEqual({ count: '3', on: 'true' });
19
+ });
20
+
21
+ it('has nothing to say about an empty file', () => {
22
+ expect(flatten({})).toEqual({});
23
+ });
24
+ });
25
+
26
+ describe('recordKey', () => {
27
+ it('names the default namespace by the language alone', () => {
28
+ expect(recordKey({ language: 'en', namespace: '' })).toBe('en');
29
+ expect(recordKey({ language: 'en', namespace: 'login' })).toBe('en:login');
30
+ });
31
+ });
32
+
33
+ describe('the cache', () => {
34
+ it('stores and finds a translation', () => {
35
+ const cache = createCache();
36
+ cache.set({ language: 'en', namespace: 'login' }, { greeting: 'Hello' });
37
+
38
+ expect(cache.find(['login'], ['en'], 'greeting')).toEqual({ value: 'Hello', namespace: 'login' });
39
+ expect(cache.find(['login'], ['en'], 'missing')).toBeUndefined();
40
+ });
41
+
42
+ /**
43
+ * The order of the two loops decides which translation the page shows, and it only shows
44
+ * up for keys that exist in more than one namespace - which is to say, silently.
45
+ *
46
+ * Namespace is the outer loop. Asked for `login` with `common` as fallback, in `el` with
47
+ * `en` as fallback, a key present in BOTH login/en and common/el resolves to login/en:
48
+ * the namespace you asked for wins over the language you asked for.
49
+ */
50
+ it('exhausts a namespace before moving to the next one', () => {
51
+ const cache = createCache();
52
+ cache.set({ language: 'en', namespace: 'login' }, { greeting: 'Hello from login' });
53
+ cache.set({ language: 'el', namespace: 'common' }, { greeting: 'Γεια from common' });
54
+
55
+ expect(cache.find(['login', 'common'], ['el', 'en'], 'greeting')).toEqual({ value: 'Hello from login', namespace: 'login' });
56
+ });
57
+
58
+ /**
59
+ * And it says which namespace answered, not which one was asked for. Anything that edits
60
+ * the string afterwards has to go to where the string actually is.
61
+ */
62
+ it('falls through to the next namespace when the first has nothing, and says so', () => {
63
+ const cache = createCache();
64
+ cache.set({ language: 'el', namespace: 'common' }, { greeting: 'Γεια' });
65
+
66
+ expect(cache.find(['login', 'common'], ['el', 'en'], 'greeting')).toEqual({ value: 'Γεια', namespace: 'common' });
67
+ });
68
+
69
+ it('flattens on the way in, so nested files are found by their dotted key', () => {
70
+ const cache = createCache();
71
+ cache.set({ language: 'en', namespace: '' }, { menu: { save: 'Save' } });
72
+
73
+ expect(cache.find([''], ['en'], 'menu.save')?.value).toBe('Save');
74
+ });
75
+
76
+ it('knows whether a record has been loaded, which is not the same as having the key', () => {
77
+ const cache = createCache();
78
+ cache.set({ language: 'en', namespace: 'login' }, {});
79
+
80
+ expect(cache.has({ language: 'en', namespace: 'login' })).toBe(true);
81
+ expect(cache.has({ language: 'el', namespace: 'login' })).toBe(false);
82
+ });
83
+
84
+ describe('writing a single translation', () => {
85
+ it('changes what a lookup finds', () => {
86
+ const cache = createCache();
87
+ cache.set({ language: 'en', namespace: '' }, { greeting: 'Hello' });
88
+
89
+ expect(cache.write({ language: 'en', namespace: '' }, 'greeting', 'Hi')).toBe(true);
90
+ expect(cache.find([''], ['en'], 'greeting')?.value).toBe('Hi');
91
+ });
92
+
93
+ /** Writing into a record nobody loaded would invent a half-loaded namespace. */
94
+ it('refuses a record that was never loaded, and says so', () => {
95
+ const cache = createCache();
96
+ expect(cache.write({ language: 'en', namespace: 'nope' }, 'greeting', 'Hi')).toBe(false);
97
+ });
98
+ });
99
+ });
package/src/cache.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { RecordDescriptor, TranslationsInput, TranslationsRecord } from './types.js';
2
+
3
+ /**
4
+ * Translations, keyed by language and namespace.
5
+ *
6
+ * Flat on the way in. A file may nest - `{ "menu": { "save": "Save" } }` - and callers ask
7
+ * for `menu.save`, so the nesting is resolved once at load rather than walked on every
8
+ * lookup. Empty values are dropped rather than stored: a null in the file means the string
9
+ * has no translation, and storing it would stop the language fallback from ever running.
10
+ */
11
+ export function flatten(data: TranslationsInput, prefix = ''): TranslationsRecord {
12
+ const flat: TranslationsRecord = {};
13
+
14
+ for (const [key, value] of Object.entries(data)) {
15
+ if (value === undefined || value === null) continue;
16
+ const path = prefix ? `${prefix}.${key}` : key;
17
+ if (typeof value === 'object') {
18
+ Object.assign(flat, flatten(value, path));
19
+ } else {
20
+ flat[path] = String(value);
21
+ }
22
+ }
23
+
24
+ return flat;
25
+ }
26
+
27
+ /** `en` for the default namespace, `en:login` otherwise - the shape staticData is keyed by. */
28
+ export function recordKey({ language, namespace }: RecordDescriptor): string {
29
+ return namespace ? `${language}:${namespace}` : language;
30
+ }
31
+
32
+ export function createCache() {
33
+ const records = new Map<string, TranslationsRecord>();
34
+
35
+ return {
36
+ has(descriptor: RecordDescriptor): boolean {
37
+ return records.has(recordKey(descriptor));
38
+ },
39
+
40
+ set(descriptor: RecordDescriptor, data: TranslationsInput): void {
41
+ records.set(recordKey(descriptor), flatten(data));
42
+ },
43
+
44
+ /**
45
+ * The first stored value, trying every namespace against every language.
46
+ *
47
+ * Namespace is the OUTER loop, and that is not arbitrary: a key present in both the
48
+ * asked-for namespace's fallback language and a fallback namespace's primary
49
+ * language resolves to the former. Reversing the loops changes which translation the
50
+ * page shows, silently, and only for keys that exist in two namespaces.
51
+ *
52
+ * WHERE it was found comes back with it. A template that names no namespace still
53
+ * resolves to a real one, and anything that wants to edit the string afterwards needs
54
+ * that name rather than the nothing the caller asked with.
55
+ */
56
+ find(namespaces: string[], languages: string[], key: string): { value: string; namespace: string } | undefined {
57
+ for (const namespace of namespaces) {
58
+ for (const language of languages) {
59
+ const value = records.get(recordKey({ language, namespace }))?.[key];
60
+ if (value !== undefined) return { value, namespace };
61
+ }
62
+ }
63
+ return undefined;
64
+ },
65
+
66
+ /** In-memory only, for a page whose text was just edited elsewhere. */
67
+ write(descriptor: RecordDescriptor, key: string, value: string): boolean {
68
+ const record = records.get(recordKey(descriptor));
69
+ if (!record) return false;
70
+ record[key] = value;
71
+ return true;
72
+ },
73
+
74
+ clear(): void {
75
+ records.clear();
76
+ },
77
+ };
78
+ }
79
+
80
+ export type Cache = ReturnType<typeof createCache>;
@@ -0,0 +1,118 @@
1
+ import { createEvents } from './events.js';
2
+
3
+ /**
4
+ * What counts as "the page may now be wrong", and when it is said.
5
+ *
6
+ * The batching is the part with teeth. Every record written during a load is a change, and a
7
+ * load writes one per language per namespace - so an unbatched runtime re-translates the
8
+ * whole page once per file, which for these applications is six times for one load.
9
+ */
10
+ describe('events', () => {
11
+ beforeEach(() => jest.useFakeTimers());
12
+ afterEach(() => jest.useRealTimers());
13
+
14
+ it('delivers an event to its own listeners, with its type and value', () => {
15
+ const events = createEvents();
16
+ const seen: unknown[] = [];
17
+ events.on('language', event => seen.push(event));
18
+
19
+ events.emit('language', 'el');
20
+
21
+ expect(seen).toEqual([{ type: 'language', value: 'el' }]);
22
+ });
23
+
24
+ it('stops delivering once unsubscribed', () => {
25
+ const events = createEvents();
26
+ const seen: unknown[] = [];
27
+ const subscription = events.on('language', event => seen.push(event));
28
+
29
+ events.emit('language', 'el');
30
+ subscription.unsubscribe();
31
+ events.emit('language', 'en');
32
+
33
+ expect(seen).toHaveLength(1);
34
+ });
35
+
36
+ it('reports a language change at once, because the page should not wait a tick for it', () => {
37
+ const events = createEvents();
38
+ const updates: unknown[] = [];
39
+ events.on('update', batch => updates.push(batch));
40
+
41
+ events.emit('language', 'el');
42
+
43
+ expect(updates).toHaveLength(1);
44
+ });
45
+
46
+ it('collects the writes of one load into a single update', () => {
47
+ const events = createEvents();
48
+ const updates: unknown[][] = [];
49
+ events.on('update', (batch: never) => updates.push(batch));
50
+
51
+ events.emit('cache', { language: 'en', namespace: 'common' });
52
+ events.emit('cache', { language: 'en', namespace: 'login' });
53
+ events.emit('cache', { language: 'el', namespace: 'common' });
54
+ expect(updates).toHaveLength(0);
55
+
56
+ jest.runAllTimers();
57
+
58
+ expect(updates).toHaveLength(1);
59
+ expect(updates[0]).toHaveLength(3);
60
+ });
61
+
62
+ /** A file that failed to load changed nothing on screen; re-rendering would be noise. */
63
+ it('does not call an error an update', () => {
64
+ const events = createEvents();
65
+ const updates: unknown[] = [];
66
+ events.on('update', batch => updates.push(batch));
67
+
68
+ events.emit('error', new Error('no'));
69
+ jest.runAllTimers();
70
+
71
+ expect(updates).toHaveLength(0);
72
+ });
73
+
74
+ it('still reports the error to anyone listening for one', () => {
75
+ const events = createEvents();
76
+ const errors: unknown[] = [];
77
+ events.on('error', event => errors.push(event));
78
+
79
+ events.emit('error', 'went wrong');
80
+
81
+ expect(errors).toEqual([{ type: 'error', value: 'went wrong' }]);
82
+ });
83
+
84
+ /**
85
+ * A handler that unsubscribes itself while the list is being walked would otherwise make
86
+ * the loop skip the handler that followed it - the classic mutate-while-iterating bug,
87
+ * and one that only shows up when two subscribers happen to be adjacent.
88
+ */
89
+ it('delivers to every listener even when one unsubscribes mid-delivery', () => {
90
+ const events = createEvents();
91
+ const seen: string[] = [];
92
+
93
+ const first = events.on('language', () => {
94
+ seen.push('first');
95
+ first.unsubscribe();
96
+ });
97
+ events.on('language', () => seen.push('second'));
98
+
99
+ events.emit('language', 'el');
100
+
101
+ expect(seen).toEqual(['first', 'second']);
102
+ });
103
+
104
+ it('flushes a pending batch alongside an immediate one rather than losing it', () => {
105
+ const events = createEvents();
106
+ const updates: unknown[][] = [];
107
+ events.on('update', (batch: never) => updates.push(batch));
108
+
109
+ events.emit('cache', { language: 'en', namespace: 'common' });
110
+ events.emit('language', 'el');
111
+
112
+ expect(updates).toHaveLength(1);
113
+ expect(updates[0]).toHaveLength(2);
114
+
115
+ jest.runAllTimers();
116
+ expect(updates).toHaveLength(1);
117
+ });
118
+ });
package/src/events.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { EventName, I18nEvent, Subscription } from './types.js';
2
+
3
+ /**
4
+ * Which events mean "what is on screen may now be wrong".
5
+ *
6
+ * Cache writes are batched. A load writes one record per (language, namespace), and emitting
7
+ * an update for each would re-translate the page six times for one load, so they are queued
8
+ * and flushed on the next tick. A language change is not batched: it is a single event and
9
+ * the page should not wait a tick to reflect it. An error is not an update at all - nothing
10
+ * on screen changed because a file failed to load.
11
+ */
12
+ const FEEDS_UPDATE: Partial<Record<EventName, 'now' | 'batched'>> = {
13
+ language: 'now',
14
+ initialLoad: 'now',
15
+ cache: 'batched',
16
+ };
17
+
18
+ export function createEvents() {
19
+ const handlers = new Map<string, ((event: never) => void)[]>();
20
+ let queued: I18nEvent[] = [];
21
+ let flushScheduled = false;
22
+
23
+ function listeners(name: string): ((event: never) => void)[] {
24
+ const existing = handlers.get(name);
25
+ if (existing) return existing;
26
+ const created: ((event: never) => void)[] = [];
27
+ handlers.set(name, created);
28
+ return created;
29
+ }
30
+
31
+ function deliver(name: string, payload: unknown): void {
32
+ // Copied before iterating: a handler that unsubscribes itself would otherwise make
33
+ // the loop skip the handler after it.
34
+ for (const handler of [...listeners(name)]) (handler as (event: unknown) => void)(payload);
35
+ }
36
+
37
+ function flush(): void {
38
+ flushScheduled = false;
39
+ if (!queued.length) return;
40
+ const batch = queued;
41
+ queued = [];
42
+ deliver('update', batch);
43
+ }
44
+
45
+ return {
46
+ on(name: EventName | 'update', handler: (event: never) => void): Subscription {
47
+ const list = listeners(name);
48
+ list.push(handler);
49
+ return {
50
+ unsubscribe() {
51
+ const at = list.indexOf(handler);
52
+ if (at >= 0) list.splice(at, 1);
53
+ },
54
+ };
55
+ },
56
+
57
+ emit(name: EventName, value: unknown): void {
58
+ const event: I18nEvent = { type: name, value };
59
+ deliver(name, event);
60
+
61
+ const update = FEEDS_UPDATE[name];
62
+ if (!update) return;
63
+
64
+ queued.push(event);
65
+ if (update === 'now') {
66
+ flush();
67
+ return;
68
+ }
69
+ if (!flushScheduled) {
70
+ flushScheduled = true;
71
+ setTimeout(flush, 0);
72
+ }
73
+ },
74
+ };
75
+ }
76
+
77
+ export type Events = ReturnType<typeof createEvents>;
@@ -0,0 +1,92 @@
1
+ import { formatIcu, forgetCompiled, resolveFormat } from './format.js';
2
+
3
+ /**
4
+ * ICU formatting, and the shortcut past it.
5
+ *
6
+ * The shortcut is where a mistake would hide: most translations are literal text, so skipping
7
+ * the parser for them is most of the speed - but skip a string that only LOOKS literal and it
8
+ * renders with its escapes showing, in one language, on one page.
9
+ */
10
+ describe('formatIcu', () => {
11
+ beforeEach(() => forgetCompiled());
12
+
13
+ const format = (translation: string, params?: Record<string, unknown>, language = 'en') => formatIcu({ translation, language, params });
14
+
15
+ it('substitutes a placeholder', () => {
16
+ expect(format('Hello {name}', { name: 'Ada' })).toBe('Hello Ada');
17
+ });
18
+
19
+ it('returns literal text unchanged', () => {
20
+ expect(format('Hello there')).toBe('Hello there');
21
+ });
22
+
23
+ it('chooses a plural form', () => {
24
+ const message = '{count, plural, one {# item} other {# items}}';
25
+ expect(format(message, { count: 1 })).toBe('1 item');
26
+ expect(format(message, { count: 5 })).toBe('5 items');
27
+ });
28
+
29
+ /** Greek has its own plural rules, and the language argument is what selects them. */
30
+ it('pluralises in the language it was given', () => {
31
+ const message = '{count, plural, one {# αρχείο} other {# αρχεία}}';
32
+ expect(formatIcu({ translation: message, language: 'el', params: { count: 1 } })).toBe('1 αρχείο');
33
+ expect(formatIcu({ translation: message, language: 'el', params: { count: 3 } })).toBe('3 αρχεία');
34
+ });
35
+
36
+ it('selects on a value', () => {
37
+ const message = '{gender, select, female {She} male {He} other {They}} replied';
38
+ expect(format(message, { gender: 'female' })).toBe('She replied');
39
+ expect(format(message, { gender: 'x' })).toBe('They replied');
40
+ });
41
+
42
+ /**
43
+ * The escape cases, and the reason the shortcut tests for an apostrophe rather than only
44
+ * for a brace. In ICU `''` is one apostrophe and `'{'` is a literal brace - so a string
45
+ * with no brace at all can still need the parser.
46
+ */
47
+ it('unescapes a doubled apostrophe, which has no brace to give it away', () => {
48
+ expect(format("It''s ready")).toBe("It's ready");
49
+ });
50
+
51
+ it('leaves a lone apostrophe alone', () => {
52
+ expect(format("L'utilisateur")).toBe("L'utilisateur");
53
+ });
54
+
55
+ it('renders a quoted brace as a brace rather than a placeholder', () => {
56
+ expect(format("Use '{' to open")).toBe('Use { to open');
57
+ });
58
+
59
+ it('formats a number for the language', () => {
60
+ expect(format('{n, number}', { n: 1234.5 })).toBe('1,234.5');
61
+ });
62
+
63
+ /** Same message, two languages: caching on the text alone would return the wrong one. */
64
+ it('keeps the languages apart in its cache', () => {
65
+ const message = '{n, number}';
66
+ expect(formatIcu({ translation: message, language: 'en', params: { n: 1234.5 } })).toBe('1,234.5');
67
+ expect(formatIcu({ translation: message, language: 'de', params: { n: 1234.5 } })).toBe('1.234,5');
68
+ });
69
+
70
+ it('gives the same answer the second time, from the cache', () => {
71
+ expect(format('Hello {name}', { name: 'Ada' })).toBe('Hello Ada');
72
+ expect(format('Hello {name}', { name: 'Grace' })).toBe('Hello Grace');
73
+ });
74
+
75
+ it('throws on a message it cannot parse, for the caller to turn into something readable', () => {
76
+ expect(() => format('Hello {name')).toThrow();
77
+ });
78
+ });
79
+
80
+ describe('resolveFormat', () => {
81
+ it('defaults to ICU', () => {
82
+ expect(resolveFormat(undefined)({ translation: 'Hello {name}', language: 'en', params: { name: 'Ada' } })).toBe('Hello Ada');
83
+ });
84
+
85
+ it('can be turned off, which leaves the stored string exactly as it is', () => {
86
+ expect(resolveFormat('none')({ translation: 'Hello {name}', language: 'en', params: { name: 'Ada' } })).toBe('Hello {name}');
87
+ });
88
+
89
+ it('takes a formatter of your own', () => {
90
+ expect(resolveFormat(({ translation }) => translation.toUpperCase())({ translation: 'hello', language: 'en' })).toBe('HELLO');
91
+ });
92
+ });
package/src/format.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The NAMED import, not the default one.
3
+ *
4
+ * intl-messageformat ships CommonJS with an ESM wrapper, and under Node's ESM interop the
5
+ * default export resolves to the whole module namespace rather than to the class - so
6
+ * `new IntlMessageFormat(...)` throws "is not a constructor", and every string with a
7
+ * placeholder in it renders as the format-error text. Bundlers and jest both take the
8
+ * CommonJS path, where the default DOES work, so nothing catches this until it is running
9
+ * somewhere real. The named export is the class in both.
10
+ */
11
+ import { IntlMessageFormat } from 'intl-messageformat';
12
+ import type { FormatFn, TranslateParams } from './types.js';
13
+
14
+ /**
15
+ * ICU MessageFormat, over FormatJS directly rather than through a wrapper.
16
+ *
17
+ * Compiled formatters are cached by (language, message). Parsing an ICU message is the
18
+ * expensive half, and the message text is what varies least: the same string is formatted
19
+ * once per render of every component that shows it, with different parameters each time.
20
+ */
21
+ const compiled = new Map<string, IntlMessageFormat>();
22
+
23
+ /**
24
+ * Strings that cannot mean anything but themselves.
25
+ *
26
+ * Most translations are plain text, and running the parser over them is pure cost. Only two
27
+ * characters can make a string non-literal: `{` opens a placeholder, and `'` is ICU's escape
28
+ * character - `'{'` renders a literal brace and `''` renders one apostrophe, so `It''s` would
29
+ * render wrongly if this shortcut took it. `#` is special only inside a plural, which needs a
30
+ * `{` to open.
31
+ */
32
+ const LITERAL = /^[^{']*$/;
33
+
34
+ export function formatIcu({ translation, language, params }: { translation: string; language: string; params?: TranslateParams }): string {
35
+ if (LITERAL.test(translation)) return translation;
36
+
37
+ const cacheKey = `${language} ${translation}`;
38
+ let formatter = compiled.get(cacheKey);
39
+ if (!formatter) {
40
+ formatter = new IntlMessageFormat(translation, language);
41
+ compiled.set(cacheKey, formatter);
42
+ }
43
+
44
+ const formatted = formatter.format(params as Record<string, string | number> | undefined);
45
+ // format() returns an array for messages with rich-text tags. Nothing here uses those,
46
+ // but joining beats rendering "[object Object]" if one ever appears.
47
+ return Array.isArray(formatted) ? formatted.join('') : String(formatted);
48
+ }
49
+
50
+ /** Test seam: the cache is a module global by design, and lives as long as the page. */
51
+ export function forgetCompiled(): void {
52
+ compiled.clear();
53
+ }
54
+
55
+ export function resolveFormat(format: 'icu' | 'none' | FormatFn | undefined): FormatFn {
56
+ if (typeof format === 'function') return format;
57
+ if (format === 'none') return ({ translation }) => translation;
58
+ return formatIcu;
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { Metaglotta } from './instance.js';
2
+ export type { I18nInstance } from './instance.js';
3
+ export { RecordLoadError } from './backend.js';
4
+ export { getTranslateProps, asList, unique } from './props.js';
5
+ export { flatten, recordKey } from './cache.js';
6
+ export { formatIcu, forgetCompiled, resolveFormat } from './format.js';
7
+ export type {
8
+ BackendOptions,
9
+ EventName,
10
+ FormatFn,
11
+ GetPath,
12
+ I18nEvent,
13
+ LanguageFallback,
14
+ MetaglottaOptions,
15
+ NamespaceFallback,
16
+ RecordDescriptor,
17
+ Subscription,
18
+ TranslateParams,
19
+ TranslateProps,
20
+ DecorateFn,
21
+ TranslateOptions,
22
+ TranslateFn,
23
+ TranslationsInput,
24
+ TranslationsRecord,
25
+ } from './types.js';