@ng-linguo/linguo 0.9.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 +25 -0
- package/fesm2022/ng-linguo-linguo-http.mjs +32 -0
- package/fesm2022/ng-linguo-linguo-http.mjs.map +1 -0
- package/fesm2022/ng-linguo-linguo-icu.mjs +104 -0
- package/fesm2022/ng-linguo-linguo-icu.mjs.map +1 -0
- package/fesm2022/ng-linguo-linguo.mjs +775 -0
- package/fesm2022/ng-linguo-linguo.mjs.map +1 -0
- package/package.json +59 -0
- package/types/ng-linguo-linguo-http.d.ts +30 -0
- package/types/ng-linguo-linguo-icu.d.ts +86 -0
- package/types/ng-linguo-linguo.d.ts +454 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import { DOCUMENT } from '@angular/common';
|
|
2
|
+
import * as i0 from '@angular/core';
|
|
3
|
+
import { InjectionToken, inject, makeEnvironmentProviders, Pipe, input, TemplateRef, Directive, ElementRef, Renderer2, ViewContainerRef, contentChildren, effect } from '@angular/core';
|
|
4
|
+
import { signalStore, withState, withMethods, patchState, withHooks } from '@ngrx/signals';
|
|
5
|
+
|
|
6
|
+
function isSupported(lang, supported) {
|
|
7
|
+
return supported === undefined || supported.some((l) => l.toLowerCase() === lang.toLowerCase());
|
|
8
|
+
}
|
|
9
|
+
/** Match a BCP-47 tag (e.g. `pl-PL`) to a supported language: exact first, then
|
|
10
|
+
* its base subtag (`pl`). Returns the supported language in its original case. */
|
|
11
|
+
function matchSupported(tag, supported) {
|
|
12
|
+
const lower = tag.toLowerCase();
|
|
13
|
+
const base = lower.split('-')[0] ?? lower;
|
|
14
|
+
return (supported.find((l) => l.toLowerCase() === lower) ??
|
|
15
|
+
supported.find((l) => l.toLowerCase() === base));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the language to load on startup, in priority order:
|
|
19
|
+
* **persisted → browser-preferred → default**.
|
|
20
|
+
*
|
|
21
|
+
* Pure: the store gathers the (SSR-safe) inputs and passes them in. A persisted
|
|
22
|
+
* value is honored only if it's supported; browser preferences are matched
|
|
23
|
+
* against `supportedLangs` (so an unshipped language is never selected), and if
|
|
24
|
+
* nothing matches the `defaultLang` is used.
|
|
25
|
+
*/
|
|
26
|
+
function resolveInitialLang(input) {
|
|
27
|
+
const { persisted, browserLangs, supportedLangs, defaultLang } = input;
|
|
28
|
+
if (persisted && persisted.trim() !== '' && isSupported(persisted, supportedLangs)) {
|
|
29
|
+
return persisted;
|
|
30
|
+
}
|
|
31
|
+
if (supportedLangs !== undefined) {
|
|
32
|
+
for (const tag of browserLangs) {
|
|
33
|
+
const match = matchSupported(tag, supportedLangs);
|
|
34
|
+
if (match !== undefined) {
|
|
35
|
+
return match;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return defaultLang;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a source message to its canonical translation key.
|
|
44
|
+
*
|
|
45
|
+
* Trims the message and collapses every internal whitespace run (including
|
|
46
|
+
* newlines from multi-line templates) to a single space. This MUST stay
|
|
47
|
+
* byte-for-byte identical to the extractor's normalizer
|
|
48
|
+
* (`@ng-linguo/extract`'s `normalizeMessage`) so that a key extracted at build
|
|
49
|
+
* time resolves at runtime — the parity contract in CLAUDE.md §5.2, verified by
|
|
50
|
+
* both packages against `tests/fixtures/normalization-cases.json`.
|
|
51
|
+
*
|
|
52
|
+
* Duplicated rather than imported because `core` must not depend on `extract`
|
|
53
|
+
* (CLAUDE.md §2.1).
|
|
54
|
+
*/
|
|
55
|
+
function normalizeKey(source) {
|
|
56
|
+
return source.trim().replace(/\s+/g, ' ');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Separator joining a context to a key in the compiled dictionary. This is the
|
|
60
|
+
* gettext `pgettext` convention — the EOT control character (U+0004) — chosen
|
|
61
|
+
* because it never occurs in real translator text, so contextual keys cannot
|
|
62
|
+
* collide with plain ones. MUST stay identical to the extractor's
|
|
63
|
+
* `compileEntries`, so a contextual key produced at build time resolves at
|
|
64
|
+
* runtime.
|
|
65
|
+
*/
|
|
66
|
+
const CONTEXT_GLUE = String.fromCharCode(4);
|
|
67
|
+
/**
|
|
68
|
+
* Build the dictionary lookup key for a message, optionally disambiguated by a
|
|
69
|
+
* context label. With a context the key is `context<glue>normalizedKey`;
|
|
70
|
+
* without one it is just the normalized key — which is also the fallback the
|
|
71
|
+
* runtime tries when a contextual entry is absent.
|
|
72
|
+
*/
|
|
73
|
+
function contextKey(key, context) {
|
|
74
|
+
const normalizedKey = normalizeKey(key);
|
|
75
|
+
const normalizedContext = context?.trim() ?? '';
|
|
76
|
+
return normalizedContext === ''
|
|
77
|
+
? normalizedKey
|
|
78
|
+
: `${normalizedContext}${CONTEXT_GLUE}${normalizedKey}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* DI token carrying the {@link TranslateConfig} supplied via `provideTranslate`.
|
|
83
|
+
* Internal: consumers configure the runtime through `provideTranslate`, not by
|
|
84
|
+
* providing this token directly.
|
|
85
|
+
*/
|
|
86
|
+
const TRANSLATE_CONFIG = new InjectionToken('ng-linguo.config');
|
|
87
|
+
/**
|
|
88
|
+
* DI token carrying the resolved {@link TranslationLoader}. `provideTranslate`
|
|
89
|
+
* registers it from the config's `loader` — either directly (a ready-made
|
|
90
|
+
* loader) or via a factory run inside the injection context (so loaders that
|
|
91
|
+
* inject Angular services, like `createHttpLoader`, can be used). Internal.
|
|
92
|
+
*/
|
|
93
|
+
const TRANSLATION_LOADER = new InjectionToken('ng-linguo.loader');
|
|
94
|
+
const initialState = {
|
|
95
|
+
currentLang: '',
|
|
96
|
+
isReady: false,
|
|
97
|
+
translations: {},
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* The reactive translation runtime, implemented as an `@ngrx/signals`
|
|
101
|
+
* `signalStore`. Consumers inject it and read state through signals; there are
|
|
102
|
+
* no `Observable` getters on the public surface.
|
|
103
|
+
*
|
|
104
|
+
* State is not loaded implicitly. Call {@link TranslateStore.restoreLang} once
|
|
105
|
+
* at startup to pick the language (persisted → browser → default) and load it,
|
|
106
|
+
* or {@link TranslateStore.setLang} to switch to a specific one.
|
|
107
|
+
* {@link TranslateStore.isReady} stays `false` until a language has loaded, so
|
|
108
|
+
* UI can gate on it to avoid a flash of untranslated content.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```ts
|
|
112
|
+
* const store = inject(TranslateStore);
|
|
113
|
+
* store.currentLang(); // Signal<string>
|
|
114
|
+
* store.isReady(); // Signal<boolean>
|
|
115
|
+
* await store.restoreLang(); // startup: persisted ?? browser ?? default
|
|
116
|
+
* await store.setLang('pl'); // explicit switch
|
|
117
|
+
* store.translate('greeting');
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
const TranslateStore = signalStore({ providedIn: 'root' }, withState(initialState), withMethods((store) => {
|
|
121
|
+
const config = inject(TRANSLATE_CONFIG);
|
|
122
|
+
const loader = inject(TRANSLATION_LOADER);
|
|
123
|
+
// `defaultView` is the Window in a browser, and null under SSR — so all
|
|
124
|
+
// localStorage/navigator access below is automatically a no-op on the server.
|
|
125
|
+
const win = inject(DOCUMENT).defaultView;
|
|
126
|
+
const persistEnabled = config.persistSelectedLanguage ?? true;
|
|
127
|
+
// Restore (read on startup) defaults to the persist setting, so disabling
|
|
128
|
+
// persistence alone still disables restore (the pre-decoupling behavior);
|
|
129
|
+
// set restoreSelectedLanguage explicitly to control the two independently.
|
|
130
|
+
const restoreEnabled = config.restoreSelectedLanguage ?? persistEnabled;
|
|
131
|
+
const detectEnabled = config.detectBrowserLanguage ?? true;
|
|
132
|
+
const persistKey = config.persistKey ?? 'ng-linguo.lang';
|
|
133
|
+
function readPersisted() {
|
|
134
|
+
if (!restoreEnabled || !win)
|
|
135
|
+
return null;
|
|
136
|
+
try {
|
|
137
|
+
return win.localStorage.getItem(persistKey);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null; // storage disabled (private mode, blocked cookies, …)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function writePersisted(lang) {
|
|
144
|
+
if (!persistEnabled || !win)
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
win.localStorage.setItem(persistKey, lang);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// ignore — persistence is best-effort
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function browserLangs() {
|
|
154
|
+
const nav = win?.navigator;
|
|
155
|
+
if (!detectEnabled || !nav)
|
|
156
|
+
return [];
|
|
157
|
+
return nav.languages?.length ? nav.languages : nav.language ? [nav.language] : [];
|
|
158
|
+
}
|
|
159
|
+
async function load(lang) {
|
|
160
|
+
const translations = await loader.load(lang);
|
|
161
|
+
patchState(store, { currentLang: lang, translations, isReady: true });
|
|
162
|
+
writePersisted(lang);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
/**
|
|
166
|
+
* Load the translations for `lang`, make them current, and (when
|
|
167
|
+
* `persistSelectedLanguage` is on) persist the choice. Resolves once the
|
|
168
|
+
* loader has returned and the store is ready.
|
|
169
|
+
*/
|
|
170
|
+
setLang(lang) {
|
|
171
|
+
return load(lang);
|
|
172
|
+
},
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the startup language — **persisted → browser-preferred →
|
|
175
|
+
* `defaultLang`** — and load it. Call this once at startup instead of
|
|
176
|
+
* `setLang(defaultLang)`. Persistence and browser detection are on by
|
|
177
|
+
* default and can be turned off via `provideTranslate` config; both are
|
|
178
|
+
* SSR-safe (they fall through to the default on the server).
|
|
179
|
+
*/
|
|
180
|
+
restoreLang() {
|
|
181
|
+
return load(resolveInitialLang({
|
|
182
|
+
persisted: readPersisted(),
|
|
183
|
+
browserLangs: browserLangs(),
|
|
184
|
+
supportedLangs: config.supportedLangs,
|
|
185
|
+
defaultLang: config.defaultLang,
|
|
186
|
+
}));
|
|
187
|
+
},
|
|
188
|
+
/**
|
|
189
|
+
* Resolve a translation key against the currently loaded language.
|
|
190
|
+
*
|
|
191
|
+
* An optional `context` disambiguates keys that share the same source
|
|
192
|
+
* text (for example `Play` in a game versus a music player). Lookup tries
|
|
193
|
+
* the contextual key first, then falls back to the plain key, then to the
|
|
194
|
+
* key itself — so a missing translation is visible rather than blank, and
|
|
195
|
+
* omitting the context always resolves to the default entry.
|
|
196
|
+
*/
|
|
197
|
+
translate(key, context) {
|
|
198
|
+
const normalized = normalizeKey(key);
|
|
199
|
+
const translations = store.translations();
|
|
200
|
+
if (context !== undefined && context.trim() !== '') {
|
|
201
|
+
const contextual = translations[contextKey(key, context)];
|
|
202
|
+
if (contextual !== undefined) {
|
|
203
|
+
return contextual;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return translations[normalized] ?? normalized;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}), withHooks({
|
|
210
|
+
onInit(store) {
|
|
211
|
+
// Report the configured default language immediately, without loading.
|
|
212
|
+
patchState(store, { currentLang: inject(TRANSLATE_CONFIG).defaultLang });
|
|
213
|
+
},
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Register the translation runtime for an application or a feature scope.
|
|
218
|
+
*
|
|
219
|
+
* Add the returned providers to `bootstrapApplication`'s `providers` (or a
|
|
220
|
+
* route's `providers`). This only wires up configuration — it never starts a
|
|
221
|
+
* load, so no HTTP is fired during DI initialization. Trigger the first load
|
|
222
|
+
* explicitly via `TranslateStore.setLang`.
|
|
223
|
+
*
|
|
224
|
+
* The `loader` may be a ready-made loader object, or a factory `() => loader`
|
|
225
|
+
* that is run inside the injection context — use the factory form for loaders
|
|
226
|
+
* that inject Angular services (e.g. `createHttpLoader()`, which needs
|
|
227
|
+
* `HttpClient`).
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```ts
|
|
231
|
+
* // a static dictionary or fetch-based loader
|
|
232
|
+
* provideTranslate({ defaultLang: 'en', loader: myLoader });
|
|
233
|
+
*
|
|
234
|
+
* // an HttpClient-backed loader (factory form)
|
|
235
|
+
* provideTranslate({ defaultLang: 'en', loader: () => createHttpLoader() });
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
function provideTranslate(config) {
|
|
239
|
+
const { loader } = config;
|
|
240
|
+
return makeEnvironmentProviders([
|
|
241
|
+
{ provide: TRANSLATE_CONFIG, useValue: config },
|
|
242
|
+
typeof loader === 'function'
|
|
243
|
+
? { provide: TRANSLATION_LOADER, useFactory: loader }
|
|
244
|
+
: { provide: TRANSLATION_LOADER, useValue: loader },
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Matches a single slot tag at the start of a string: an optional leading slash
|
|
250
|
+
* (closing tag) followed by a name. The name grammar
|
|
251
|
+
* (`[a-zA-Z_][a-zA-Z0-9_-]*`) is part of the public contract — see CLAUDE.md
|
|
252
|
+
* §5.1. Changing it is a major version bump.
|
|
253
|
+
*
|
|
254
|
+
* A literal `[` is written as `[[`; that escape is handled in {@link tokenize}
|
|
255
|
+
* before this pattern is tried, so a tag is never matched across an escape.
|
|
256
|
+
*/
|
|
257
|
+
const TAG_PATTERN = /^\[(\/?)([a-zA-Z_][a-zA-Z0-9_-]*)\]/;
|
|
258
|
+
function tokenize(input) {
|
|
259
|
+
const tokens = [];
|
|
260
|
+
let buffer = '';
|
|
261
|
+
const flush = () => {
|
|
262
|
+
if (buffer.length > 0) {
|
|
263
|
+
tokens.push({ kind: 'text', value: buffer });
|
|
264
|
+
buffer = '';
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
let index = 0;
|
|
268
|
+
while (index < input.length) {
|
|
269
|
+
if (input[index] === '[') {
|
|
270
|
+
// `[[` is an escaped literal `[` — the one way a translatable string can
|
|
271
|
+
// contain text that looks like a slot tag (e.g. `[note]`) without it being
|
|
272
|
+
// parsed as one. Checked before TAG_PATTERN so the escape always wins; a
|
|
273
|
+
// lone `]` needs no escape, since it is never significant on its own.
|
|
274
|
+
if (input[index + 1] === '[') {
|
|
275
|
+
buffer += '[';
|
|
276
|
+
index += 2;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const match = TAG_PATTERN.exec(input.slice(index));
|
|
280
|
+
if (match) {
|
|
281
|
+
// Defaults satisfy `noUncheckedIndexedAccess` without non-null `!`.
|
|
282
|
+
const [whole, slash = '', name = ''] = match;
|
|
283
|
+
flush();
|
|
284
|
+
tokens.push(slash === '/' ? { kind: 'close', name } : { kind: 'open', name });
|
|
285
|
+
index += whole.length;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// A `[` that does not start a valid tag is ordinary text — malformed input
|
|
290
|
+
// is tolerated rather than throwing, so a stray bracket never breaks a page.
|
|
291
|
+
buffer += input[index];
|
|
292
|
+
index += 1;
|
|
293
|
+
}
|
|
294
|
+
flush();
|
|
295
|
+
return tokens;
|
|
296
|
+
}
|
|
297
|
+
function appendText(into, value) {
|
|
298
|
+
const last = into[into.length - 1];
|
|
299
|
+
if (last && last.kind === 'text') {
|
|
300
|
+
into[into.length - 1] = { kind: 'text', value: last.value + value };
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
into.push({ kind: 'text', value });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Parse a translator-authored string into a tree of {@link SlotNode}s.
|
|
308
|
+
*
|
|
309
|
+
* The parser is total: every input produces a tree, and any malformed
|
|
310
|
+
* construct (a stray `[`, an unclosed tag, a mismatched closing tag) degrades
|
|
311
|
+
* gracefully to literal text rather than throwing. Well-formed
|
|
312
|
+
* `[name]...[/name]` regions — including nested ones — become `slot` nodes. To
|
|
313
|
+
* include a literal `[` (so text such as `[note]` is not read as a tag), double
|
|
314
|
+
* it: `[[` parses to a single `[`.
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* ```ts
|
|
318
|
+
* parseSlots('Hello [b]world[/b]!');
|
|
319
|
+
* // [
|
|
320
|
+
* // { kind: 'text', value: 'Hello ' },
|
|
321
|
+
* // { kind: 'slot', name: 'b', children: [{ kind: 'text', value: 'world' }] },
|
|
322
|
+
* // { kind: 'text', value: '!' },
|
|
323
|
+
* // ]
|
|
324
|
+
*
|
|
325
|
+
* parseSlots('See [[b] for bold');
|
|
326
|
+
* // [{ kind: 'text', value: 'See [b] for bold' }]
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
function parseSlots(input) {
|
|
330
|
+
const root = [];
|
|
331
|
+
const stack = [];
|
|
332
|
+
const current = () => {
|
|
333
|
+
const top = stack.at(-1);
|
|
334
|
+
return top ? top.children : root;
|
|
335
|
+
};
|
|
336
|
+
for (const token of tokenize(input)) {
|
|
337
|
+
if (token.kind === 'text') {
|
|
338
|
+
appendText(current(), token.value);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (token.kind === 'open') {
|
|
342
|
+
stack.push({ name: token.name, children: [] });
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
// Closing tag.
|
|
346
|
+
const top = stack.at(-1);
|
|
347
|
+
if (top && top.name === token.name) {
|
|
348
|
+
stack.pop();
|
|
349
|
+
current().push({ kind: 'slot', name: top.name, children: top.children });
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// No matching open tag: treat the closing tag as literal text.
|
|
353
|
+
appendText(current(), `[/${token.name}]`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Unwind any tags left open at end of input, back into literal text so their
|
|
357
|
+
// contents are still rendered.
|
|
358
|
+
for (let frame = stack.pop(); frame !== undefined; frame = stack.pop()) {
|
|
359
|
+
const parent = current();
|
|
360
|
+
appendText(parent, `[${frame.name}]`);
|
|
361
|
+
for (const child of frame.children) {
|
|
362
|
+
if (child.kind === 'text') {
|
|
363
|
+
appendText(parent, child.value);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
parent.push(child);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return root;
|
|
371
|
+
}
|
|
372
|
+
function collectText$1(nodes) {
|
|
373
|
+
let text = '';
|
|
374
|
+
for (const node of nodes) {
|
|
375
|
+
text += node.kind === 'text' ? node.value : collectText$1(node.children);
|
|
376
|
+
}
|
|
377
|
+
return text;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Flatten a slot string to plain text: slot tags are dropped and their inner
|
|
381
|
+
* text is kept. This is how the `t` pipe and {@link injectTranslate} render a
|
|
382
|
+
* message containing `[name]...[/name]` slots — they return a string, so the
|
|
383
|
+
* markup degrades to text (the `[t]` directive renders the slots into templates
|
|
384
|
+
* instead).
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* ```ts
|
|
388
|
+
* slotsToText('Read the [docs]documentation[/docs] now'); // 'Read the documentation now'
|
|
389
|
+
* slotsToText('Press [[Enter] to send'); // 'Press [Enter] to send'
|
|
390
|
+
* ```
|
|
391
|
+
*/
|
|
392
|
+
function slotsToText(input) {
|
|
393
|
+
return collectText$1(parseSlots(input));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* DI token for an optional {@link MessageFormatter}. Provided by
|
|
398
|
+
* `@ng-linguo/linguo/icu`'s `provideIcu`; absent by default.
|
|
399
|
+
*/
|
|
400
|
+
const MESSAGE_FORMATTER = new InjectionToken('ng-linguo.message-formatter');
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Obtain a {@link TranslateFn} for use in component code — the TypeScript
|
|
404
|
+
* counterpart of the `t` pipe.
|
|
405
|
+
*
|
|
406
|
+
* Call it in an injection context (a field initializer or constructor); the
|
|
407
|
+
* returned `t` reads the store's signals each time it runs, so it stays reactive
|
|
408
|
+
* when used in a template binding or inside a `computed`.
|
|
409
|
+
*
|
|
410
|
+
* @example
|
|
411
|
+
* ```ts
|
|
412
|
+
* export class Greeting {
|
|
413
|
+
* private readonly t = injectTranslate();
|
|
414
|
+
* protected readonly name = signal('Ada');
|
|
415
|
+
* protected readonly text = computed(() =>
|
|
416
|
+
* this.t('Hello {$name}!', { params: { name: this.name() } }),
|
|
417
|
+
* );
|
|
418
|
+
* }
|
|
419
|
+
* ```
|
|
420
|
+
*/
|
|
421
|
+
function injectTranslate() {
|
|
422
|
+
const store = inject(TranslateStore);
|
|
423
|
+
const formatter = inject(MESSAGE_FORMATTER, { optional: true });
|
|
424
|
+
return (key, options) => {
|
|
425
|
+
let message = store.translate(key, options?.context);
|
|
426
|
+
if (options?.params && formatter) {
|
|
427
|
+
message = formatter.format(message, options.params, store.currentLang() || 'en');
|
|
428
|
+
}
|
|
429
|
+
// Returns a string, so slot tags degrade to their inner text; the `[t]`
|
|
430
|
+
// directive renders them into templates instead.
|
|
431
|
+
return slotsToText(message);
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Shallow value-equality for `params`: same keys, each value strictly equal. */
|
|
436
|
+
function paramsEqual(a, b) {
|
|
437
|
+
if (a === b)
|
|
438
|
+
return true;
|
|
439
|
+
if (a === undefined || b === undefined)
|
|
440
|
+
return false;
|
|
441
|
+
const keys = Object.keys(a);
|
|
442
|
+
return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k]);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Resolve a translation key to its string for the current language, with options
|
|
446
|
+
* passed as a single object: ICU `params` and a disambiguating `context`.
|
|
447
|
+
*
|
|
448
|
+
* @example
|
|
449
|
+
* ```html
|
|
450
|
+
* {{ 'Play' | t }}
|
|
451
|
+
* {{ 'Play' | t: { context: 'game' } }}
|
|
452
|
+
* {{ 'Hello {$name}!' | t: { params: { name } } }}
|
|
453
|
+
* ```
|
|
454
|
+
*
|
|
455
|
+
* The key is the source text; a missing key renders as itself. ICU `params` are
|
|
456
|
+
* applied by the formatter from `@ng-linguo/linguo/icu` (`provideIcu`); without
|
|
457
|
+
* it the message is returned unformatted.
|
|
458
|
+
*
|
|
459
|
+
* Impure so it re-evaluates against the store's signals each change detection,
|
|
460
|
+
* re-rendering when the language changes. To keep that cheap it **memoizes** by
|
|
461
|
+
* value: the lookup/format only re-runs when the key, `context`, `params`
|
|
462
|
+
* contents, or the active language actually change — so a fresh `{ params: … }`
|
|
463
|
+
* literal on every change-detection pass costs only an equality check. For hot
|
|
464
|
+
* or looped bindings, prefer `injectTranslate()` inside a `computed()`, which
|
|
465
|
+
* does zero work per change-detection pass.
|
|
466
|
+
*/
|
|
467
|
+
class TranslatePipe {
|
|
468
|
+
t = injectTranslate();
|
|
469
|
+
// The translations signal's value reference changes on every `setLang`, so it
|
|
470
|
+
// doubles as the "language version" for cache invalidation.
|
|
471
|
+
translations = inject(TranslateStore).translations;
|
|
472
|
+
cache;
|
|
473
|
+
transform(key, options) {
|
|
474
|
+
const version = this.translations();
|
|
475
|
+
const context = options?.context;
|
|
476
|
+
const params = options?.params;
|
|
477
|
+
const cached = this.cache;
|
|
478
|
+
if (cached !== undefined &&
|
|
479
|
+
cached.key === key &&
|
|
480
|
+
cached.context === context &&
|
|
481
|
+
cached.version === version &&
|
|
482
|
+
paramsEqual(cached.params, params)) {
|
|
483
|
+
return cached.result;
|
|
484
|
+
}
|
|
485
|
+
const result = this.t(key, options);
|
|
486
|
+
this.cache = { key, context, params, version, result };
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TranslatePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
|
|
490
|
+
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.9", ngImport: i0, type: TranslatePipe, isStandalone: true, name: "t", pure: false });
|
|
491
|
+
}
|
|
492
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TranslatePipe, decorators: [{
|
|
493
|
+
type: Pipe,
|
|
494
|
+
args: [{ name: 't', pure: false }]
|
|
495
|
+
}] });
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Mark a string as a translatable message so `@ng-linguo/extract` collects it,
|
|
499
|
+
* returning the string unchanged (it does not translate at runtime).
|
|
500
|
+
*
|
|
501
|
+
* Use it for messages that are not written inline at a `t` pipe or `[t]`
|
|
502
|
+
* directive — for example an ICU message kept in a component field (an MF2
|
|
503
|
+
* pattern contains `{{ … }}`, which collides with Angular's `{{ }}` binding).
|
|
504
|
+
* The marked string is still translated at render time by the pipe/directive
|
|
505
|
+
* that consumes it.
|
|
506
|
+
*
|
|
507
|
+
* Pass `context` to record a `msgctxt`/translator note for the entry. Because
|
|
508
|
+
* context is part of the key, the **same** `context` must be supplied where the
|
|
509
|
+
* marked string is rendered (`| t: { context }` or `tContext`), or the runtime
|
|
510
|
+
* lookup will not find the contextual entry.
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* ```ts
|
|
514
|
+
* readonly fileCount = mark(
|
|
515
|
+
* '.input {$count :number} .match $count one {{{$count} file}} * {{{$count} files}}',
|
|
516
|
+
* { context: 'file = a document on disk' },
|
|
517
|
+
* );
|
|
518
|
+
* // template: {{ fileCount | t: { params: { count: count() }, context: 'file = a document on disk' } }}
|
|
519
|
+
* ```
|
|
520
|
+
*/
|
|
521
|
+
function mark(message, options) {
|
|
522
|
+
// `options` is read only by the extractor (statically); at runtime it is a
|
|
523
|
+
// no-op, so the parameter is intentionally unused here.
|
|
524
|
+
void options;
|
|
525
|
+
return message;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Flatten a slot subtree to its concatenated text, dropping the tags. */
|
|
529
|
+
function collectText(nodes) {
|
|
530
|
+
let text = '';
|
|
531
|
+
for (const node of nodes) {
|
|
532
|
+
text += node.kind === 'text' ? node.value : collectText(node.children);
|
|
533
|
+
}
|
|
534
|
+
return text;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Renders a parsed slot tree into the DOM as text nodes and embedded
|
|
538
|
+
* `<ng-template>` views — never as HTML (CLAUDE.md §5.1).
|
|
539
|
+
*
|
|
540
|
+
* Owns the views and host-level text nodes it creates so a re-render (a language
|
|
541
|
+
* or params change) can tear them down. A single instance is shared by a `[t]`
|
|
542
|
+
* element and every `*tRender` outlet nested inside it: views are tracked flat
|
|
543
|
+
* and destroyed together, so a re-render is a clean teardown-then-rebuild
|
|
544
|
+
* regardless of nesting depth.
|
|
545
|
+
*/
|
|
546
|
+
class SlotRenderer {
|
|
547
|
+
renderer;
|
|
548
|
+
viewContainer;
|
|
549
|
+
views = [];
|
|
550
|
+
hostNodes = [];
|
|
551
|
+
constructor(renderer, viewContainer) {
|
|
552
|
+
this.renderer = renderer;
|
|
553
|
+
this.viewContainer = viewContainer;
|
|
554
|
+
}
|
|
555
|
+
/** Destroy every view and remove every host-level text node from the last render. */
|
|
556
|
+
clear() {
|
|
557
|
+
for (const view of this.views) {
|
|
558
|
+
view.destroy();
|
|
559
|
+
}
|
|
560
|
+
this.views = [];
|
|
561
|
+
for (const node of this.hostNodes) {
|
|
562
|
+
// Nodes placed inside an embedded view are gone with their view; only the
|
|
563
|
+
// ones appended straight onto the persistent host element remain here.
|
|
564
|
+
if (node.parentNode) {
|
|
565
|
+
this.renderer.removeChild(node.parentNode, node);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
this.hostNodes = [];
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Render `nodes` into `parent`, before `before` (or appended when `before` is
|
|
572
|
+
* `null`). `trackHost` is `true` only for nodes placed directly on the `[t]`
|
|
573
|
+
* host element — those text nodes outlive any single view and must be removed
|
|
574
|
+
* explicitly on the next render; nodes inside an embedded view ride along when
|
|
575
|
+
* that view is destroyed, so they are not tracked.
|
|
576
|
+
*/
|
|
577
|
+
render(nodes, parent, before, templates, trackHost) {
|
|
578
|
+
for (const node of nodes) {
|
|
579
|
+
if (node.kind === 'text') {
|
|
580
|
+
const text = this.renderer.createText(node.value);
|
|
581
|
+
this.insert(parent, text, before);
|
|
582
|
+
if (trackHost) {
|
|
583
|
+
this.hostNodes.push(text);
|
|
584
|
+
}
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const template = templates.get(node.name);
|
|
588
|
+
if (!template) {
|
|
589
|
+
// No matching template: render the slot's children inline (transparent),
|
|
590
|
+
// so a nested *matched* slot still renders and text-only content reads
|
|
591
|
+
// the same as a plain flatten.
|
|
592
|
+
this.render(node.children, parent, before, templates, trackHost);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const view = this.viewContainer.createEmbeddedView(template, {
|
|
596
|
+
$implicit: collectText(node.children),
|
|
597
|
+
children: node.children,
|
|
598
|
+
});
|
|
599
|
+
this.views.push(view);
|
|
600
|
+
// Running the view's bindings instantiates any `*tRender` outlet inside it,
|
|
601
|
+
// which calls back to render this slot's children into that outlet — the
|
|
602
|
+
// nesting recursion happens here, on the call stack.
|
|
603
|
+
view.detectChanges();
|
|
604
|
+
for (const rootNode of view.rootNodes) {
|
|
605
|
+
this.insert(parent, rootNode, before);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
insert(parent, node, before) {
|
|
610
|
+
if (before) {
|
|
611
|
+
this.renderer.insertBefore(parent, node, before);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
this.renderer.appendChild(parent, node);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Provides an `<ng-template>` as the renderer for a named slot of the enclosing
|
|
621
|
+
* `[t]` element. The `[name]...[/name]` bracket syntax resembles BBCode, but the
|
|
622
|
+
* name is arbitrary and author-chosen — it identifies a slot to fill, not a tag
|
|
623
|
+
* with predefined HTML. Declared as a child of the `[t]` element (it renders as
|
|
624
|
+
* an invisible anchor), so the name is scoped to that element. The template
|
|
625
|
+
* receives the slot's inner text (`let-text`) and parsed children
|
|
626
|
+
* (`let-kids="children"`, for nesting via `*tRender`) as its context.
|
|
627
|
+
*
|
|
628
|
+
* @example
|
|
629
|
+
* ```html
|
|
630
|
+
* <ng-template tFor="docs" let-text>
|
|
631
|
+
* <a routerLink="/docs">{{ text }}</a>
|
|
632
|
+
* </ng-template>
|
|
633
|
+
* ```
|
|
634
|
+
*/
|
|
635
|
+
class TranslateSlot {
|
|
636
|
+
/** Slot name, matching `[name]...[/name]` in the translation. */
|
|
637
|
+
name = input.required({ ...(ngDevMode ? { debugName: "name" } : /* istanbul ignore next */ {}), alias: 'tFor' });
|
|
638
|
+
templateRef = inject(TemplateRef);
|
|
639
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TranslateSlot, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
640
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: TranslateSlot, isStandalone: true, selector: "ng-template[tFor]", inputs: { name: { classPropertyName: "name", publicName: "tFor", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
|
|
641
|
+
}
|
|
642
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TranslateSlot, decorators: [{
|
|
643
|
+
type: Directive,
|
|
644
|
+
args: [{ selector: 'ng-template[tFor]' }]
|
|
645
|
+
}], propDecorators: { name: [{ type: i0.Input, args: [{ isSignal: true, alias: "tFor", required: true }] }] } });
|
|
646
|
+
/**
|
|
647
|
+
* Translate the `t` message and render it into the element, applying ICU `tParams`
|
|
648
|
+
* and binding any `[name]...[/name]` slot regions to `<ng-template tFor>`
|
|
649
|
+
* children.
|
|
650
|
+
*
|
|
651
|
+
* The message is the `t` attribute (a string expression), so it may contain both
|
|
652
|
+
* ICU (`{$name}`) and slot tags (`[name]`) safely. Rendered text is emitted as DOM
|
|
653
|
+
* text nodes, never HTML (CLAUDE.md §5.1); a slot with no matching template
|
|
654
|
+
* degrades to its inner text. Nested slots bind to their own templates when the
|
|
655
|
+
* parent template marks where they go with `*tRender` (see
|
|
656
|
+
* `TranslateSlotOutlet`); otherwise a nested slot renders as text. Re-renders
|
|
657
|
+
* when the language, params, or a slot template changes.
|
|
658
|
+
*
|
|
659
|
+
* @example
|
|
660
|
+
* ```html
|
|
661
|
+
* <p t="Hello {$name}!" [tParams]="{ name }"></p>
|
|
662
|
+
*
|
|
663
|
+
* <p t="Read the [docs]documentation[/docs] to get started">
|
|
664
|
+
* <ng-template tFor="docs" let-text>
|
|
665
|
+
* <a routerLink="/docs">{{ text }}</a>
|
|
666
|
+
* </ng-template>
|
|
667
|
+
* </p>
|
|
668
|
+
* ```
|
|
669
|
+
*/
|
|
670
|
+
class TranslateDirective {
|
|
671
|
+
store = inject(TranslateStore);
|
|
672
|
+
host = inject(ElementRef).nativeElement;
|
|
673
|
+
renderer = inject(Renderer2);
|
|
674
|
+
formatter = inject(MESSAGE_FORMATTER, { optional: true });
|
|
675
|
+
slotRenderer = new SlotRenderer(this.renderer, inject(ViewContainerRef));
|
|
676
|
+
/** Source message (the translation key); may contain ICU and slot tags. */
|
|
677
|
+
message = input.required({ ...(ngDevMode ? { debugName: "message" } : /* istanbul ignore next */ {}), alias: 't' });
|
|
678
|
+
/** ICU arguments, formatted via `@ng-linguo/linguo/icu` when provided. */
|
|
679
|
+
tParams = input(undefined, ...(ngDevMode ? [{ debugName: "tParams" }] : /* istanbul ignore next */ []));
|
|
680
|
+
/** Optional disambiguating/contextual text (part of the key). */
|
|
681
|
+
tContext = input('', ...(ngDevMode ? [{ debugName: "tContext" }] : /* istanbul ignore next */ []));
|
|
682
|
+
slots = contentChildren(TranslateSlot, { ...(ngDevMode ? { debugName: "slots" } : /* istanbul ignore next */ {}), descendants: true });
|
|
683
|
+
templates = new Map();
|
|
684
|
+
constructor() {
|
|
685
|
+
effect(() => {
|
|
686
|
+
const templates = new Map();
|
|
687
|
+
for (const slot of this.slots()) {
|
|
688
|
+
templates.set(slot.name(), slot.templateRef);
|
|
689
|
+
}
|
|
690
|
+
this.templates = templates;
|
|
691
|
+
let text = this.store.translate(this.message(), this.tContext());
|
|
692
|
+
const params = this.tParams();
|
|
693
|
+
if (params && this.formatter) {
|
|
694
|
+
text = this.formatter.format(text, params, this.store.currentLang() || 'en');
|
|
695
|
+
}
|
|
696
|
+
this.slotRenderer.clear();
|
|
697
|
+
this.slotRenderer.render(parseSlots(text), this.host, null, templates, true);
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
ngOnInit() {
|
|
701
|
+
// Remove any authored whitespace text so the rendered translation is not
|
|
702
|
+
// preceded by stray spacing. Slot `<ng-template>` anchors (comments) are
|
|
703
|
+
// left intact for the content query.
|
|
704
|
+
for (const node of Array.from(this.host.childNodes)) {
|
|
705
|
+
if (node.nodeType === 3 /* text node */) {
|
|
706
|
+
this.renderer.removeChild(this.host, node);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Render a nested slot's `nodes` into `parent` before `before`, reusing the
|
|
712
|
+
* current templates and the shared renderer. Internal: called by
|
|
713
|
+
* `TranslateSlotOutlet` (`*tRender`); not part of the consumer-facing API.
|
|
714
|
+
*/
|
|
715
|
+
renderChildren(parent, before, nodes) {
|
|
716
|
+
this.slotRenderer.render(nodes, parent, before, this.templates, false);
|
|
717
|
+
}
|
|
718
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TranslateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
719
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "21.2.9", type: TranslateDirective, isStandalone: true, selector: "[t]", inputs: { message: { classPropertyName: "message", publicName: "t", isSignal: true, isRequired: true, transformFunction: null }, tParams: { classPropertyName: "tParams", publicName: "tParams", isSignal: true, isRequired: false, transformFunction: null }, tContext: { classPropertyName: "tContext", publicName: "tContext", isSignal: true, isRequired: false, transformFunction: null } }, queries: [{ propertyName: "slots", predicate: TranslateSlot, descendants: true, isSignal: true }], ngImport: i0 });
|
|
720
|
+
}
|
|
721
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TranslateDirective, decorators: [{
|
|
722
|
+
type: Directive,
|
|
723
|
+
args: [{ selector: '[t]' }]
|
|
724
|
+
}], ctorParameters: () => [], propDecorators: { message: [{ type: i0.Input, args: [{ isSignal: true, alias: "t", required: true }] }], tParams: [{ type: i0.Input, args: [{ isSignal: true, alias: "tParams", required: false }] }], tContext: [{ type: i0.Input, args: [{ isSignal: true, alias: "tContext", required: false }] }], slots: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => TranslateSlot), { ...{ descendants: true }, isSignal: true }] }] } });
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Renders the *children* of a slot at this position, so a nested slot binds to
|
|
728
|
+
* its own `<ng-template>` instead of flattening to text. Without it, a slot
|
|
729
|
+
* inside a templated slot renders as plain text (its `$implicit` value); with
|
|
730
|
+
* it, each nested slot finds its own `tFor` template.
|
|
731
|
+
*
|
|
732
|
+
* Bind the `children` from the enclosing slot's context and place the outlet
|
|
733
|
+
* where the nested content should appear:
|
|
734
|
+
*
|
|
735
|
+
* @example
|
|
736
|
+
* ```html
|
|
737
|
+
* <p t="Agree to our [link]Terms and [b]Conditions[/b][/link]">
|
|
738
|
+
* <ng-template tFor="link" let-kids="children">
|
|
739
|
+
* <a href="/terms"><ng-container *tRender="kids" /></a>
|
|
740
|
+
* </ng-template>
|
|
741
|
+
* <ng-template tFor="b" let-text><strong>{{ text }}</strong></ng-template>
|
|
742
|
+
* </p>
|
|
743
|
+
* ```
|
|
744
|
+
*
|
|
745
|
+
* Valid only inside a `[t]` element's slot template — it resolves the enclosing
|
|
746
|
+
* {@link TranslateDirective} and renders through its shared engine, so nested
|
|
747
|
+
* views are torn down with the rest on a language or params change.
|
|
748
|
+
*/
|
|
749
|
+
class TranslateSlotOutlet {
|
|
750
|
+
/** The nodes to render — the `children` value from the slot context. */
|
|
751
|
+
nodes = input.required({ ...(ngDevMode ? { debugName: "nodes" } : /* istanbul ignore next */ {}), alias: 'tRender' });
|
|
752
|
+
host = inject(TranslateDirective);
|
|
753
|
+
// The outlet sits on an `<ng-container>`, whose anchor is a comment node; its
|
|
754
|
+
// parent is the element the children belong in, and we render before it.
|
|
755
|
+
anchor = inject(ViewContainerRef).element.nativeElement;
|
|
756
|
+
ngOnInit() {
|
|
757
|
+
const parent = this.anchor.parentNode;
|
|
758
|
+
if (parent) {
|
|
759
|
+
this.host.renderChildren(parent, this.anchor, this.nodes());
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TranslateSlotOutlet, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
763
|
+
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: TranslateSlotOutlet, isStandalone: true, selector: "[tRender]", inputs: { nodes: { classPropertyName: "nodes", publicName: "tRender", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
|
|
764
|
+
}
|
|
765
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: TranslateSlotOutlet, decorators: [{
|
|
766
|
+
type: Directive,
|
|
767
|
+
args: [{ selector: '[tRender]' }]
|
|
768
|
+
}], propDecorators: { nodes: [{ type: i0.Input, args: [{ isSignal: true, alias: "tRender", required: true }] }] } });
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Generated bundle index. Do not edit.
|
|
772
|
+
*/
|
|
773
|
+
|
|
774
|
+
export { MESSAGE_FORMATTER, TranslateDirective, TranslatePipe, TranslateSlot, TranslateSlotOutlet, TranslateStore, injectTranslate, mark, parseSlots, provideTranslate, slotsToText };
|
|
775
|
+
//# sourceMappingURL=ng-linguo-linguo.mjs.map
|