UNPKG

@ngx-translate/core

Version:

Translation library (i18n) for Angular

1 lines 129 kB
{"version":3,"file":"ngx-translate-core.mjs","sources":["../../../projects/ngx-translate/src/lib/extraction-marker.ts","../../../projects/ngx-translate/src/lib/loading-translations-registry.ts","../../../projects/ngx-translate/src/lib/missing-translation-handler.ts","../../../projects/ngx-translate/src/lib/translate.compiler.ts","../../../projects/ngx-translate/src/lib/translate.loader.ts","../../../projects/ngx-translate/src/lib/util.ts","../../../projects/ngx-translate/src/lib/translate.parser.ts","../../../projects/ngx-translate/src/lib/translate.store.ts","../../../projects/ngx-translate/src/lib/translate.service.ts","../../../projects/ngx-translate/src/lib/translate.function.ts","../../../projects/ngx-translate/src/lib/translate-block.directive.ts","../../../projects/ngx-translate/src/lib/translate-content-key.ts","../../../projects/ngx-translate/src/lib/translate.directive.ts","../../../projects/ngx-translate/src/lib/translate.pipe.ts","../../../projects/ngx-translate/src/lib/translate.providers.ts","../../../projects/ngx-translate/src/lib/translate.service.interface.ts","../../../projects/ngx-translate/src/ngx-translate-core.ts"],"sourcesContent":["export function _<T extends string | string[]>(key: T): T {\n return key;\n}\n","import { computed, Signal, signal, WritableSignal } from \"@angular/core\";\nimport { Observable } from \"rxjs\";\nimport type { InterpolatableTranslationObject, Language } from \"./translate.service.interface\";\n\nfunction omit<V>(map: Record<Language, V>, key: Language): Record<Language, V> {\n const next: Record<Language, V> = {};\n for (const k of Object.keys(map)) {\n if (k !== key) next[k] = map[k];\n }\n return next;\n}\n\n/**\n * In-flight load registry — one entry per language currently being loaded.\n * Backed by a signal so `isLoading` can derive reactively from its contents.\n *\n * Invariants:\n * - Entries added by `loadAndCompileTranslations` before subscribe.\n * - Entries removed by `loadAndCompileTranslations`'s `tap` on the success\n * path (synchronously between store update and subscriber notification, so\n * subscribers observe this language gone from the registry — and\n * `isLoading()` flips false if no other load is in flight) and by the\n * `shareReplay`'d Observable's `finalize` for error / sync-throw /\n * last-subscriber-unsubscribe paths. The success-path finalize is a safe\n * no-op (idempotent `clearIfOwner`).\n * - Same-language back-to-back loads dedup via the existing `shareReplay`; the\n * registry stays at one entry per language regardless of subscriber count.\n * `isLoading` therefore toggles `0 -> 1 -> 0` once per language load, not\n * once per subscriber. Set-based, not count-based — matches user intent\n * (\"a language is loading\" not \"N callers asked\").\n * - `clearIfOwner(lang, token)` only removes the entry if it still matches the\n * original Observable token captured at `set()` time. Prevents stale-finalize\n * races where an old load's `finalize` would clobber a newer load's entry\n * (e.g. `resetLang` followed by `reloadLang` while the old load is still\n * mid-flight).\n * - `clear(lang)` is unconditional — used by `resetLang` to forcibly drop\n * ownership regardless of which load owns the entry.\n *\n * Internal to the package — not re-exported from `public-api.ts`.\n */\nexport class LoadingTranslationsRegistry {\n private state: WritableSignal<Record<Language, Observable<InterpolatableTranslationObject>>> =\n signal({});\n\n /** Reactive — `true` while at least one load is in flight. */\n readonly hasAny: Signal<boolean> = computed(() => Object.keys(this.state()).length > 0);\n\n /** `true` while THIS language is being loaded on this instance. */\n isLoading(lang: Language): boolean {\n return this.state()[lang] !== undefined;\n }\n\n get(lang: Language): Observable<InterpolatableTranslationObject> | undefined {\n return this.state()[lang];\n }\n\n set(lang: Language, obs: Observable<InterpolatableTranslationObject>): void {\n this.state.update((m) => ({ ...m, [lang]: obs }));\n }\n\n /** Unconditional clear. Used by `resetLang` to forcibly drop the entry. */\n clear(lang: Language): void {\n this.state.update((m) => omit(m, lang));\n }\n\n /**\n * Token-aware clear. Used by `loadAndCompileTranslations`'s `finalize` so\n * an old load's `finalize` cannot clobber a newer load's entry.\n */\n clearIfOwner(lang: Language, token: Observable<InterpolatableTranslationObject>): void {\n this.state.update((m) => (m[lang] === token ? omit(m, lang) : m));\n }\n}\n","import { Injectable } from \"@angular/core\";\nimport { Observable } from \"rxjs\";\nimport { TranslateService } from \"./translate.service\";\nimport { StrictTranslation } from \"./translate.service.interface\";\n\nexport interface MissingTranslationHandlerParams {\n /**\n * the key that's missing in translation files\n */\n key: string;\n\n /**\n * an instance of the service that was unable to translate the key.\n */\n translateService: TranslateService;\n\n /**\n * interpolation params that were passed along for translating the given key.\n */\n interpolateParams?: object;\n}\n\nexport abstract class MissingTranslationHandler {\n /**\n * A function that handles missing translations.\n *\n * @param params context for resolving a missing translation\n * @returns a value or an observable\n *\n * If it returns a value, then this value is used.\n * If it returns an observable, the value returned by this observable will be used (except if the method was \"instant\").\n * If it returns undefined, the key will be used as a value\n */\n abstract handle(\n params: MissingTranslationHandlerParams,\n ): StrictTranslation | Observable<StrictTranslation>;\n}\n\n/**\n * This handler is just a placeholder that does nothing; in case you don't need a missing translation handler at all\n */\n@Injectable()\nexport class DefaultMissingTranslationHandler implements MissingTranslationHandler {\n handle(params: MissingTranslationHandlerParams): string {\n return params.key;\n }\n}\n","import { Injectable } from \"@angular/core\";\nimport { InterpolateFunction } from \"./translate.parser\";\nimport {\n InterpolatableTranslation,\n InterpolatableTranslationObject,\n TranslationObject,\n} from \"./translate.service.interface\";\n\nexport abstract class TranslateCompiler {\n abstract compile(value: string, lang: string): InterpolatableTranslation;\n\n abstract compileTranslations(\n translations: TranslationObject,\n lang: string,\n ): InterpolatableTranslationObject;\n}\n\n/**\n * This compiler is just a placeholder that does nothing; in case you don't need a compiler at all\n */\n@Injectable()\nexport class TranslateNoOpCompiler extends TranslateCompiler {\n compile(value: string, lang: string): string | InterpolateFunction {\n void lang;\n return value;\n }\n\n compileTranslations(\n translations: TranslationObject,\n lang: string,\n ): InterpolatableTranslationObject {\n void lang;\n return translations;\n }\n}\n","import { Injectable } from \"@angular/core\";\nimport { Observable, of } from \"rxjs\";\n\nimport { TranslationObject } from \"./translate.service.interface\";\n\nexport abstract class TranslateLoader {\n abstract getTranslation(lang: string): Observable<TranslationObject>;\n}\n\n/**\n * This loader is just a placeholder that does nothing; in case you don't need a loader at all\n */\n@Injectable()\nexport class TranslateNoOpLoader extends TranslateLoader {\n getTranslation(lang: string): Observable<TranslationObject> {\n void lang;\n return of({});\n }\n}\n","import { InterpolatableTranslationObject } from \"./translate.service.interface\";\n\n/**\n * Determines if two objects or two values are equivalent.\n *\n * Two objects or values are considered equivalent if at least one of the following is true:\n *\n * * Both objects or values pass `===` comparison.\n * * Both objects or values are of the same type and all of their properties are equal by\n * comparing them with `equals`.\n *\n * @param o1 Object or value to compare.\n * @param o2 Object or value to compare.\n * @returns true if arguments are equal.\n */\nexport function equals(o1: unknown, o2: unknown): boolean {\n if (o1 === o2) return true;\n if (o1 === null || o2 === null) return false;\n if (o1 !== o1 && o2 !== o2) return true; // NaN === NaN\n\n const t1 = typeof o1,\n t2 = typeof o2;\n let length: number;\n\n if (t1 == t2 && t1 == \"object\") {\n if (Array.isArray(o1)) {\n if (!Array.isArray(o2)) return false;\n if ((length = o1.length) == o2.length) {\n for (let key = 0; key < length; key++) {\n if (!equals(o1[key], o2[key])) return false;\n }\n return true;\n }\n } else {\n if (Array.isArray(o2)) {\n return false;\n }\n if (isDict(o1) && isDict(o2)) {\n const keySet = Object.create(null);\n for (const key in o1) {\n if (!equals(o1[key], o2[key])) {\n return false;\n }\n keySet[key] = true;\n }\n for (const key in o2) {\n if (!(key in keySet) && typeof o2[key] !== \"undefined\") {\n return false;\n }\n }\n return true;\n }\n }\n }\n return false;\n}\n\nexport function isDefinedAndNotNull<T>(value: T | null | undefined): value is T {\n return typeof value !== \"undefined\" && value !== null;\n}\n\nexport function isDefined<T>(value: T | null | undefined): value is T | null {\n return value !== undefined;\n}\n\nexport function isDict(value: unknown): value is InterpolatableTranslationObject {\n return isObject(value) && !isArray(value) && value !== null;\n}\n\nexport function isObject(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n\nexport function isArray(value: unknown): value is unknown[] {\n return Array.isArray(value);\n}\n\nexport function isString(value: unknown): value is string {\n return typeof value === \"string\";\n}\n\nexport function isFunction(value: unknown): boolean {\n return typeof value === \"function\";\n}\n\nfunction cloneDeep(value: unknown): unknown {\n if (isArray(value)) {\n return value.map((item) => cloneDeep(item));\n } else if (isDict(value)) {\n const cloned: Record<string, unknown> = {};\n Object.keys(value).forEach((key) => {\n cloned[key] = cloneDeep((value as Record<string, unknown>)[key]);\n });\n return cloned;\n } else {\n return value;\n }\n}\n\n/* eslint-disable-next-line @typescript-eslint/no-explicit-any */\nexport function mergeDeep(target: Readonly<unknown>, source: Readonly<unknown>): any {\n if (!isObject(target)) {\n return cloneDeep(source);\n }\n\n const output = cloneDeep(target);\n\n if (isObject(output) && isObject(source)) {\n /* eslint-disable-next-line @typescript-eslint/no-explicit-any */\n Object.keys(source).forEach((key: any) => {\n if (isDict(source[key])) {\n if (key in target) {\n output[key] = mergeDeep(target[key] as Readonly<unknown>, source[key]);\n } else {\n Object.assign(output, { [key]: source[key] });\n }\n } else {\n Object.assign(output, { [key]: source[key] });\n }\n });\n }\n return output;\n}\n\n/**\n * Retrieves a value from a nested object using a dot-separated key path.\n *\n * Example usage:\n * ```ts\n * getValue({ key1: { keyA: 'valueI' }}, 'key1.keyA'); // returns 'valueI'\n * ```\n *\n * @param target The source object from which to retrieve the value.\n * @param key Dot-separated key path specifying the value to retrieve.\n * @returns The value at the specified key path, or `undefined` if not found.\n */\nexport function getValue(target: unknown, key: string): unknown {\n const keys = key.split(\".\");\n\n key = \"\";\n do {\n key += keys.shift();\n const isLastKey = !keys.length;\n\n if (isDefinedAndNotNull(target)) {\n if (\n isDict(target) &&\n isDefined(target[key]) &&\n (isDict(target[key]) || isArray(target[key]) || isLastKey)\n ) {\n target = target[key];\n key = \"\";\n continue;\n }\n\n if (isArray(target)) {\n if (key === \"length\" && isLastKey) {\n target = target.length;\n key = \"\";\n continue;\n }\n if (/^\\d+$/.test(key)) {\n const index = parseInt(key, 10);\n if (\n isDefined(target[index]) &&\n (isDict(target[index]) || isArray(target[index]) || isLastKey)\n ) {\n target = target[index];\n key = \"\";\n continue;\n }\n }\n }\n }\n\n if (isLastKey) {\n target = undefined;\n continue;\n }\n key += \".\";\n } while (keys.length);\n\n return target;\n}\n\n/**\n * Sets a value on object using a dot separated key.\n * Returns a clone of the object without modifying it\n * insertValue({a:{b:{c: \"test\"}}}, 'a.b.c', \"test2\") ==> {a:{b:{c: \"test2\"}}}\n * @param target an object\n * @param key E.g. \"a.b.c\"\n * @param value to set\n */\nexport function insertValue<T>(target: Readonly<T>, key: string, value: unknown): T {\n return mergeDeep(target, createNestedObject(key, value) as Readonly<unknown>);\n}\n\nfunction createNestedObject(\n dotSeparatedKey: string,\n value: unknown,\n): Record<string, unknown> | unknown {\n return dotSeparatedKey.split(\".\").reduceRight<unknown>((acc, key) => ({ [key]: acc }), value);\n}\n","import { Injectable } from \"@angular/core\";\nimport { getValue, isArray, isFunction, isObject, isString } from \"./util\";\nimport { InterpolationParameters } from \"./translate.service.interface\";\n\nexport type InterpolateFunction = (params?: InterpolationParameters) => string;\n\nexport abstract class TranslateParser {\n /**\n * Interpolates a string to replace parameters\n * \"This is a {{ key }}\" ==> \"This is a value\", with params = { key: \"value\" }\n * @param expr\n * @param params\n */\n abstract interpolate(\n expr: InterpolateFunction | string,\n params?: InterpolationParameters,\n ): string | undefined;\n}\n\n@Injectable()\nexport class TranslateDefaultParser extends TranslateParser {\n templateMatcher = /{{\\s?([^{}\\s]*)\\s?}}/g;\n\n public interpolate(\n expr: InterpolateFunction | string,\n params?: InterpolationParameters,\n ): string | undefined {\n if (isString(expr)) {\n return this.interpolateString(expr as string, params);\n } else if (isFunction(expr)) {\n return this.interpolateFunction(expr as InterpolateFunction, params);\n }\n return undefined;\n }\n\n protected interpolateFunction(\n fn: InterpolateFunction,\n params?: InterpolationParameters,\n ): string {\n return fn(params);\n }\n\n protected interpolateString(expr: string, params?: InterpolationParameters): string {\n if (!params) {\n return expr;\n }\n\n return expr.replace(this.templateMatcher, (substring: string, key: string) => {\n const replacement = this.getInterpolationReplacement(params, key);\n return replacement !== undefined ? replacement : substring;\n });\n }\n\n /**\n * Returns the replacement for an interpolation parameter\n * @params:\n */\n protected getInterpolationReplacement(\n params: InterpolationParameters,\n key: string,\n ): string | undefined {\n return this.formatValue(getValue(params, key));\n }\n\n /**\n * Converts a value into a useful string representation.\n * @param value The value to format.\n * @returns A string representation of the value.\n */\n protected formatValue(value: unknown): string | undefined {\n if (isString(value)) {\n return value;\n }\n if (typeof value === \"number\" || typeof value === \"boolean\") {\n return value.toString();\n }\n if (value === null) {\n return \"null\";\n }\n if (isArray(value)) {\n return value.join(\", \");\n }\n if (isObject(value)) {\n if (\n typeof value.toString === \"function\" &&\n value.toString !== Object.prototype.toString\n ) {\n return value.toString();\n }\n return JSON.stringify(value); // Pretty-print JSON if no meaningful toString()\n }\n\n return undefined;\n }\n}\n","import { DestroyRef, Injectable, Signal, inject, signal } from \"@angular/core\";\nimport { Observable, Subject } from \"rxjs\";\nimport { getValue, mergeDeep } from \"./util\";\nimport {\n InterpolatableTranslation,\n InterpolatableTranslationObject,\n Language,\n TranslationChangeEvent,\n} from \"./translate.service.interface\";\n\nexport type DeepReadonly<T> = {\n readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];\n};\n\n@Injectable()\nexport class TranslateStore {\n private readonly _translations = signal<Record<Language, InterpolatableTranslationObject>>({});\n readonly translations: Signal<Record<Language, InterpolatableTranslationObject>> =\n this._translations.asReadonly();\n\n private readonly _languages = signal<Language[]>([]);\n readonly languages: Signal<Language[]> = this._languages.asReadonly();\n\n private readonly _lastTranslationChange = signal<TranslationChangeEvent | null>(null);\n readonly lastTranslationChange: Signal<TranslationChangeEvent | null> =\n this._lastTranslationChange.asReadonly();\n\n private readonly _translationChange$ = new Subject<TranslationChangeEvent>();\n readonly translationChange$: Observable<TranslationChangeEvent> =\n this._translationChange$.asObservable();\n\n constructor() {\n // Complete the Subject when the owning injector tears down. Without\n // this, child-service stores on lazy routes leak `translationChange$`\n // subscribers across navigations.\n inject(DestroyRef).onDestroy(() => {\n this._translationChange$.complete();\n });\n }\n\n public getTranslations(language: Language): DeepReadonly<InterpolatableTranslationObject> {\n return this.translations()[language];\n }\n\n public setTranslations(\n language: Language,\n translations: InterpolatableTranslationObject,\n extend: boolean,\n ): void {\n this._translations.update((current) => ({\n ...current,\n [language]:\n extend && this.hasTranslationFor(language)\n ? mergeDeep(current[language], translations)\n : translations,\n }));\n this.addLanguages([language]);\n const event: TranslationChangeEvent = {\n lang: language,\n translations: this.getTranslations(language),\n };\n this._lastTranslationChange.set(event);\n this._translationChange$.next(event);\n }\n\n public getLanguages(): readonly Language[] {\n return this.languages();\n }\n\n public addLanguages(langs: Language[]): void {\n this._languages.update((current) => Array.from(new Set([...current, ...langs])));\n }\n\n public hasTranslationFor(lang: string) {\n return typeof this.translations()[lang] !== \"undefined\";\n }\n\n public deleteTranslations(lang: string) {\n this._translations.update((current) => {\n const { [lang]: _, ...rest } = current;\n return rest;\n });\n }\n\n public getTranslationValue(language: Language, key: string): InterpolatableTranslation {\n return getValue(this.getTranslations(language), key) as InterpolatableTranslation;\n }\n}\n","import {\n computed,\n DestroyRef,\n inject,\n Injectable,\n InjectionToken,\n Signal,\n signal,\n untracked,\n WritableSignal,\n} from \"@angular/core\";\nimport { takeUntilDestroyed } from \"@angular/core/rxjs-interop\";\nimport {\n concat,\n defer,\n EMPTY,\n finalize,\n forkJoin,\n isObservable,\n merge,\n Observable,\n of,\n Subject,\n tap,\n} from \"rxjs\";\nimport { concatMap, filter, map, shareReplay, switchMap, take } from \"rxjs/operators\";\nimport { LoadingTranslationsRegistry } from \"./loading-translations-registry\";\nimport { MissingTranslationHandler } from \"./missing-translation-handler\";\nimport { TranslateCompiler } from \"./translate.compiler\";\nimport { TranslateLoader } from \"./translate.loader\";\nimport { TranslateParser } from \"./translate.parser\";\nimport { DeepReadonly, TranslateStore } from \"./translate.store\";\nimport { insertValue, isArray, isDefinedAndNotNull, isDict, isString } from \"./util\";\nimport {\n FallbackLangChangeEvent,\n InterpolatableTranslation,\n InterpolatableTranslationObject,\n InterpolationParameters,\n ITranslateService,\n LangChangeEvent,\n Language,\n StrictTranslation,\n Translation,\n TranslationChangeEvent,\n TranslationObject,\n} from \"./translate.service.interface\";\n\n/**\n * Configuration object for the translation service.\n *\n * Provides options to customize translation behavior, including setting the primary language,\n * specifying a fallback language, and other deprecated flags for legacy support.\n */\nexport interface TranslateServiceConfig {\n lang?: Language;\n fallbackLang?: Language | null;\n isRoot: boolean;\n}\n\nexport const TRANSLATE_SERVICE_CONFIG = new InjectionToken<TranslateServiceConfig>(\n \"TRANSLATE_CONFIG\",\n);\n\ndeclare interface Window {\n navigator: {\n languages?: string[];\n language?: string;\n browserLanguage?: string;\n userLanguage?: string;\n };\n}\n\ndeclare const window: Window;\n\nconst makeObservable = <T>(value: T | Observable<T>): Observable<T> => {\n return isObservable(value) ? value : of(value);\n};\n\n@Injectable()\nexport class TranslateService implements ITranslateService {\n protected readonly loadingTranslations = new LoadingTranslationsRegistry();\n protected lastUseLanguage: Language | null = null;\n\n protected currentLoader = inject(TranslateLoader);\n protected compiler = inject(TranslateCompiler);\n protected parser = inject(TranslateParser);\n protected missingTranslationHandler = inject(MissingTranslationHandler);\n protected store: TranslateStore = inject(TranslateStore);\n private readonly destroyRef = inject(DestroyRef);\n\n protected readonly parent: TranslateService | null;\n\n protected get isRoot(): boolean {\n return this.parent === null;\n }\n\n protected _onLangChange = new Subject<LangChangeEvent>();\n protected _onFallbackLangChange = new Subject<FallbackLangChangeEvent>();\n protected _currentLang: WritableSignal<Language | null> = signal(null);\n protected _fallbackLang: WritableSignal<Language | null> = signal(null);\n private _onTranslationRefresh: Observable<void> | null = null;\n\n // Downward-inheritance: `true` if THIS service has loads in flight, OR any\n // ancestor does. Walks the parent chain via the public `parent.isLoading()`\n // getter (not by reaching into `parent.loadingTranslations`) so the\n // encapsulation boundary holds. Angular signals re-collect dependencies on\n // each evaluation; short-circuit is safe — the only signal whose flip\n // could change the result is the one returned by short-circuit, and it IS\n // tracked. Parent chain is acyclic (DI tree is acyclic; `skipSelf` only\n // walks up), so no cycle guard needed.\n //\n // Set-based composition at each level: `loadingTranslations.hasAny()`\n // stays true while ANY language has an in-flight load on this service,\n // flips false only when the last entry is cleared.\n private _isLoading: Signal<boolean> = computed(\n () => this.loadingTranslations.hasAny() || (this.parent?.isLoading() ?? false),\n );\n\n /**\n * Returns the root of this service's hierarchy — the topmost service in\n * the `getParent()` chain. For an isolated subtree, returns the subtree's\n * root (since `parent === null` at the isolation boundary).\n *\n * A root service returns itself. Equivalent to walking `getParent()` until\n * it returns `null`, but provided as a convenience.\n */\n public getRoot(): TranslateService {\n // eslint-disable-next-line @typescript-eslint/no-this-alias\n let svc: TranslateService = this;\n while (svc.parent) svc = svc.parent;\n return svc;\n }\n\n /**\n * Returns the service this one inherits translations from, or `null` if\n * this is a root (a top-level service or an isolated subtree root).\n *\n * A `null` return means the service is the terminus of its translation\n * fallback chain — equivalent to \"is this a root?\".\n */\n public getParent(): TranslateService | null {\n return this.parent;\n }\n\n /**\n * The language most recently requested via `use()`. Always read from the\n * root, because `use()` delegates to the root (`parent!.use(lang)`) and only\n * the root ever assigns `lastUseLanguage`. A child's own `lastUseLanguage`\n * stays `null`, so reading it directly would make `get()` miss the child's\n * in-flight load. `null` until the first `use()` runs.\n */\n protected getActiveRequestedLang(): Language | null {\n return this.getRoot().lastUseLanguage;\n }\n\n protected hasTranslationInChain(lang: Language): boolean {\n // eslint-disable-next-line @typescript-eslint/no-this-alias\n for (let svc: TranslateService | null = this; svc; svc = svc.parent) {\n if (svc.store.hasTranslationFor(lang)) return true;\n }\n return false;\n }\n\n protected chainTranslationChange$(): Observable<TranslationChangeEvent> {\n const streams: Observable<TranslationChangeEvent>[] = [];\n // eslint-disable-next-line @typescript-eslint/no-this-alias\n for (let svc: TranslateService | null = this; svc; svc = svc.parent) {\n streams.push(svc.store.translationChange$);\n }\n return streams.length === 1 ? streams[0] : merge(...streams);\n }\n\n /**\n * An Observable to listen to translation change events\n * onTranslationChange.subscribe((params: TranslationChangeEvent) => {\n * // do something\n * });\n */\n public get onTranslationChange(): Observable<TranslationChangeEvent> {\n return this.store.translationChange$;\n }\n\n /**\n * An Observable to listen to lang change events\n * onLangChange.subscribe((params: LangChangeEvent) => {\n * // do something\n * });\n */\n get onLangChange(): Observable<LangChangeEvent> {\n if (this.isRoot) {\n return this._onLangChange.asObservable();\n }\n return this.parent ? this.parent.onLangChange : EMPTY;\n }\n\n /**\n * An Observable to listen to fallback lang change events\n * onFallbackLangChange.subscribe((params: FallbackLangChangeEvent) => {\n * // do something\n * });\n */\n get onFallbackLangChange(): Observable<FallbackLangChangeEvent> {\n if (this.isRoot) {\n return this._onFallbackLangChange.asObservable();\n }\n return this.parent ? this.parent.onFallbackLangChange : EMPTY;\n }\n\n /**\n * A combined Observable that emits whenever translations might need to be refreshed.\n * This includes: language changes, translation updates for the current or fallback language,\n * and fallback language changes.\n */\n get onTranslationRefresh(): Observable<void> {\n if (!this._onTranslationRefresh) {\n const refresh$ = merge(\n this.onTranslationChange.pipe(\n filter(\n (event) =>\n event.lang === this.getCurrentLang() ||\n event.lang === this.getFallbackLang(),\n ),\n ),\n this.onLangChange,\n this.onFallbackLangChange,\n ).pipe(map(() => void 0));\n\n if (this.isRoot) {\n this._onTranslationRefresh = refresh$;\n } else {\n this._onTranslationRefresh = this.parent\n ? merge(refresh$, this.parent.onTranslationRefresh)\n : refresh$;\n }\n }\n return this._onTranslationRefresh;\n }\n\n constructor() {\n const config: TranslateServiceConfig = {\n isRoot: true,\n fallbackLang: null,\n\n ...inject<TranslateServiceConfig>(TRANSLATE_SERVICE_CONFIG, {\n optional: true,\n }),\n };\n\n // parent === null exactly means \"I am a root\" (including isolated-subtree roots).\n // isRoot is now a getter derived from this single source of truth.\n this.parent = config.isRoot\n ? null\n : inject(TranslateService, { optional: true, skipSelf: true });\n\n const destroyRef = inject(DestroyRef);\n\n if (this.isRoot) {\n if (config.lang) {\n this.use(config.lang);\n }\n if (config.fallbackLang) {\n this.setFallbackLang(config.fallbackLang);\n }\n } else {\n // Child services should initially load the root's current and fallback languages.\n // Loader failures are warned contextually here — the internal no-op subscribe\n // inside loadAndCompileTranslations would otherwise swallow them silently.\n // Best-effort: takeUntilDestroyed tears down the subscription on host destroy,\n // so a destroy-then-error race silently drops the warn.\n const currentLang = this.getCurrentLang();\n if (currentLang) {\n this.loadOrExtendLanguage(currentLang)\n ?.pipe(takeUntilDestroyed(destroyRef))\n .subscribe({\n error: (err) => {\n console.warn(\n `@ngx-translate/core: child failed to load \"${currentLang}\". Cause:`,\n err,\n );\n },\n });\n }\n const fallbackLang = this.getFallbackLang();\n // Dedup guard: currentLang === fallbackLang means both branches would\n // resolve to the same in-flight observable (via the loading-translations\n // registry's get-or-create) and double-log on error.\n if (fallbackLang && fallbackLang !== currentLang) {\n this.loadOrExtendLanguage(fallbackLang)\n ?.pipe(takeUntilDestroyed(destroyRef))\n .subscribe({\n error: (err) => {\n console.warn(\n `@ngx-translate/core: child failed to load \"${fallbackLang}\". Cause:`,\n err,\n );\n },\n });\n }\n }\n\n // Child services should load translations when the language changes on the root\n this.onLangChange.pipe(takeUntilDestroyed(destroyRef)).subscribe((event) => {\n if (!this.isRoot) {\n this.loadOrExtendLanguage(event.lang)\n ?.pipe(takeUntilDestroyed(destroyRef))\n .subscribe({\n error: (err) => {\n console.warn(\n `@ngx-translate/core: child failed to load \"${event.lang}\". Cause:`,\n err,\n );\n },\n });\n }\n });\n\n this.onFallbackLangChange.pipe(takeUntilDestroyed(destroyRef)).subscribe((event) => {\n if (!this.isRoot) {\n this.loadOrExtendLanguage(event.lang)\n ?.pipe(takeUntilDestroyed(destroyRef))\n .subscribe({\n error: (err) => {\n console.warn(\n `@ngx-translate/core: child failed to load \"${event.lang}\". Cause:`,\n err,\n );\n },\n });\n }\n });\n\n // Complete this service's Subjects when its injector tears down.\n // Root singletons live as long as the app, but child services on\n // lazy routes would otherwise pin their Subjects until GC.\n destroyRef.onDestroy(() => {\n this._onLangChange.complete();\n this._onFallbackLangChange.complete();\n });\n }\n\n /**\n * Sets the fallback language to use if a translation is not found in the\n * current language\n */\n public setFallbackLang(lang: Language): Observable<InterpolatableTranslationObject> {\n if (!this.isRoot) {\n return this.parent!.setFallbackLang(lang);\n }\n\n if (!this._fallbackLang()) {\n // on init set the fallbackLang immediately, but do not emit a change yet\n this._fallbackLang.set(lang);\n }\n\n const pending = this.loadOrExtendLanguage(lang);\n if (isObservable(pending)) {\n pending.pipe(take(1)).subscribe({\n next: () => {\n this._fallbackLang.set(lang);\n this._onFallbackLangChange.next({\n lang: lang,\n translations: this.store.getTranslations(lang),\n });\n },\n error: (err) => {\n console.warn(\n `@ngx-translate/core: failed to load fallback \"${lang}\". Cause:`,\n err,\n );\n },\n });\n return pending;\n }\n\n this._fallbackLang.set(lang);\n this._onFallbackLangChange.next({\n lang: lang,\n translations: this.store.getTranslations(lang),\n });\n return of(this.store.getTranslations(lang));\n }\n\n /**\n * Signal that is `true` while one or more language loads are in flight at\n * this service or any of its ancestors in the service hierarchy.\n *\n * Loading scope propagates DOWNWARD: a load triggered at the root marks\n * the root and all descendants as loading. A load triggered at a child\n * (e.g. a lazy-route bootstrap fetching its translations) marks only that\n * child's subtree. Siblings and ancestors are unaffected by a descendant's\n * loads.\n *\n * Drive a spinner by reading it from the service injected at the scope\n * where the spinner should live: root for an app-shell spinner, the\n * nearest child for a local spinner inside a lazy-loaded subtree.\n */\n public get isLoading(): Signal<boolean> {\n return this._isLoading;\n }\n\n /**\n * Changes the lang currently used\n */\n public use(lang: Language): Observable<InterpolatableTranslationObject> {\n if (!this.isRoot) {\n return this.parent!.use(lang);\n }\n\n // Snapshot prior state so we can roll back if the loader fails.\n const prevLang = this._currentLang();\n const prevLastUseLang = this.lastUseLanguage;\n\n // Remember the language that was called — used by changeLang() to discard\n // late-arriving completions from superseded calls.\n this.lastUseLanguage = lang;\n\n if (!this._currentLang()) {\n // on init set the currentLang immediately, but do not emit a change yet\n this._currentLang.set(lang);\n }\n\n const pending = this.loadOrExtendLanguage(lang);\n if (!isObservable(pending)) {\n // Defensive: loadOrExtendLanguage is typed `Observable | undefined`.\n // The undefined branch means \"nothing to load\" — synchronously activate.\n this.changeLang(lang);\n return of(this.store.getTranslations(lang));\n }\n\n pending.pipe(take(1)).subscribe({\n next: () => {\n this.changeLang(lang);\n },\n error: (err) => {\n // Only roll back if THIS call is still the most-recent one.\n // A later use() may have superseded it (symmetric with the\n // changeLang() guard).\n if (this.lastUseLanguage === lang) {\n this._currentLang.set(prevLang);\n this.lastUseLanguage = prevLastUseLang;\n }\n console.warn(\n `@ngx-translate/core: failed to load \"${lang}\". ` +\n `currentLang was NOT changed; remains ` +\n `\"${prevLang ?? \"null\"}\". Cause:`,\n err,\n );\n },\n });\n return pending;\n }\n\n /**\n * Retrieves the given translations\n */\n protected loadOrExtendLanguage(\n lang: Language,\n ): Observable<InterpolatableTranslationObject> | undefined {\n // if this language is unavailable, ask for it\n if (!this.store.hasTranslationFor(lang)) {\n return this.loadAndCompileTranslations(lang);\n }\n\n return of(this.store.getTranslations(lang));\n }\n\n /**\n * @returns The loaded translations for the given language\n */\n public getTranslations(language: Language): DeepReadonly<InterpolatableTranslationObject> {\n return this.store.getTranslations(language);\n }\n\n /**\n * Changes the current lang\n */\n protected changeLang(lang: Language): void {\n if (lang !== this.lastUseLanguage) {\n // received new language data,\n // but this was not the one requested last\n return;\n }\n\n this._currentLang.set(lang);\n this._onLangChange.next({ lang: lang, translations: this.store.getTranslations(lang) });\n }\n\n public getCurrentLang(): Language | null {\n return this.isRoot ? this._currentLang() : (this.parent?.getCurrentLang() ?? null);\n }\n\n /**\n * Loads translations for `lang` via the configured `TranslateLoader`,\n * compiles them, and stores the result. Tracking via the protected\n * `loadingTranslations` registry happens automatically.\n *\n * Subclasses that override this method bypass `isLoading` tracking\n * unless they call `this.loadingTranslations.set(lang, obs)` and arrange\n * a token-aware finalize (`this.loadingTranslations.clearIfOwner(lang, obs)`)\n * from the override.\n */\n protected loadAndCompileTranslations(\n lang: Language,\n ): Observable<InterpolatableTranslationObject> {\n const existing = this.loadingTranslations.get(lang);\n if (existing) {\n return existing;\n }\n\n const translations$ = this.currentLoader.getTranslation(lang).pipe(\n map((res: TranslationObject) => this.compiler.compileTranslations(res, lang)),\n tap((compiled: InterpolatableTranslationObject) => {\n this.store.setTranslations(lang, compiled, false);\n // Clear synchronously on success — this `tap` runs before\n // `shareReplay` forwards `next` to subscribers, so subscribers\n // receiving `next` observe `isLoading()` reflecting that this\n // language is no longer in flight. The finalize below covers\n // error / sync-throw / unsubscribe paths; clearIfOwner is\n // idempotent so the double-clear on success is a safe no-op.\n // Invariant: `tap` MUST stay before `shareReplay` in the pipe.\n this.loadingTranslations.clearIfOwner(lang, translations$);\n }),\n // Token-aware clear: if `resetLang` + `reloadLang` raced between\n // set() and finalize(), this load's finalize must NOT clobber the\n // newer load's entry. clearIfOwner compares by reference identity.\n finalize(() => this.loadingTranslations.clearIfOwner(lang, translations$)),\n // cache the single result & share it across all subscribers\n shareReplay({ bufferSize: 1, refCount: true }),\n );\n\n this.loadingTranslations.set(lang, translations$);\n\n // Trigger loading if nobody subscribes from outside. The error callback\n // is intentionally a no-op: use() and setFallbackLang() already emit a\n // console.warn on loader failure for their own paths. Warning here\n // would double-log for those callers. Bound to the service lifetime so a\n // non-completing/hot custom loader cannot pin this subscription (and the\n // upstream loader subscription) past service teardown.\n translations$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({\n // eslint-disable-next-line @typescript-eslint/no-empty-function\n error: () => {},\n });\n\n return translations$;\n }\n\n /**\n * Manually sets an object of translations for a given language,\n * passing it through the configured {@link TranslateCompiler} first.\n *\n * If you already have translations in their final compiled form\n * (e.g. interpolation functions produced at build time), use\n * {@link setCompiledTranslation} instead — it stores the data\n * directly and skips the compiler.\n */\n public setTranslation(\n lang: Language,\n translations: TranslationObject,\n shouldMerge = false,\n ): void {\n const interpolatableTranslations: InterpolatableTranslationObject =\n this.compiler.compileTranslations(translations, lang);\n this.store.setTranslations(lang, interpolatableTranslations, shouldMerge);\n }\n\n /**\n * Stores an already-compiled translation object for the given language,\n * bypassing the configured {@link TranslateCompiler}.\n *\n * Use this when you have translations in their final, interpolator-ready\n * form — e.g. interpolation functions produced at build time. For raw\n * translations that still need to go through the compiler, use\n * {@link setTranslation} instead.\n */\n public setCompiledTranslation(\n lang: Language,\n translations: InterpolatableTranslationObject,\n shouldMerge = false,\n ): void {\n this.store.setTranslations(lang, translations, shouldMerge);\n }\n\n public getLangs(): readonly Language[] {\n return this.store.getLanguages();\n }\n\n /**\n * Add available languages\n */\n public addLangs(languages: Language[]): void {\n this.store.addLanguages(languages);\n }\n\n protected getParsedResultForKey(\n key: string,\n interpolateParams?: InterpolationParameters,\n lang?: Language,\n ): StrictTranslation | Observable<StrictTranslation> {\n const textToInterpolate = this.getTextToInterpolate(key, lang);\n\n if (isDefinedAndNotNull(textToInterpolate)) {\n return this.runInterpolation(textToInterpolate, interpolateParams);\n }\n\n const handler = this.getMissingTranslationHandler();\n const res = handler.handle({\n key,\n translateService: this,\n ...(interpolateParams !== undefined && { interpolateParams }),\n });\n\n return res !== undefined ? res : key;\n }\n\n protected getMissingTranslationHandler(): MissingTranslationHandler {\n return this.missingTranslationHandler;\n }\n\n /**\n * Gets the fallback language. null if none is defined\n */\n public getFallbackLang(): Language | null {\n return this.isRoot ? this._fallbackLang() : (this.parent?.getFallbackLang() ?? null);\n }\n\n protected getTextToInterpolate(\n key: string,\n lang?: Language,\n ): InterpolatableTranslation | undefined {\n if (lang) {\n const res = this.store.getTranslationValue(lang, key);\n if (res !== undefined) {\n return res;\n }\n return this.parent?.getTextToInterpolate(key, lang);\n }\n\n const currentLang = this.getCurrentLang();\n const fallbackLang = this.getFallbackLang();\n\n // 1. Try own store (currentLang)\n let res: InterpolatableTranslation | undefined;\n if (currentLang) {\n res = this.store.getTranslationValue(currentLang, key);\n }\n\n // 2. Try own store (fallbackLang) - null values also trigger fallback\n if (!isDefinedAndNotNull(res) && fallbackLang && fallbackLang !== currentLang) {\n res = this.store.getTranslationValue(fallbackLang, key);\n }\n\n if (res !== undefined) {\n return res;\n }\n\n // 3. Try parent\n return this.parent?.getTextToInterpolate(key);\n }\n\n protected runInterpolation(\n translations: InterpolatableTranslation,\n interpolateParams?: InterpolationParameters,\n ): StrictTranslation {\n if (!isDefinedAndNotNull(translations)) {\n return;\n }\n\n if (isArray(translations)) {\n return this.runInterpolationOnArray(translations, interpolateParams);\n }\n\n if (isDict(translations)) {\n return this.runInterpolationOnDict(translations, interpolateParams);\n }\n\n return this.parser.interpolate(translations, interpolateParams);\n }\n\n protected runInterpolationOnArray(\n translations: InterpolatableTranslation,\n interpolateParams: InterpolationParameters | undefined,\n ) {\n return (translations as StrictTranslation[]).map((translation) =>\n this.runInterpolation(translation, interpolateParams),\n );\n }\n\n protected runInterpolationOnDict(\n translations: InterpolatableTranslationObject,\n interpolateParams: InterpolationParameters | undefined,\n ) {\n const result: TranslationObject = {};\n for (const key in translations) {\n const res = this.runInterpolation(translations[key], interpolateParams);\n if (res !== undefined) {\n result[key] = res;\n }\n }\n return result;\n }\n\n /**\n * Returns the parsed result of the translations\n */\n public getParsedResult(\n key: string | string[],\n interpolateParams?: InterpolationParameters,\n lang?: Language,\n ): StrictTranslation | Observable<StrictTranslation> {\n return key instanceof Array\n ? this.getParsedResultForArray(key, interpolateParams, lang)\n : this.getParsedResultForKey(key, interpolateParams, lang);\n }\n\n protected getParsedResultForArray(\n key: string[],\n interpolateParams: InterpolationParameters | undefined,\n lang?: Language,\n ) {\n const result: Record<string, StrictTranslation | Observable<StrictTranslation>> = {};\n\n let observables = false;\n for (const k of key) {\n result[k] = this.getParsedResultForKey(k, interpolateParams, lang);\n observables = observables || isObservable(result[k]);\n }\n\n if (!observables) {\n return result as TranslationObject;\n }\n\n const sources: Observable<StrictTranslation>[] = key.map((k) => makeObservable(result[k]));\n return forkJoin(sources).pipe(\n map((arr: StrictTranslation[]) => {\n const obj: TranslationObject = {};\n arr.forEach((value: StrictTranslation, index: number) => {\n obj[key[index]] = value;\n });\n return obj;\n }),\n );\n }\n\n /**\n * Gets the translated value of a key (or an array of keys)\n * @returns the translated key, or an object of translated keys\n */\n public get(\n key: string | string[],\n interpolateParams?: InterpolationParameters,\n lang?: Language,\n ): Observable<Translation> {\n if (!isDefinedAndNotNull(key) || !key.length) {\n return of(\