@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.
@@ -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