@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
package/src/instance.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { createBackend, RecordLoadError, type Backend } from './backend.js';
|
|
2
|
+
import { createCache } from './cache.js';
|
|
3
|
+
import { createEvents } from './events.js';
|
|
4
|
+
import { resolveFormat } from './format.js';
|
|
5
|
+
import { asList, getTranslateProps, unique } from './props.js';
|
|
6
|
+
import type { EventName, LanguageFallback, MetaglottaOptions, NamespaceFallback, RecordDescriptor, Subscription, TranslateProps } from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What both runtimes provide, and all the Angular layer asks for.
|
|
10
|
+
*
|
|
11
|
+
* Eight methods. That number is the whole reason this package could be written at all: the
|
|
12
|
+
* dependency being replaced is nineteen thousand lines, but the part anything here actually
|
|
13
|
+
* calls is this - measured by counting call sites, not by reading a feature list.
|
|
14
|
+
*/
|
|
15
|
+
export type I18nInstance = {
|
|
16
|
+
t: (keyOrProps: string | TranslateProps, ...rest: unknown[]) => string;
|
|
17
|
+
on: (event: EventName | 'update', handler: (event: never) => void) => Subscription;
|
|
18
|
+
run: () => Promise<void>;
|
|
19
|
+
stop: () => void;
|
|
20
|
+
getLanguage: () => string | undefined;
|
|
21
|
+
changeLanguage: (language: string) => Promise<void>;
|
|
22
|
+
addActiveNs: (ns?: NamespaceFallback, forget?: boolean) => Promise<void>;
|
|
23
|
+
removeActiveNs: (ns?: NamespaceFallback) => void;
|
|
24
|
+
/** In-memory only, and only where the record is loaded. Returns whether it took. */
|
|
25
|
+
changeTranslation: (descriptor: { language?: string; namespace?: string }, key: string, value: string) => boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DEFAULT_FORMAT_ERROR = 'invalid';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The Metaglotta translation runtime.
|
|
32
|
+
*
|
|
33
|
+
* Everything the eleven applications do in production: hold translations per language and
|
|
34
|
+
* namespace, fetch the ones they need from the CDN, fall back through namespaces and
|
|
35
|
+
* languages when a key is missing, format ICU, and say when any of that changed.
|
|
36
|
+
*
|
|
37
|
+
* What it deliberately does not do is in-context editing. That needs the page's rendered text
|
|
38
|
+
* traced back to its key, which means wrapping every string and watching the DOM - a
|
|
39
|
+
* different job, only wanted while authoring, and it stays where it is until it is rewritten
|
|
40
|
+
* too. Both runtimes satisfy I18nInstance, so a bootstrap picks one per environment.
|
|
41
|
+
*/
|
|
42
|
+
export function Metaglotta(options: MetaglottaOptions): I18nInstance {
|
|
43
|
+
const events = createEvents();
|
|
44
|
+
const cache = createCache();
|
|
45
|
+
const format = resolveFormat(options.format);
|
|
46
|
+
const backend: Backend | undefined = options.backend ? createBackend(options.backend) : undefined;
|
|
47
|
+
const onMissing = options.onMissing ?? ((props: TranslateProps) => props.key);
|
|
48
|
+
// Identity in production: there is nothing to decorate a string with, and a call per
|
|
49
|
+
// rendered string is worth not making.
|
|
50
|
+
const decorate: NonNullable<MetaglottaOptions['decorate']> = options.decorate ?? ((result: string) => result);
|
|
51
|
+
|
|
52
|
+
/** Namespaces a subscriber is currently holding open, by how many hold each. */
|
|
53
|
+
const active = new Map<string, number>();
|
|
54
|
+
/**
|
|
55
|
+
* Loads in flight, by record.
|
|
56
|
+
*
|
|
57
|
+
* Every pipe instance activates its namespace on subscribe - some thousands of them on a
|
|
58
|
+
* first render - and without this each would start its own fetch of the same file.
|
|
59
|
+
*/
|
|
60
|
+
const inFlight = new Map<string, Promise<void>>();
|
|
61
|
+
|
|
62
|
+
let language = options.language;
|
|
63
|
+
/** The language a change is on its way to, so a slower earlier change cannot win. */
|
|
64
|
+
let pending = options.language;
|
|
65
|
+
let running = false;
|
|
66
|
+
let runPromise: Promise<void> | undefined;
|
|
67
|
+
|
|
68
|
+
for (const [key, data] of Object.entries(options.staticData ?? {})) {
|
|
69
|
+
const [first, ...rest] = key.split(':');
|
|
70
|
+
cache.set({ language: first!, namespace: rest.join(':') }, data);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const defaultNamespace = (): string => options.defaultNs ?? options.ns?.[0] ?? '';
|
|
74
|
+
|
|
75
|
+
/** Where a lookup looks, in order: the one asked for, then the configured fallbacks. */
|
|
76
|
+
const lookupNamespaces = (ns: NamespaceFallback): string[] => unique([...asList(ns ?? defaultNamespace()), ...asList(options.fallbackNs)]);
|
|
77
|
+
|
|
78
|
+
/** Everything that must be in memory: the fallbacks plus whatever is being held open. */
|
|
79
|
+
const requiredNamespaces = (ns?: NamespaceFallback): string[] =>
|
|
80
|
+
unique([...asList(ns ?? defaultNamespace()), defaultNamespace(), ...(options.ns ?? []), ...asList(options.fallbackNs), ...active.keys()]);
|
|
81
|
+
|
|
82
|
+
const languagesFor = (requested?: string): string[] => {
|
|
83
|
+
const from = requested || language;
|
|
84
|
+
if (!from) return [];
|
|
85
|
+
return unique([from, ...fallbacksOf(from, options.fallbackLanguage)]);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const descriptors = (forLanguage?: string, ns?: NamespaceFallback): RecordDescriptor[] =>
|
|
89
|
+
languagesFor(forLanguage).flatMap(lang => requiredNamespaces(ns).map(namespace => ({ language: lang, namespace })));
|
|
90
|
+
|
|
91
|
+
async function loadOne(descriptor: RecordDescriptor): Promise<void> {
|
|
92
|
+
if (!backend || cache.has(descriptor)) return;
|
|
93
|
+
|
|
94
|
+
const key = `${descriptor.language}:${descriptor.namespace}`;
|
|
95
|
+
const existing = inFlight.get(key);
|
|
96
|
+
if (existing) return existing;
|
|
97
|
+
|
|
98
|
+
const attempt = backend
|
|
99
|
+
.load(descriptor)
|
|
100
|
+
.then(data => {
|
|
101
|
+
// A backend that returns nothing has nothing to say about this record, which
|
|
102
|
+
// is different from failing: leave the record absent so a later load retries.
|
|
103
|
+
if (data) {
|
|
104
|
+
cache.set(descriptor, data);
|
|
105
|
+
events.emit('cache', descriptor);
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.catch((error: unknown) => {
|
|
109
|
+
events.emit('error', error);
|
|
110
|
+
console.error(error);
|
|
111
|
+
if (!backend.fallbackOnFail) throw error;
|
|
112
|
+
/**
|
|
113
|
+
* Remembered as empty rather than left absent.
|
|
114
|
+
*
|
|
115
|
+
* Absent means "not loaded", and every one of the thousands of pipes on a
|
|
116
|
+
* page activates its namespace on subscribe - so a namespace whose file 404s
|
|
117
|
+
* would be re-fetched once per pipe, for as long as the page is open. One
|
|
118
|
+
* bad path is a deploy mistake; it should not also be a thundering herd.
|
|
119
|
+
*/
|
|
120
|
+
cache.set(descriptor, {});
|
|
121
|
+
})
|
|
122
|
+
.finally(() => inFlight.delete(key));
|
|
123
|
+
|
|
124
|
+
inFlight.set(key, attempt);
|
|
125
|
+
return attempt;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const loadAll = async (records: RecordDescriptor[]): Promise<void> => {
|
|
129
|
+
await Promise.all(records.map(loadOne));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function formatted(props: TranslateProps, text: string, forLanguage: string): string {
|
|
133
|
+
try {
|
|
134
|
+
return format({ translation: text, language: forLanguage, params: props.params });
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error(error);
|
|
137
|
+
const handler = options.onFormatError;
|
|
138
|
+
if (typeof handler === 'string') return handler;
|
|
139
|
+
if (typeof handler === 'function') return handler(messageOf(error), props);
|
|
140
|
+
return DEFAULT_FORMAT_ERROR;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const self: I18nInstance = {
|
|
145
|
+
t(keyOrProps, ...rest) {
|
|
146
|
+
const props = getTranslateProps(keyOrProps, ...rest);
|
|
147
|
+
const namespaces = lookupNamespaces(props.ns);
|
|
148
|
+
const stored = cache.find(namespaces, languagesFor(props.language), props.key);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Where the string came from, or - with nothing stored - where a new one would go.
|
|
152
|
+
*
|
|
153
|
+
* Only interesting to the decorate pass, and only in the authoring build, but it
|
|
154
|
+
* has to be worked out here: this is the one place that knows which namespace
|
|
155
|
+
* answered, and by the time anybody clicks the rendered text it is unknowable.
|
|
156
|
+
*/
|
|
157
|
+
const namespace = stored?.namespace ?? namespaces[0] ?? '';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The missing case, which two call sites depend on being exactly this.
|
|
161
|
+
*
|
|
162
|
+
* With nothing stored and no default, t() ECHOES THE KEY - that is what makes a
|
|
163
|
+
* missing string visible on the page instead of a silent gap, and callers write
|
|
164
|
+
* `instant(k) || fallback` against it. The pipe passes orEmpty and so renders ''.
|
|
165
|
+
*/
|
|
166
|
+
const value = stored?.value ?? props.defaultValue;
|
|
167
|
+
if (value === undefined) return decorate(props.orEmpty ? '' : onMissing(props), props, namespace);
|
|
168
|
+
|
|
169
|
+
const forLanguage = props.language || language;
|
|
170
|
+
if (!value || !forLanguage) return decorate(value, props, namespace);
|
|
171
|
+
return decorate(formatted(props, value, forLanguage), props, namespace);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Rewrites one translation in memory.
|
|
176
|
+
*
|
|
177
|
+
* For the in-context dialog: the page shows the new text the moment it is saved,
|
|
178
|
+
* rather than after a reload. Refuses a namespace nothing has loaded, because writing
|
|
179
|
+
* into one would invent a record no element reads and hide the mistake.
|
|
180
|
+
*/
|
|
181
|
+
changeTranslation(descriptor, key, value) {
|
|
182
|
+
const written = cache.write({ language: descriptor.language ?? language, namespace: descriptor.namespace ?? defaultNamespace() }, key, value);
|
|
183
|
+
if (written) events.emit('cache', descriptor);
|
|
184
|
+
return written;
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
on: events.on,
|
|
188
|
+
|
|
189
|
+
getLanguage: () => language,
|
|
190
|
+
|
|
191
|
+
async changeLanguage(next: string) {
|
|
192
|
+
if (pending === next && language === next) return;
|
|
193
|
+
pending = next;
|
|
194
|
+
|
|
195
|
+
if (running) await loadAll(descriptors(next));
|
|
196
|
+
|
|
197
|
+
// Two changes can be in flight at once, and the one that finishes last is not
|
|
198
|
+
// necessarily the one that was asked for last. Only the latest may apply.
|
|
199
|
+
if (pending !== next) return;
|
|
200
|
+
if (language !== next) {
|
|
201
|
+
language = next;
|
|
202
|
+
events.emit('language', next);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
async addActiveNs(ns?: NamespaceFallback, forget?: boolean) {
|
|
207
|
+
if (!forget) {
|
|
208
|
+
// Naming no namespace holds nothing open: the default one is always required
|
|
209
|
+
// anyway, so there is nothing for a reference count to keep alive.
|
|
210
|
+
for (const namespace of asList(ns)) {
|
|
211
|
+
active.set(namespace, (active.get(namespace) ?? 0) + 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (running) await loadAll(descriptors(undefined, ns));
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
removeActiveNs(ns?: NamespaceFallback) {
|
|
218
|
+
for (const namespace of asList(ns)) {
|
|
219
|
+
const held = active.get(namespace);
|
|
220
|
+
if (held !== undefined && held > 1) active.set(namespace, held - 1);
|
|
221
|
+
else active.delete(namespace);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
run() {
|
|
226
|
+
if (!running) {
|
|
227
|
+
running = true;
|
|
228
|
+
runPromise = loadAll(descriptors()).then(() => {
|
|
229
|
+
events.emit('initialLoad', undefined);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return runPromise ?? Promise.resolve();
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
stop() {
|
|
236
|
+
running = false;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return self;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A fallback language list, which may be given per language or for all of them. */
|
|
244
|
+
function fallbacksOf(forLanguage: string, fallback: LanguageFallback): string[] {
|
|
245
|
+
if (fallback && typeof fallback === 'object' && !Array.isArray(fallback)) {
|
|
246
|
+
return asList(fallback[forLanguage]);
|
|
247
|
+
}
|
|
248
|
+
return asList(fallback as NamespaceFallback);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function messageOf(error: unknown): string {
|
|
252
|
+
if (typeof error === 'string') return error;
|
|
253
|
+
if (error instanceof Error) return error.message;
|
|
254
|
+
return DEFAULT_FORMAT_ERROR;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { RecordLoadError };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { asList, getTranslateProps, unique } from './props.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How t() is called.
|
|
5
|
+
*
|
|
6
|
+
* Five forms, and the awkward one is `t(key, default, options)`: options and parameters share
|
|
7
|
+
* a single object, so anything that is not a known option name is a parameter. Getting that
|
|
8
|
+
* split wrong turns `ns` into a message parameter, and the string then renders from the wrong
|
|
9
|
+
* namespace with a stray placeholder.
|
|
10
|
+
*/
|
|
11
|
+
describe('getTranslateProps', () => {
|
|
12
|
+
it('takes a key on its own', () => {
|
|
13
|
+
expect(getTranslateProps('greeting')).toEqual({ key: 'greeting' });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('takes a key and a default value', () => {
|
|
17
|
+
expect(getTranslateProps('greeting', 'Hello')).toEqual({ key: 'greeting', defaultValue: 'Hello' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('reads a bare object as parameters, not as options', () => {
|
|
21
|
+
expect(getTranslateProps('greeting', { name: 'Ada' })).toEqual({ key: 'greeting', params: { name: 'Ada' } });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('separates the options from the parameters they travel with', () => {
|
|
25
|
+
expect(getTranslateProps('greeting', 'Hello', { ns: 'login', name: 'Ada' })).toEqual({
|
|
26
|
+
key: 'greeting',
|
|
27
|
+
defaultValue: 'Hello',
|
|
28
|
+
ns: 'login',
|
|
29
|
+
params: { name: 'Ada' },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('knows every option name, so none of them leaks into the parameters', () => {
|
|
34
|
+
const props = getTranslateProps('greeting', { ns: 'login', noWrap: true, orEmpty: true, language: 'el', name: 'Ada' });
|
|
35
|
+
|
|
36
|
+
expect(props.ns).toBe('login');
|
|
37
|
+
expect(props.noWrap).toBe(true);
|
|
38
|
+
expect(props.orEmpty).toBe(true);
|
|
39
|
+
expect(props.language).toBe('el');
|
|
40
|
+
expect(props.params).toEqual({ name: 'Ada' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('passes a full props object straight through', () => {
|
|
44
|
+
const props = { key: 'greeting', ns: 'login', params: { name: 'Ada' } };
|
|
45
|
+
expect(getTranslateProps(props)).toBe(props);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** The explicit key wins: it is the one the caller wrote outside the options bag. */
|
|
49
|
+
it('does not let the options bag overwrite the key', () => {
|
|
50
|
+
expect(getTranslateProps('greeting', { key: 'other' } as never).key).toBe('greeting');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('leaves parameters absent when there were none', () => {
|
|
54
|
+
expect(getTranslateProps('greeting', 'Hello').params).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('asList', () => {
|
|
59
|
+
it('accepts a fallback written either way', () => {
|
|
60
|
+
expect(asList('common')).toEqual(['common']);
|
|
61
|
+
expect(asList(['common', 'login'])).toEqual(['common', 'login']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The default namespace is the empty string, which is a real namespace, not an absence. */
|
|
65
|
+
it('keeps the empty string, which names the default namespace', () => {
|
|
66
|
+
expect(asList('')).toEqual(['']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reads nothing as no fallbacks at all', () => {
|
|
70
|
+
expect(asList(undefined)).toEqual([]);
|
|
71
|
+
expect(asList(null)).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('unique', () => {
|
|
76
|
+
it('keeps the first of each, in order', () => {
|
|
77
|
+
expect(unique(['a', 'b', 'a', 'c', 'b'])).toEqual(['a', 'b', 'c']);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/props.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { NamespaceFallback, TranslateParams, TranslateProps } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The four ways t() can be called, reduced to one shape.
|
|
5
|
+
*
|
|
6
|
+
* t('key')
|
|
7
|
+
* t('key', 'Default value')
|
|
8
|
+
* t('key', { name: 'Ada' })
|
|
9
|
+
* t('key', 'Default value', { ns: 'login' })
|
|
10
|
+
* t({ key: 'key', ns: 'login', params: { name: 'Ada' } })
|
|
11
|
+
*
|
|
12
|
+
* The third form is the awkward one: options and parameters share a single object, so
|
|
13
|
+
* anything that is not a known option is a parameter. That is why `ns`, `noWrap`, `orEmpty`
|
|
14
|
+
* and `language` cannot be used as parameter names - a limitation of the calling convention,
|
|
15
|
+
* not of the formatter.
|
|
16
|
+
*/
|
|
17
|
+
const OPTION_NAMES = ['ns', 'noWrap', 'orEmpty', 'language'] as const;
|
|
18
|
+
|
|
19
|
+
type Combined = TranslateParams & Partial<TranslateProps>;
|
|
20
|
+
|
|
21
|
+
function split(combined: Combined): Partial<TranslateProps> {
|
|
22
|
+
const options: Partial<TranslateProps> = {};
|
|
23
|
+
const params: TranslateParams = {};
|
|
24
|
+
|
|
25
|
+
for (const [name, value] of Object.entries(combined)) {
|
|
26
|
+
if ((OPTION_NAMES as readonly string[]).includes(name)) {
|
|
27
|
+
(options as Record<string, unknown>)[name] = value;
|
|
28
|
+
} else {
|
|
29
|
+
params[name] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { ...options, params };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getTranslateProps(keyOrProps: string | TranslateProps, ...rest: unknown[]): TranslateProps {
|
|
37
|
+
// Already the full shape: nothing to work out.
|
|
38
|
+
if (typeof keyOrProps === 'object' && keyOrProps !== null) return keyOrProps;
|
|
39
|
+
|
|
40
|
+
const props: TranslateProps = { key: keyOrProps };
|
|
41
|
+
let combined: Combined | undefined;
|
|
42
|
+
|
|
43
|
+
if (typeof rest[0] === 'string') {
|
|
44
|
+
props.defaultValue = rest[0];
|
|
45
|
+
combined = rest[1] as Combined | undefined;
|
|
46
|
+
} else if (typeof rest[0] === 'object' && rest[0] !== null) {
|
|
47
|
+
combined = rest[0] as Combined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return combined ? { ...split(combined), ...props } : props;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A fallback list, however it was written, as an array. */
|
|
54
|
+
export function asList(value: NamespaceFallback): string[] {
|
|
55
|
+
if (typeof value === 'string') return [value];
|
|
56
|
+
if (Array.isArray(value)) return value;
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function unique<T>(values: T[]): T[] {
|
|
61
|
+
return [...new Set(values)];
|
|
62
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a caller can pass to t(), and what the runtime hands back.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small: this is the whole surface the Angular glue and the eleven applications
|
|
5
|
+
* use, verified by counting call sites rather than by reading a feature list.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type TranslateParams = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
export type TranslateProps = {
|
|
11
|
+
key: string;
|
|
12
|
+
/** Used when nothing is stored, in place of echoing the key back. */
|
|
13
|
+
defaultValue?: string;
|
|
14
|
+
params?: TranslateParams;
|
|
15
|
+
/**
|
|
16
|
+
* Where to look. Undefined means the default namespace; null means the same, because
|
|
17
|
+
* callers that model "no namespace" as null are common enough to be worth accepting.
|
|
18
|
+
*/
|
|
19
|
+
ns?: NamespaceFallback;
|
|
20
|
+
/** Return '' rather than the key when nothing is stored. */
|
|
21
|
+
orEmpty?: boolean;
|
|
22
|
+
/** Read a language other than the current one. */
|
|
23
|
+
language?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Accepted and ignored.
|
|
26
|
+
*
|
|
27
|
+
* In-context editing wraps every rendered string so a click can be traced back to its
|
|
28
|
+
* key; noWrap opts out of that. This runtime does no wrapping at all - it is the
|
|
29
|
+
* production path - so there is nothing to opt out of. Accepted rather than rejected
|
|
30
|
+
* because the same templates render under both runtimes.
|
|
31
|
+
*/
|
|
32
|
+
noWrap?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A single namespace, several to try in order, or nothing for the default one. */
|
|
36
|
+
export type NamespaceFallback = string | string[] | undefined | null;
|
|
37
|
+
|
|
38
|
+
/** A language, several to fall back through, or a per-language map of those. */
|
|
39
|
+
export type LanguageFallback = string | string[] | Record<string, string | string[]> | undefined;
|
|
40
|
+
|
|
41
|
+
export type I18nEvent<Value = unknown> = { type: string; value: Value };
|
|
42
|
+
export type Subscription = { unsubscribe(): void };
|
|
43
|
+
|
|
44
|
+
export type EventName = 'language' | 'update' | 'running' | 'initialLoad' | 'cache' | 'error';
|
|
45
|
+
|
|
46
|
+
/** Nested json is flattened on the way in, so a record is always one level deep. */
|
|
47
|
+
export type TranslationsRecord = Record<string, string>;
|
|
48
|
+
export type TranslationsInput = { [key: string]: string | number | boolean | null | undefined | TranslationsInput };
|
|
49
|
+
|
|
50
|
+
export type RecordDescriptor = { language: string; namespace: string };
|
|
51
|
+
|
|
52
|
+
export type GetPath = (options: { namespace: string; language: string; prefix: string }) => string;
|
|
53
|
+
|
|
54
|
+
export type BackendOptions = {
|
|
55
|
+
/** Where the files live. A namespace and language are appended by getPath. */
|
|
56
|
+
prefix?: string;
|
|
57
|
+
getPath?: GetPath;
|
|
58
|
+
/**
|
|
59
|
+
* Reads the body. Given the record it was asked for, because a source may not answer in
|
|
60
|
+
* the shape it was asked in - the authoring API returns `{ [language]: {...} }` where the
|
|
61
|
+
* CDN returns the translations directly, and unwrapping that needs to know the language.
|
|
62
|
+
*/
|
|
63
|
+
getData?: (response: Response, descriptor: RecordDescriptor) => Promise<unknown>;
|
|
64
|
+
headers?: Record<string, string>;
|
|
65
|
+
/**
|
|
66
|
+
* Leave a record empty when its file cannot be read, rather than failing the whole load
|
|
67
|
+
* (default true).
|
|
68
|
+
*
|
|
69
|
+
* The opposite is what the previous runtime defaulted to, and it means one missing
|
|
70
|
+
* namespace file empties every namespace: the load is one operation and it rejects as
|
|
71
|
+
* one. A missing `reception/el.json` should cost you the reception strings, not the
|
|
72
|
+
* application. Failures are reported on the 'error' event and to the console either way.
|
|
73
|
+
*/
|
|
74
|
+
fallbackOnFail?: boolean;
|
|
75
|
+
fetch?: typeof fetch;
|
|
76
|
+
/** Abort a file that has not arrived in this many milliseconds. */
|
|
77
|
+
timeout?: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type FormatFn = (options: { translation: string; language: string; params?: TranslateParams }) => string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A last pass over the finished string, for in-context editing.
|
|
84
|
+
*
|
|
85
|
+
* The authoring build appends invisible characters naming the key, so a click on the rendered
|
|
86
|
+
* text can be traced back to what produced it. Production leaves this unset and the string
|
|
87
|
+
* goes out exactly as formatted.
|
|
88
|
+
*
|
|
89
|
+
* `namespace` is where the string was RESOLVED FROM, which is not what the caller asked with:
|
|
90
|
+
* a template writing `{{ 'greeting' | translate }}` names no namespace at all, and one
|
|
91
|
+
* writing `ns: 'login'` may still have been answered out of `common` by the fallback. Passing
|
|
92
|
+
* the caller's own answer through here instead sends anything editing the string to the wrong
|
|
93
|
+
* place - and for the default case, to a namespace nothing is in. When there is nothing stored
|
|
94
|
+
* it is the namespace a new key would be created in.
|
|
95
|
+
*/
|
|
96
|
+
export type DecorateFn = (result: string, props: TranslateProps, namespace: string) => string;
|
|
97
|
+
|
|
98
|
+
export type MetaglottaOptions = {
|
|
99
|
+
/** The language to start in. */
|
|
100
|
+
language: string;
|
|
101
|
+
/** Tried in order when the current language has nothing for a key. */
|
|
102
|
+
fallbackLanguage?: LanguageFallback;
|
|
103
|
+
/** Fetched up front, and always kept loaded. */
|
|
104
|
+
ns?: string[];
|
|
105
|
+
/** Where to look when a call names no namespace of its own. */
|
|
106
|
+
fallbackNs?: NamespaceFallback;
|
|
107
|
+
/** The namespace a call with no namespace means (default: the first of `ns`). */
|
|
108
|
+
defaultNs?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Accepted and ignored.
|
|
111
|
+
*
|
|
112
|
+
* It exists for language detection and for loading every language at once, neither of
|
|
113
|
+
* which this runtime does. Kept in the type so a bootstrap can pass the same options
|
|
114
|
+
* object to either runtime.
|
|
115
|
+
*/
|
|
116
|
+
availableLanguages?: string[];
|
|
117
|
+
/** Where translations are loaded from. Omitted, nothing is loaded. */
|
|
118
|
+
backend?: BackendOptions;
|
|
119
|
+
/**
|
|
120
|
+
* How a stored string becomes the rendered one. 'icu' is the ICU MessageFormat used
|
|
121
|
+
* across these projects; 'none' returns it unchanged.
|
|
122
|
+
*/
|
|
123
|
+
format?: 'icu' | 'none' | FormatFn;
|
|
124
|
+
/** Translations to start with, keyed `language` or `language:namespace`. */
|
|
125
|
+
staticData?: Record<string, TranslationsInput>;
|
|
126
|
+
/**
|
|
127
|
+
* What a missing translation renders as, when there is no defaultValue and orEmpty was
|
|
128
|
+
* not asked for. The default echoes the key, which is what makes a missing string
|
|
129
|
+
* visible on the page rather than a silent gap.
|
|
130
|
+
*/
|
|
131
|
+
onMissing?: (props: TranslateProps) => string;
|
|
132
|
+
/** What an unformattable string renders as. Default 'invalid'. */
|
|
133
|
+
onFormatError?: string | ((error: string, props: TranslateProps) => string);
|
|
134
|
+
/**
|
|
135
|
+
* A last pass over every finished string. Used by in-context editing and by nothing else.
|
|
136
|
+
*
|
|
137
|
+
* Applied to the MISSING case too - to the echoed key, and to the empty string orEmpty
|
|
138
|
+
* asks for - because a key with no translation yet is exactly the one somebody wants to
|
|
139
|
+
* click on.
|
|
140
|
+
*/
|
|
141
|
+
decorate?: DecorateFn;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** The options half of the combined bag `t(key, { ns, name })` accepts. */
|
|
145
|
+
export type TranslateOptions = {
|
|
146
|
+
ns?: NamespaceFallback;
|
|
147
|
+
noWrap?: boolean;
|
|
148
|
+
orEmpty?: boolean;
|
|
149
|
+
language?: string;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* How t(), instant(), translate() and the pipe are all called.
|
|
154
|
+
*
|
|
155
|
+
* Written out as overloads rather than a loose signature because these are the shapes 3,709
|
|
156
|
+
* template uses and 938 call sites already have, and the compiler is the only thing that
|
|
157
|
+
* would notice if one of them stopped being accepted.
|
|
158
|
+
*/
|
|
159
|
+
export type TranslateFn<Result> = {
|
|
160
|
+
(key: string): Result;
|
|
161
|
+
(key: string, defaultValue: string): Result;
|
|
162
|
+
(key: string, options: TranslateParams & TranslateOptions): Result;
|
|
163
|
+
(key: string, defaultValue: string, options: TranslateParams & TranslateOptions): Result;
|
|
164
|
+
(props: TranslateProps): Result;
|
|
165
|
+
};
|