UNPKG

@ngx-translate/core

Version:

Translation library (i18n) for Angular

1,223 lines (1,212 loc) 65.9 kB
import * as i0 from '@angular/core'; import { signal, computed, Injectable, inject, DestroyRef, InjectionToken, untracked, TemplateRef, ViewContainerRef, Directive, ElementRef, ChangeDetectorRef, Injector, effect, Input, Pipe } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { of, Subject, isObservable, merge, EMPTY, tap, finalize, forkJoin, concat, defer } from 'rxjs'; import { filter, map, take, shareReplay, concatMap, switchMap } from 'rxjs/operators'; function _(key) { return key; } function omit(map, key) { const next = {}; for (const k of Object.keys(map)) { if (k !== key) next[k] = map[k]; } return next; } /** * In-flight load registry — one entry per language currently being loaded. * Backed by a signal so `isLoading` can derive reactively from its contents. * * Invariants: * - Entries added by `loadAndCompileTranslations` before subscribe. * - Entries removed by `loadAndCompileTranslations`'s `tap` on the success * path (synchronously between store update and subscriber notification, so * subscribers observe this language gone from the registry — and * `isLoading()` flips false if no other load is in flight) and by the * `shareReplay`'d Observable's `finalize` for error / sync-throw / * last-subscriber-unsubscribe paths. The success-path finalize is a safe * no-op (idempotent `clearIfOwner`). * - Same-language back-to-back loads dedup via the existing `shareReplay`; the * registry stays at one entry per language regardless of subscriber count. * `isLoading` therefore toggles `0 -> 1 -> 0` once per language load, not * once per subscriber. Set-based, not count-based — matches user intent * ("a language is loading" not "N callers asked"). * - `clearIfOwner(lang, token)` only removes the entry if it still matches the * original Observable token captured at `set()` time. Prevents stale-finalize * races where an old load's `finalize` would clobber a newer load's entry * (e.g. `resetLang` followed by `reloadLang` while the old load is still * mid-flight). * - `clear(lang)` is unconditional — used by `resetLang` to forcibly drop * ownership regardless of which load owns the entry. * * Internal to the package — not re-exported from `public-api.ts`. */ class LoadingTranslationsRegistry { state = signal({}, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ [])); /** Reactive — `true` while at least one load is in flight. */ hasAny = computed(() => Object.keys(this.state()).length > 0, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "hasAny" }] : /* istanbul ignore next */ [])); /** `true` while THIS language is being loaded on this instance. */ isLoading(lang) { return this.state()[lang] !== undefined; } get(lang) { return this.state()[lang]; } set(lang, obs) { this.state.update((m) => ({ ...m, [lang]: obs })); } /** Unconditional clear. Used by `resetLang` to forcibly drop the entry. */ clear(lang) { this.state.update((m) => omit(m, lang)); } /** * Token-aware clear. Used by `loadAndCompileTranslations`'s `finalize` so * an old load's `finalize` cannot clobber a newer load's entry. */ clearIfOwner(lang, token) { this.state.update((m) => (m[lang] === token ? omit(m, lang) : m)); } } class MissingTranslationHandler { } /** * This handler is just a placeholder that does nothing; in case you don't need a missing translation handler at all */ class DefaultMissingTranslationHandler { handle(params) { return params.key; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: DefaultMissingTranslationHandler, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: DefaultMissingTranslationHandler }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: DefaultMissingTranslationHandler, decorators: [{ type: Injectable }] }); class TranslateCompiler { } /** * This compiler is just a placeholder that does nothing; in case you don't need a compiler at all */ class TranslateNoOpCompiler extends TranslateCompiler { compile(value, lang) { void lang; return value; } compileTranslations(translations, lang) { void lang; return translations; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateNoOpCompiler, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateNoOpCompiler }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateNoOpCompiler, decorators: [{ type: Injectable }] }); class TranslateLoader { } /** * This loader is just a placeholder that does nothing; in case you don't need a loader at all */ class TranslateNoOpLoader extends TranslateLoader { getTranslation(lang) { void lang; return of({}); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateNoOpLoader, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateNoOpLoader }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateNoOpLoader, decorators: [{ type: Injectable }] }); /** * Determines if two objects or two values are equivalent. * * Two objects or values are considered equivalent if at least one of the following is true: * * * Both objects or values pass `===` comparison. * * Both objects or values are of the same type and all of their properties are equal by * comparing them with `equals`. * * @param o1 Object or value to compare. * @param o2 Object or value to compare. * @returns true if arguments are equal. */ function equals(o1, o2) { if (o1 === o2) return true; if (o1 === null || o2 === null) return false; if (o1 !== o1 && o2 !== o2) return true; // NaN === NaN const t1 = typeof o1, t2 = typeof o2; let length; if (t1 == t2 && t1 == "object") { if (Array.isArray(o1)) { if (!Array.isArray(o2)) return false; if ((length = o1.length) == o2.length) { for (let key = 0; key < length; key++) { if (!equals(o1[key], o2[key])) return false; } return true; } } else { if (Array.isArray(o2)) { return false; } if (isDict(o1) && isDict(o2)) { const keySet = Object.create(null); for (const key in o1) { if (!equals(o1[key], o2[key])) { return false; } keySet[key] = true; } for (const key in o2) { if (!(key in keySet) && typeof o2[key] !== "undefined") { return false; } } return true; } } } return false; } function isDefinedAndNotNull(value) { return typeof value !== "undefined" && value !== null; } function isDefined(value) { return value !== undefined; } function isDict(value) { return isObject(value) && !isArray(value) && value !== null; } function isObject(value) { return typeof value === "object" && value !== null; } function isArray(value) { return Array.isArray(value); } function isString(value) { return typeof value === "string"; } function isFunction(value) { return typeof value === "function"; } function cloneDeep(value) { if (isArray(value)) { return value.map((item) => cloneDeep(item)); } else if (isDict(value)) { const cloned = {}; Object.keys(value).forEach((key) => { cloned[key] = cloneDeep(value[key]); }); return cloned; } else { return value; } } /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ function mergeDeep(target, source) { if (!isObject(target)) { return cloneDeep(source); } const output = cloneDeep(target); if (isObject(output) && isObject(source)) { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ Object.keys(source).forEach((key) => { if (isDict(source[key])) { if (key in target) { output[key] = mergeDeep(target[key], source[key]); } else { Object.assign(output, { [key]: source[key] }); } } else { Object.assign(output, { [key]: source[key] }); } }); } return output; } /** * Retrieves a value from a nested object using a dot-separated key path. * * Example usage: * ```ts * getValue({ key1: { keyA: 'valueI' }}, 'key1.keyA'); // returns 'valueI' * ``` * * @param target The source object from which to retrieve the value. * @param key Dot-separated key path specifying the value to retrieve. * @returns The value at the specified key path, or `undefined` if not found. */ function getValue(target, key) { const keys = key.split("."); key = ""; do { key += keys.shift(); const isLastKey = !keys.length; if (isDefinedAndNotNull(target)) { if (isDict(target) && isDefined(target[key]) && (isDict(target[key]) || isArray(target[key]) || isLastKey)) { target = target[key]; key = ""; continue; } if (isArray(target)) { if (key === "length" && isLastKey) { target = target.length; key = ""; continue; } if (/^\d+$/.test(key)) { const index = parseInt(key, 10); if (isDefined(target[index]) && (isDict(target[index]) || isArray(target[index]) || isLastKey)) { target = target[index]; key = ""; continue; } } } } if (isLastKey) { target = undefined; continue; } key += "."; } while (keys.length); return target; } /** * Sets a value on object using a dot separated key. * Returns a clone of the object without modifying it * insertValue({a:{b:{c: "test"}}}, 'a.b.c', "test2") ==> {a:{b:{c: "test2"}}} * @param target an object * @param key E.g. "a.b.c" * @param value to set */ function insertValue(target, key, value) { return mergeDeep(target, createNestedObject(key, value)); } function createNestedObject(dotSeparatedKey, value) { return dotSeparatedKey.split(".").reduceRight((acc, key) => ({ [key]: acc }), value); } class TranslateParser { } class TranslateDefaultParser extends TranslateParser { templateMatcher = /{{\s?([^{}\s]*)\s?}}/g; interpolate(expr, params) { if (isString(expr)) { return this.interpolateString(expr, params); } else if (isFunction(expr)) { return this.interpolateFunction(expr, params); } return undefined; } interpolateFunction(fn, params) { return fn(params); } interpolateString(expr, params) { if (!params) { return expr; } return expr.replace(this.templateMatcher, (substring, key) => { const replacement = this.getInterpolationReplacement(params, key); return replacement !== undefined ? replacement : substring; }); } /** * Returns the replacement for an interpolation parameter * @params: */ getInterpolationReplacement(params, key) { return this.formatValue(getValue(params, key)); } /** * Converts a value into a useful string representation. * @param value The value to format. * @returns A string representation of the value. */ formatValue(value) { if (isString(value)) { return value; } if (typeof value === "number" || typeof value === "boolean") { return value.toString(); } if (value === null) { return "null"; } if (isArray(value)) { return value.join(", "); } if (isObject(value)) { if (typeof value.toString === "function" && value.toString !== Object.prototype.toString) { return value.toString(); } return JSON.stringify(value); // Pretty-print JSON if no meaningful toString() } return undefined; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateDefaultParser, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateDefaultParser }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateDefaultParser, decorators: [{ type: Injectable }] }); class TranslateStore { _translations = signal({}, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "_translations" }] : /* istanbul ignore next */ [])); translations = this._translations.asReadonly(); _languages = signal([], /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "_languages" }] : /* istanbul ignore next */ [])); languages = this._languages.asReadonly(); _lastTranslationChange = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "_lastTranslationChange" }] : /* istanbul ignore next */ [])); lastTranslationChange = this._lastTranslationChange.asReadonly(); _translationChange$ = new Subject(); translationChange$ = this._translationChange$.asObservable(); constructor() { // Complete the Subject when the owning injector tears down. Without // this, child-service stores on lazy routes leak `translationChange$` // subscribers across navigations. inject(DestroyRef).onDestroy(() => { this._translationChange$.complete(); }); } getTranslations(language) { return this.translations()[language]; } setTranslations(language, translations, extend) { this._translations.update((current) => ({ ...current, [language]: extend && this.hasTranslationFor(language) ? mergeDeep(current[language], translations) : translations, })); this.addLanguages([language]); const event = { lang: language, translations: this.getTranslations(language), }; this._lastTranslationChange.set(event); this._translationChange$.next(event); } getLanguages() { return this.languages(); } addLanguages(langs) { this._languages.update((current) => Array.from(new Set([...current, ...langs]))); } hasTranslationFor(lang) { return typeof this.translations()[lang] !== "undefined"; } deleteTranslations(lang) { this._translations.update((current) => { const { [lang]: _, ...rest } = current; return rest; }); } getTranslationValue(language, key) { return getValue(this.getTranslations(language), key); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateStore }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateStore, decorators: [{ type: Injectable }], ctorParameters: () => [] }); const TRANSLATE_SERVICE_CONFIG = new InjectionToken("TRANSLATE_CONFIG"); const makeObservable = (value) => { return isObservable(value) ? value : of(value); }; class TranslateService { loadingTranslations = new LoadingTranslationsRegistry(); lastUseLanguage = null; currentLoader = inject(TranslateLoader); compiler = inject(TranslateCompiler); parser = inject(TranslateParser); missingTranslationHandler = inject(MissingTranslationHandler); store = inject(TranslateStore); destroyRef = inject(DestroyRef); parent; get isRoot() { return this.parent === null; } _onLangChange = new Subject(); _onFallbackLangChange = new Subject(); _currentLang = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "_currentLang" }] : /* istanbul ignore next */ [])); _fallbackLang = signal(null, /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "_fallbackLang" }] : /* istanbul ignore next */ [])); _onTranslationRefresh = null; // Downward-inheritance: `true` if THIS service has loads in flight, OR any // ancestor does. Walks the parent chain via the public `parent.isLoading()` // getter (not by reaching into `parent.loadingTranslations`) so the // encapsulation boundary holds. Angular signals re-collect dependencies on // each evaluation; short-circuit is safe — the only signal whose flip // could change the result is the one returned by short-circuit, and it IS // tracked. Parent chain is acyclic (DI tree is acyclic; `skipSelf` only // walks up), so no cycle guard needed. // // Set-based composition at each level: `loadingTranslations.hasAny()` // stays true while ANY language has an in-flight load on this service, // flips false only when the last entry is cleared. _isLoading = computed(() => this.loadingTranslations.hasAny() || (this.parent?.isLoading() ?? false), /* @ts-ignore */ ...(ngDevMode ? [{ debugName: "_isLoading" }] : /* istanbul ignore next */ [])); /** * Returns the root of this service's hierarchy — the topmost service in * the `getParent()` chain. For an isolated subtree, returns the subtree's * root (since `parent === null` at the isolation boundary). * * A root service returns itself. Equivalent to walking `getParent()` until * it returns `null`, but provided as a convenience. */ getRoot() { // eslint-disable-next-line @typescript-eslint/no-this-alias let svc = this; while (svc.parent) svc = svc.parent; return svc; } /** * Returns the service this one inherits translations from, or `null` if * this is a root (a top-level service or an isolated subtree root). * * A `null` return means the service is the terminus of its translation * fallback chain — equivalent to "is this a root?". */ getParent() { return this.parent; } /** * The language most recently requested via `use()`. Always read from the * root, because `use()` delegates to the root (`parent!.use(lang)`) and only * the root ever assigns `lastUseLanguage`. A child's own `lastUseLanguage` * stays `null`, so reading it directly would make `get()` miss the child's * in-flight load. `null` until the first `use()` runs. */ getActiveRequestedLang() { return this.getRoot().lastUseLanguage; } hasTranslationInChain(lang) { // eslint-disable-next-line @typescript-eslint/no-this-alias for (let svc = this; svc; svc = svc.parent) { if (svc.store.hasTranslationFor(lang)) return true; } return false; } chainTranslationChange$() { const streams = []; // eslint-disable-next-line @typescript-eslint/no-this-alias for (let svc = this; svc; svc = svc.parent) { streams.push(svc.store.translationChange$); } return streams.length === 1 ? streams[0] : merge(...streams); } /** * An Observable to listen to translation change events * onTranslationChange.subscribe((params: TranslationChangeEvent) => { * // do something * }); */ get onTranslationChange() { return this.store.translationChange$; } /** * An Observable to listen to lang change events * onLangChange.subscribe((params: LangChangeEvent) => { * // do something * }); */ get onLangChange() { if (this.isRoot) { return this._onLangChange.asObservable(); } return this.parent ? this.parent.onLangChange : EMPTY; } /** * An Observable to listen to fallback lang change events * onFallbackLangChange.subscribe((params: FallbackLangChangeEvent) => { * // do something * }); */ get onFallbackLangChange() { if (this.isRoot) { return this._onFallbackLangChange.asObservable(); } return this.parent ? this.parent.onFallbackLangChange : EMPTY; } /** * A combined Observable that emits whenever translations might need to be refreshed. * This includes: language changes, translation updates for the current or fallback language, * and fallback language changes. */ get onTranslationRefresh() { if (!this._onTranslationRefresh) { const refresh$ = merge(this.onTranslationChange.pipe(filter((event) => event.lang === this.getCurrentLang() || event.lang === this.getFallbackLang())), this.onLangChange, this.onFallbackLangChange).pipe(map(() => void 0)); if (this.isRoot) { this._onTranslationRefresh = refresh$; } else { this._onTranslationRefresh = this.parent ? merge(refresh$, this.parent.onTranslationRefresh) : refresh$; } } return this._onTranslationRefresh; } constructor() { const config = { isRoot: true, fallbackLang: null, ...inject(TRANSLATE_SERVICE_CONFIG, { optional: true, }), }; // parent === null exactly means "I am a root" (including isolated-subtree roots). // isRoot is now a getter derived from this single source of truth. this.parent = config.isRoot ? null : inject(TranslateService, { optional: true, skipSelf: true }); const destroyRef = inject(DestroyRef); if (this.isRoot) { if (config.lang) { this.use(config.lang); } if (config.fallbackLang) { this.setFallbackLang(config.fallbackLang); } } else { // Child services should initially load the root's current and fallback languages. // Loader failures are warned contextually here — the internal no-op subscribe // inside loadAndCompileTranslations would otherwise swallow them silently. // Best-effort: takeUntilDestroyed tears down the subscription on host destroy, // so a destroy-then-error race silently drops the warn. const currentLang = this.getCurrentLang(); if (currentLang) { this.loadOrExtendLanguage(currentLang) ?.pipe(takeUntilDestroyed(destroyRef)) .subscribe({ error: (err) => { console.warn(`@ngx-translate/core: child failed to load "${currentLang}". Cause:`, err); }, }); } const fallbackLang = this.getFallbackLang(); // Dedup guard: currentLang === fallbackLang means both branches would // resolve to the same in-flight observable (via the loading-translations // registry's get-or-create) and double-log on error. if (fallbackLang && fallbackLang !== currentLang) { this.loadOrExtendLanguage(fallbackLang) ?.pipe(takeUntilDestroyed(destroyRef)) .subscribe({ error: (err) => { console.warn(`@ngx-translate/core: child failed to load "${fallbackLang}". Cause:`, err); }, }); } } // Child services should load translations when the language changes on the root this.onLangChange.pipe(takeUntilDestroyed(destroyRef)).subscribe((event) => { if (!this.isRoot) { this.loadOrExtendLanguage(event.lang) ?.pipe(takeUntilDestroyed(destroyRef)) .subscribe({ error: (err) => { console.warn(`@ngx-translate/core: child failed to load "${event.lang}". Cause:`, err); }, }); } }); this.onFallbackLangChange.pipe(takeUntilDestroyed(destroyRef)).subscribe((event) => { if (!this.isRoot) { this.loadOrExtendLanguage(event.lang) ?.pipe(takeUntilDestroyed(destroyRef)) .subscribe({ error: (err) => { console.warn(`@ngx-translate/core: child failed to load "${event.lang}". Cause:`, err); }, }); } }); // Complete this service's Subjects when its injector tears down. // Root singletons live as long as the app, but child services on // lazy routes would otherwise pin their Subjects until GC. destroyRef.onDestroy(() => { this._onLangChange.complete(); this._onFallbackLangChange.complete(); }); } /** * Sets the fallback language to use if a translation is not found in the * current language */ setFallbackLang(lang) { if (!this.isRoot) { return this.parent.setFallbackLang(lang); } if (!this._fallbackLang()) { // on init set the fallbackLang immediately, but do not emit a change yet this._fallbackLang.set(lang); } const pending = this.loadOrExtendLanguage(lang); if (isObservable(pending)) { pending.pipe(take(1)).subscribe({ next: () => { this._fallbackLang.set(lang); this._onFallbackLangChange.next({ lang: lang, translations: this.store.getTranslations(lang), }); }, error: (err) => { console.warn(`@ngx-translate/core: failed to load fallback "${lang}". Cause:`, err); }, }); return pending; } this._fallbackLang.set(lang); this._onFallbackLangChange.next({ lang: lang, translations: this.store.getTranslations(lang), }); return of(this.store.getTranslations(lang)); } /** * Signal that is `true` while one or more language loads are in flight at * this service or any of its ancestors in the service hierarchy. * * Loading scope propagates DOWNWARD: a load triggered at the root marks * the root and all descendants as loading. A load triggered at a child * (e.g. a lazy-route bootstrap fetching its translations) marks only that * child's subtree. Siblings and ancestors are unaffected by a descendant's * loads. * * Drive a spinner by reading it from the service injected at the scope * where the spinner should live: root for an app-shell spinner, the * nearest child for a local spinner inside a lazy-loaded subtree. */ get isLoading() { return this._isLoading; } /** * Changes the lang currently used */ use(lang) { if (!this.isRoot) { return this.parent.use(lang); } // Snapshot prior state so we can roll back if the loader fails. const prevLang = this._currentLang(); const prevLastUseLang = this.lastUseLanguage; // Remember the language that was called — used by changeLang() to discard // late-arriving completions from superseded calls. this.lastUseLanguage = lang; if (!this._currentLang()) { // on init set the currentLang immediately, but do not emit a change yet this._currentLang.set(lang); } const pending = this.loadOrExtendLanguage(lang); if (!isObservable(pending)) { // Defensive: loadOrExtendLanguage is typed `Observable | undefined`. // The undefined branch means "nothing to load" — synchronously activate. this.changeLang(lang); return of(this.store.getTranslations(lang)); } pending.pipe(take(1)).subscribe({ next: () => { this.changeLang(lang); }, error: (err) => { // Only roll back if THIS call is still the most-recent one. // A later use() may have superseded it (symmetric with the // changeLang() guard). if (this.lastUseLanguage === lang) { this._currentLang.set(prevLang); this.lastUseLanguage = prevLastUseLang; } console.warn(`@ngx-translate/core: failed to load "${lang}". ` + `currentLang was NOT changed; remains ` + `"${prevLang ?? "null"}". Cause:`, err); }, }); return pending; } /** * Retrieves the given translations */ loadOrExtendLanguage(lang) { // if this language is unavailable, ask for it if (!this.store.hasTranslationFor(lang)) { return this.loadAndCompileTranslations(lang); } return of(this.store.getTranslations(lang)); } /** * @returns The loaded translations for the given language */ getTranslations(language) { return this.store.getTranslations(language); } /** * Changes the current lang */ changeLang(lang) { if (lang !== this.lastUseLanguage) { // received new language data, // but this was not the one requested last return; } this._currentLang.set(lang); this._onLangChange.next({ lang: lang, translations: this.store.getTranslations(lang) }); } getCurrentLang() { return this.isRoot ? this._currentLang() : (this.parent?.getCurrentLang() ?? null); } /** * Loads translations for `lang` via the configured `TranslateLoader`, * compiles them, and stores the result. Tracking via the protected * `loadingTranslations` registry happens automatically. * * Subclasses that override this method bypass `isLoading` tracking * unless they call `this.loadingTranslations.set(lang, obs)` and arrange * a token-aware finalize (`this.loadingTranslations.clearIfOwner(lang, obs)`) * from the override. */ loadAndCompileTranslations(lang) { const existing = this.loadingTranslations.get(lang); if (existing) { return existing; } const translations$ = this.currentLoader.getTranslation(lang).pipe(map((res) => this.compiler.compileTranslations(res, lang)), tap((compiled) => { this.store.setTranslations(lang, compiled, false); // Clear synchronously on success — this `tap` runs before // `shareReplay` forwards `next` to subscribers, so subscribers // receiving `next` observe `isLoading()` reflecting that this // language is no longer in flight. The finalize below covers // error / sync-throw / unsubscribe paths; clearIfOwner is // idempotent so the double-clear on success is a safe no-op. // Invariant: `tap` MUST stay before `shareReplay` in the pipe. this.loadingTranslations.clearIfOwner(lang, translations$); }), // Token-aware clear: if `resetLang` + `reloadLang` raced between // set() and finalize(), this load's finalize must NOT clobber the // newer load's entry. clearIfOwner compares by reference identity. finalize(() => this.loadingTranslations.clearIfOwner(lang, translations$)), // cache the single result & share it across all subscribers shareReplay({ bufferSize: 1, refCount: true })); this.loadingTranslations.set(lang, translations$); // Trigger loading if nobody subscribes from outside. The error callback // is intentionally a no-op: use() and setFallbackLang() already emit a // console.warn on loader failure for their own paths. Warning here // would double-log for those callers. Bound to the service lifetime so a // non-completing/hot custom loader cannot pin this subscription (and the // upstream loader subscription) past service teardown. translations$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ // eslint-disable-next-line @typescript-eslint/no-empty-function error: () => { }, }); return translations$; } /** * Manually sets an object of translations for a given language, * passing it through the configured {@link TranslateCompiler} first. * * If you already have translations in their final compiled form * (e.g. interpolation functions produced at build time), use * {@link setCompiledTranslation} instead — it stores the data * directly and skips the compiler. */ setTranslation(lang, translations, shouldMerge = false) { const interpolatableTranslations = this.compiler.compileTranslations(translations, lang); this.store.setTranslations(lang, interpolatableTranslations, shouldMerge); } /** * Stores an already-compiled translation object for the given language, * bypassing the configured {@link TranslateCompiler}. * * Use this when you have translations in their final, interpolator-ready * form — e.g. interpolation functions produced at build time. For raw * translations that still need to go through the compiler, use * {@link setTranslation} instead. */ setCompiledTranslation(lang, translations, shouldMerge = false) { this.store.setTranslations(lang, translations, shouldMerge); } getLangs() { return this.store.getLanguages(); } /** * Add available languages */ addLangs(languages) { this.store.addLanguages(languages); } getParsedResultForKey(key, interpolateParams, lang) { const textToInterpolate = this.getTextToInterpolate(key, lang); if (isDefinedAndNotNull(textToInterpolate)) { return this.runInterpolation(textToInterpolate, interpolateParams); } const handler = this.getMissingTranslationHandler(); const res = handler.handle({ key, translateService: this, ...(interpolateParams !== undefined && { interpolateParams }), }); return res !== undefined ? res : key; } getMissingTranslationHandler() { return this.missingTranslationHandler; } /** * Gets the fallback language. null if none is defined */ getFallbackLang() { return this.isRoot ? this._fallbackLang() : (this.parent?.getFallbackLang() ?? null); } getTextToInterpolate(key, lang) { if (lang) { const res = this.store.getTranslationValue(lang, key); if (res !== undefined) { return res; } return this.parent?.getTextToInterpolate(key, lang); } const currentLang = this.getCurrentLang(); const fallbackLang = this.getFallbackLang(); // 1. Try own store (currentLang) let res; if (currentLang) { res = this.store.getTranslationValue(currentLang, key); } // 2. Try own store (fallbackLang) - null values also trigger fallback if (!isDefinedAndNotNull(res) && fallbackLang && fallbackLang !== currentLang) { res = this.store.getTranslationValue(fallbackLang, key); } if (res !== undefined) { return res; } // 3. Try parent return this.parent?.getTextToInterpolate(key); } runInterpolation(translations, interpolateParams) { if (!isDefinedAndNotNull(translations)) { return; } if (isArray(translations)) { return this.runInterpolationOnArray(translations, interpolateParams); } if (isDict(translations)) { return this.runInterpolationOnDict(translations, interpolateParams); } return this.parser.interpolate(translations, interpolateParams); } runInterpolationOnArray(translations, interpolateParams) { return translations.map((translation) => this.runInterpolation(translation, interpolateParams)); } runInterpolationOnDict(translations, interpolateParams) { const result = {}; for (const key in translations) { const res = this.runInterpolation(translations[key], interpolateParams); if (res !== undefined) { result[key] = res; } } return result; } /** * Returns the parsed result of the translations */ getParsedResult(key, interpolateParams, lang) { return key instanceof Array ? this.getParsedResultForArray(key, interpolateParams, lang) : this.getParsedResultForKey(key, interpolateParams, lang); } getParsedResultForArray(key, interpolateParams, lang) { const result = {}; let observables = false; for (const k of key) { result[k] = this.getParsedResultForKey(k, interpolateParams, lang); observables = observables || isObservable(result[k]); } if (!observables) { return result; } const sources = key.map((k) => makeObservable(result[k])); return forkJoin(sources).pipe(map((arr) => { const obj = {}; arr.forEach((value, index) => { obj[key[index]] = value; }); return obj; })); } /** * Gets the translated value of a key (or an array of keys) * @returns the translated key, or an object of translated keys */ get(key, interpolateParams, lang) { if (!isDefinedAndNotNull(key) || !key.length) { return of(""); } // check if we are loading a new translation to use. // Use the ROOT's last-requested language (not this service's own // lastUseLanguage, which a child never sets) so a child's in-flight // load is found in its registry; fall back to the current language when // no use() has run yet. const effectiveLang = lang ?? this.getActiveRequestedLang() ?? this.getCurrentLang(); const pending = effectiveLang ? this.loadingTranslations.get(effectiveLang) : undefined; if (pending) { return pending.pipe(concatMap(() => { return makeObservable(this.getParsedResult(key, interpolateParams, lang)); })); } return makeObservable(this.getParsedResult(key, interpolateParams, lang)); } /** * Returns a stream of translated values of a key (or an array of keys) which updates * whenever the translation changes. * @returns A stream of the translated key, or an object of translated keys */ getStreamOnTranslationChange(key, interpolateParams, lang) { if (!isDefinedAndNotNull(key) || !key.length) { throw new Error(`Parameter "key" is required and cannot be empty`); } return concat(defer(() => this.get(key, interpolateParams, lang)), this.onTranslationChange.pipe(switchMap(() => { const res = this.getParsedResult(key, interpolateParams, lang); return makeObservable(res); }))); } /** * Returns a stream of translated values of a key (or an array of keys) which updates * whenever the language changes, the requested language's translations are * (re)loaded, or the explicitly-requested `lang` argument's translations change. * * Without `lang`: re-emits on `onLangChange` (active-language switches via * {@link use}). With `lang`: also re-emits when translations for that specific * language are loaded or updated via `store.translationChange$`, so an * explicit `stream("KEY", undefined, "de")` updates once "de" finishes * loading. * * @returns A stream of the translated key, or an object of translated keys */ stream(key, interpolateParams, lang) { if (!isDefinedAndNotNull(key) || !key.length) { throw new Error(`Parameter "key" required`); } const reemit$ = lang ? merge(this.onLangChange, this.chainTranslationChange$().pipe(filter((e) => e.lang === lang))) : this.onLangChange; return concat(defer(() => this.get(key, interpolateParams, lang)), reemit$.pipe(switchMap(() => { const res = this.getParsedResult(key, interpolateParams, lang); return makeObservable(res); }))); } /** * Returns a translation instantly from the internal state of loaded translation. * All rules regarding the current language, the preferred language of even fallback languages * will be used except any promise handling. * * When `lang` is provided, the lookup goes directly to the specified language, * bypassing the current language and fallback chain. */ instant(key, interpolateParams, lang) { if (!isDefinedAndNotNull(key) || key.length === 0) { return ""; } if (lang && !this.hasTranslationInChain(lang)) { this.warnUnloadedInstantLang(lang); } const result = this.getParsedResult(key, interpolateParams, lang); return isObservable(result) ? this.keyToObject(key) : result; } warnedUnloadedInstantLangs = new Set(); warnUnloadedInstantLang(lang) { // Delegate to the root so the warn budget is shared across the isolated // subtree. With parent === null at isolated boundaries, getRoot() stops // at the right place — one warn per (isolated subtree, lang) pair. const root = this.getRoot(); if (root !== this) { root.warnUnloadedInstantLang(lang); return; } if (this.warnedUnloadedInstantLangs.has(lang)) return; // instant() runs inside the translate() computed; keep the Set write and // console.warn out of the reactive graph so they never register as a // producer during signal evaluation. untracked(() => { this.warnedUnloadedInstantLangs.add(lang); console.warn(`@ngx-translate/core: instant() called with lang="${lang}" but no ` + `translations are loaded for that language. Returning the key as ` + `fallback. Load with use("${lang}") or setTranslation("${lang}", ...) first.`); }); } /** * Returns a Signal that provides the translated value and automatically * updates when the language changes or translations are reloaded. * * Parameters accept plain values or arrow functions. Signal reads inside * the function are tracked reactively. Signals themselves are also * accepted directly, since Signal<T> is callable. * * @param key The translation key (or array of keys), a function returning one * @param params Optional interpolation parameters, or a function returning them * @param lang Optional language override, or a function returning one * @returns A Signal that emits the translated value(s) * * @example * // Static key * greeting = this.translate.translate('HELLO'); * * @example * // Derived key from another signal (no separate computed needed) * model = signal({ currentKey: 'HELLO' }); * greeting = this.translate.translate(() => this.model().currentKey); * * @example * // Multi-key lookup * labels = this.translate.translate(['SAVE', 'CANCEL']); */ translate(key, params, lang) { return computed(() => { const currentKey = typeof key === "function" ? key() : key; const currentParams = typeof params === "function" ? params() : params; const currentLang = typeof lang === "function" ? lang() : lang; return this.instant(currentKey, currentParams, currentLang); }); } keyToObject(key) { if (Array.isArray(key)) { return key.reduce((acc, currKey) => { acc[currKey] = currKey; return acc; }, {}); } return key; } /** * Sets the translated value of a key, after compiling it */ set(key, translation, lang = this.getCurrentLang()) { this.store.setTranslations(lang, insertValue(this.store.getTranslations(lang), key, isString(translation) ? this.compiler.compile(translation, lang) : this.compiler.compileTranslations(translation, lang)), false); } /** * Allows reloading the lang file from the file */ reloadLang(lang) { this.resetLang(lang); return this.loadAndCompileTranslations(lang); } /** * Deletes stored translations for `lang` and clears the in-flight registry * entry — `isLoading()` flips to `false` immediately on this service. * * Does NOT cancel the underlying network call: if the loader is mid-flight * when this method returns, the request can still complete and `tap()` * translations back into the store. To replace state and re-fetch * deterministically, follow with `reloadLang(lang)`. */ resetLang(lang) { // Unconditional clear — `resetLang`'s contract is "forget this entry // NOW", regardless of which load owns it. this.loadingTranslations.clear(lang); this.store.deleteTranslations(lang); } /** * Returns the language code name from the browser, e.g. "de" */ static getBrowserLang() { if (typeof window === "undefined" || !window.navigator) { return undefined; } const browserLang = this.getBrowserCultureLang(); return browserLang ? browserLang.split(/[-_]/)[0] : undefined; } /** * Returns the culture language code name from the browser, e.g. "de-DE" */ static getBrowserCultureLang() { if (typeof window === "undefined" || typeof window.navigator === "undefined") { return undefined; } return window.navigator.languages ? window.navigator.languages[0] : window.navigator.language || window.navigator.browserLanguage || window.navigator.userLanguage; } getBrowserLang() { return TranslateService.getBrowserLang(); } getBrowserCultureLang() { return TranslateService.getBrowserCultureLang(); } /** * The current language as a reactive Signal. * Use `getCurrentLang()` for a non-reactive snapshot. */ get currentLang() { return this.isRoot ? this._currentLang.asReadonly() : this.parent.currentLang; } /** * The fallback language as a reactive Signal. * Use `getFallbackLang()` for a non-reactive snapshot. */ get fallbackLang() { return this.isRoot ? this._fallbackLang.asReadonly() : this.parent.fallbackLang; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateService }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: TranslateService, decorators: [{ type: Injectable }], ctorParameters: () => [] }); /** * Returns a Signal with the translation for the given key, resolved against * the current language. Must be called in an Angular injection context. * * Parameters accept plain values or arrow functions. Signal reads inside * the function are tracked reactively. Signals themselves are also accepted * directly, since Signal<T> is callable. * * @example * greeting = translate('HELLO'); * * @example * model = signal({ currentKey: 'HELLO' }); * greeting = translate(() => this.model().currentKey); */ function translate(key, params, lang) { return inject(TranslateService).translate(key, params, lang); } class TranslateBlockContext { $implicit; constructor($implicit) { this.$implicit = $implicit; } } class TranslateBlockDirective { templateRef = inject((TemplateRef)); viewContainer = inject(ViewContainerRef); t