UNPKG

@jsverse/transloco

Version:

The internationalization (i18n) library for Angular

1,347 lines (1,324 loc) 64.8 kB
import * as i0 from '@angular/core'; import { InjectionToken, inject, Injectable, Injector, Inject, DestroyRef, Optional, Component, Input, TemplateRef, ChangeDetectorRef, ElementRef, ViewContainerRef, Renderer2, Directive, Pipe, NgModule, makeEnvironmentProviders, APP_INITIALIZER, assertInInjectionContext, runInInjectionContext, isSignal, computed } from '@angular/core'; import { of, from, map, Subject, BehaviorSubject, EMPTY, forkJoin, retry, tap, catchError, shareReplay, switchMap, combineLatest, take } from 'rxjs'; import { toSignal, takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { isString, isDefined, isObject, isFunction, isNil, isEmpty, size, toCamelCase } from '@jsverse/utils'; class DefaultLoader { translations; constructor(translations) { this.translations = translations; } getTranslation(lang) { return of(this.translations.get(lang) || {}); } } const TRANSLOCO_LOADER = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_LOADER' : ''); const TRANSLOCO_CONFIG = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_CONFIG' : '', { providedIn: 'root', factory: () => defaultConfig, }); const defaultConfig = { defaultLang: 'en', reRenderOnLangChange: false, prodMode: false, failedRetries: 2, fallbackLang: [], availableLangs: [], missingHandler: { logMissingKey: true, useFallbackTranslation: false, allowEmpty: false, }, flatten: { aot: false, }, interpolation: ['{{', '}}'], scopes: { keepCasing: false, autoPrefixKeys: true, }, }; function translocoConfig(config = {}) { return { ...defaultConfig, ...config, missingHandler: { ...defaultConfig.missingHandler, ...config.missingHandler, }, flatten: { ...defaultConfig.flatten, ...config.flatten, }, scopes: { ...defaultConfig.scopes, ...config.scopes, }, }; } function getValue(obj, path) { if (!obj) { return obj; } /* For cases where the key is like: 'general.something.thing' */ if (Object.prototype.hasOwnProperty.call(obj, path)) { return obj[path]; } return path.split('.').reduce((p, c) => p?.[c], obj); } function setValue(obj, prop, val) { obj = { ...obj }; const split = prop.split('.'); const lastIndex = split.length - 1; split.reduce((acc, part, index) => { if (index === lastIndex) { acc[part] = val; } else { acc[part] = Array.isArray(acc[part]) ? acc[part].slice() : { ...acc[part] }; } return acc && acc[part]; }, obj); return obj; } const TRANSLOCO_TRANSPILER = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_TRANSPILER' : ''); class DefaultTranspiler { config = inject(TRANSLOCO_CONFIG, { optional: true }) ?? defaultConfig; get interpolationMatcher() { return resolveMatcher(this.config); } transpile({ value, params = {}, translation, key }) { if (isString(value)) { let paramMatch; let parsedValue = value; while ((paramMatch = this.interpolationMatcher.exec(parsedValue)) !== null) { const [match, paramValue] = paramMatch; parsedValue = parsedValue.replace(match, () => { const match = paramValue.trim(); const param = getValue(params, match); if (isDefined(param)) { return param; } return isDefined(translation[match]) ? this.transpile({ params, translation, key, value: translation[match], }) : ''; }); } return parsedValue; } else if (params) { if (isObject(value)) { value = this.handleObject({ value, params, translation, key, }); } else if (Array.isArray(value)) { value = this.handleArray({ value, params, translation, key }); } } return value; } /** * * @example * * const en = { * a: { * b: { * c: "Hello {{ value }}" * } * } * } * * const params = { * "b.c": { value: "Transloco "} * } * * service.selectTranslate('a', params); * * // the first param will be the result of `en.a`. * // the second param will be `params`. * parser.transpile(value, params, {}); * * */ handleObject({ value, params = {}, translation, key, }) { let result = value; Object.keys(params).forEach((p) => { // transpile the value => "Hello Transloco" const transpiled = this.transpile({ // get the value of "b.c" inside "a" => "Hello {{ value }}" value: getValue(result, p), // get the params of "b.c" => { value: "Transloco" } params: getValue(params, p), translation, key, }); // set "b.c" to `transpiled` result = setValue(result, p, transpiled); }); return result; } handleArray({ value, ...rest }) { return value.map((v) => this.transpile({ value: v, ...rest, })); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultTranspiler, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultTranspiler }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultTranspiler, decorators: [{ type: Injectable }] }); function resolveMatcher(config) { const [start, end] = config.interpolation; return new RegExp(`${start}([^${start}${end}]*?)${end}`, 'g'); } function getFunctionArgs(argsString) { const splitted = argsString ? argsString.split(',') : []; const args = []; for (let i = 0; i < splitted.length; i++) { let value = splitted[i].trim(); while (value[value.length - 1] === '\\') { i++; value = value.replace('\\', ',') + splitted[i]; } args.push(value); } return args; } class FunctionalTranspiler extends DefaultTranspiler { injector = inject(Injector); transpile({ value, ...rest }) { let transpiled = value; if (isString(value)) { transpiled = value.replace(/\[\[\s*(\w+)\((.*?)\)\s*]]/g, (match, functionName, args) => { try { const func = this.injector.get(functionName); return func.transpile(...getFunctionArgs(args)); } catch (e) { let message = `There is an error in: '${value}'. Check that the you used the right syntax in your translation and that the implementation of ${functionName} is correct.`; if (e.message.includes('NullInjectorError')) { message = `You are using the '${functionName}' function in your translation but no provider was found!`; } throw new Error(message); } }); } return super.transpile({ value: transpiled, ...rest }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: FunctionalTranspiler, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: FunctionalTranspiler }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: FunctionalTranspiler, decorators: [{ type: Injectable }] }); const TRANSLOCO_MISSING_HANDLER = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_MISSING_HANDLER' : ''); class DefaultMissingHandler { handle(key, config) { if (config.missingHandler.logMissingKey && !config.prodMode) { const msg = `Missing translation for '${key}'`; console.warn(`%c ${msg}`, 'font-size: 12px; color: red'); } return key; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultMissingHandler, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultMissingHandler }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultMissingHandler, decorators: [{ type: Injectable }] }); const TRANSLOCO_INTERCEPTOR = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_INTERCEPTOR' : ''); class DefaultInterceptor { preSaveTranslation(translation) { return translation; } preSaveTranslationKey(_, value) { return value; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultInterceptor, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultInterceptor }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultInterceptor, decorators: [{ type: Injectable }] }); const TRANSLOCO_FALLBACK_STRATEGY = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_FALLBACK_STRATEGY' : ''); class DefaultFallbackStrategy { userConfig; constructor(userConfig) { this.userConfig = userConfig; } getNextLangs() { const fallbackLang = this.userConfig.fallbackLang; if (!fallbackLang) { throw new Error('When using the default fallback, a fallback language must be provided in the config!'); } return Array.isArray(fallbackLang) ? fallbackLang : [fallbackLang]; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultFallbackStrategy, deps: [{ token: TRANSLOCO_CONFIG }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultFallbackStrategy }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: DefaultFallbackStrategy, decorators: [{ type: Injectable }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Inject, args: [TRANSLOCO_CONFIG] }] }] }); function resolveLoader(options) { const { path, inlineLoader, mainLoader, data } = options; if (inlineLoader) { const pathLoader = inlineLoader[path]; if (isFunction(pathLoader) === false) { throw `You're using an inline loader but didn't provide a loader for ${path}`; } return inlineLoader[path]().then((res) => res.default ? res.default : res); } return mainLoader.getTranslation(path, data); } function getFallbacksLoaders({ mainLoader, path, data, fallbackPath, inlineLoader, }) { const paths = fallbackPath ? [path, fallbackPath] : [path]; return paths.map((path) => { const loader = resolveLoader({ path, mainLoader, inlineLoader, data }); return from(loader).pipe(map((translation) => ({ translation, lang: path, }))); }); } function flatten(obj) { const result = {}; function recurse(curr, prop) { if (curr === null) { result[prop] = null; } else if (isObject(curr)) { for (const [key, value] of Object.entries(curr)) { recurse(value, prop ? `${prop}.${key}` : key); } } else { result[prop] = curr; } } recurse(obj, ''); return result; } function unflatten(obj) { const result = {}; for (const [key, value] of Object.entries(obj)) { const keys = key.split('.'); let current = result; keys.forEach((key, i) => { if (i === keys.length - 1) { current[key] = value; } else { current[key] ??= {}; current = current[key]; } }); } return result; } /* * @example * * given: lazy-page/en => lazy-page * */ function getScopeFromLang(lang) { if (!lang) { return ''; } const split = lang.split('/'); split.pop(); return split.join('/'); } /* * @example * * given: lazy-page/en => en * */ function getLangFromScope(lang) { if (!lang) { return ''; } return lang.split('/').pop(); } function prependScope(inlineLoader, scope) { return Object.keys(inlineLoader).reduce((acc, lang) => { acc[`${scope}/${lang}`] = inlineLoader[lang]; return acc; }, {}); } function isScopeObject(item) { return typeof item?.scope === 'string'; } function hasInlineLoader(item) { return item?.loader && isObject(item.loader); } function resolveInlineLoader(providerScope, scope) { return hasInlineLoader(providerScope) ? prependScope(providerScope.loader, scope) : undefined; } function getEventPayload(lang) { return { scope: getScopeFromLang(lang) || null, langName: getLangFromScope(lang), }; } let service; function translate(key, params = {}, lang) { return service.translate(key, params, lang); } function translateObject(key, params = {}, lang) { return service.translateObject(key, params, lang); } class TranslationLoadError extends Error { lang; fallbackLangs; isScope; constructor(lang, fallbackLangs, isScope) { let msg = `Unable to load translation and all the fallback languages`; if (isScope) { msg += `, did you misspell the scope name?`; } super(msg); this.lang = lang; this.fallbackLangs = fallbackLangs; this.isScope = isScope; this.name = 'TranslationLoadError'; } } class TranslocoService { loader; parser; missingHandler; interceptor; fallbackStrategy; langChanges$; translations = new Map(); cache = new Map(); firstFallbackLang; defaultLang = ''; availableLangs = []; isResolvedMissingOnce = false; lang; failedLangs = new Set(); events = new Subject(); events$ = this.events.asObservable(); config; /** * A signal that reflects the currently active language. * * @example * * const upper = computed(() => this.transloco.activeLang().toUpperCase()); * * const lang = linkedSignal(() => this.transloco.activeLang()); */ activeLang; destroyRef = inject(DestroyRef); destroyed = false; constructor(loader, parser, missingHandler, interceptor, userConfig, fallbackStrategy) { this.loader = loader; this.parser = parser; this.missingHandler = missingHandler; this.interceptor = interceptor; this.fallbackStrategy = fallbackStrategy; if (!this.loader) { this.loader = new DefaultLoader(this.translations); } service = this; this.config = JSON.parse(JSON.stringify(userConfig)); this.setAvailableLangs(this.config.availableLangs || []); this.setFallbackLangForMissingTranslation(this.config); this.setDefaultLang(this.config.defaultLang); this.lang = new BehaviorSubject(this.getDefaultLang()); // Don't use distinctUntilChanged as we need the ability to update // the value when using setTranslation or setTranslationKeys this.langChanges$ = this.lang.asObservable(); this.activeLang = toSignal(this.lang, { requireSync: true }); /** * When we have a failure, we want to define the next language that succeeded as the active */ this.events$.subscribe((e) => { if (e.type === 'translationLoadSuccess' && e.wasFailure) { this.setActiveLang(e.payload.langName); } }); this.destroyRef.onDestroy(() => { this.destroyed = true; // Complete subjects to release observers if users forget to unsubscribe manually. // This is important in server-side rendering. this.lang.complete(); this.events.complete(); // As a root provider, this service is destroyed with when the application is destroyed. // Cached values retain `this`, causing circular references that block garbage collection, // leading to memory leaks during server-side rendering. this.cache.clear(); }); } getDefaultLang() { return this.defaultLang; } setDefaultLang(lang) { this.defaultLang = lang; } getActiveLang() { return this.lang.getValue(); } setActiveLang(lang) { this.parser.onLangChanged?.(lang); this.lang.next(lang); this.events.next({ type: 'langChanged', payload: getEventPayload(lang), }); return this; } setAvailableLangs(langs) { this.availableLangs = langs; } /** * Gets the available languages. * * @returns * An array of the available languages. Can be either a `string[]` or a `{ id: string; label: string }[]` * depending on how the available languages are set in your module. */ getAvailableLangs() { return this.availableLangs; } load(path, options = {}) { // If the application has already been destroyed, return an empty observable. // We use EMPTY instead of NEVER to ensure the observable completes. // This is important for operators like switchMap, which rely on the inner observable completing // before they can subscribe to the next one. NEVER would hang the chain indefinitely. if (this.destroyed) { return EMPTY; } const cached = this.cache.get(path); if (cached) { return cached; } let loadTranslation; const isScope = this._isLangScoped(path); let scope; if (isScope) { scope = getScopeFromLang(path); } const loadersOptions = { path, mainLoader: this.loader, inlineLoader: options.inlineLoader, data: isScope ? { scope: scope } : undefined, }; if (this.useFallbackTranslation(path)) { // if the path is scope the fallback should be `scope/fallbackLang`; const fallback = isScope ? `${scope}/${this.firstFallbackLang}` : this.firstFallbackLang; const loaders = getFallbacksLoaders({ ...loadersOptions, fallbackPath: fallback, }); loadTranslation = forkJoin(loaders); } else { const loader = resolveLoader(loadersOptions); loadTranslation = from(loader); } const load$ = loadTranslation.pipe(retry(this.config.failedRetries), tap((translation) => { if (Array.isArray(translation)) { translation.forEach((t) => { this.handleSuccess(t.lang, t.translation); // Save the fallback in cache so we'll not create a redundant request if (t.lang !== path) { this.cache.set(t.lang, of({})); } }); return; } this.handleSuccess(path, translation); }), catchError((error) => { if (!this.config.prodMode) { console.error(`Error while trying to load "${path}"`, error); } return this.handleFailure(path, options); }), shareReplay(1), takeUntilDestroyed(this.destroyRef)); this.cache.set(path, load$); return load$; } /** * Gets the instant translated value of a key * * @example * * translate<string>('hello') * translate('hello', { value: 'value' }) * translate<string[]>(['hello', 'key']) * translate('hello', { }, 'en') * translate('scope.someKey', { }, 'en') */ translate(key, params = {}, lang = this.getActiveLang()) { if (!key) return key; const { scope, resolveLang } = this.resolveLangAndScope(lang); if (Array.isArray(key)) { return key.map((k) => this.translate(this.config.scopes.autoPrefixKeys && scope ? `${scope}.${k}` : k, params, resolveLang)); } key = this.config.scopes.autoPrefixKeys && scope ? `${scope}.${key}` : key; const translation = this.getTranslation(resolveLang); const value = translation[key]; if (!value) { return this._handleMissingKey(key, value, params); } return this.parser.transpile({ value, params, translation, key, }); } /** * Gets the translated value of a key as observable * * @example * * selectTranslate<string>('hello').subscribe(value => ...) * selectTranslate<string>('hello', {}, 'es').subscribe(value => ...) * selectTranslate<string>('hello', {}, 'todos').subscribe(value => ...) * selectTranslate<string>('hello', {}, { scope: 'todos' }).subscribe(value => ...) * */ selectTranslate(key, params, lang, _isObject = false) { let inlineLoader; const load = (lang, options) => this.load(lang, options).pipe(map(() => _isObject ? this.translateObject(key, params, lang) : this.translate(key, params, lang))); if (isNil(lang)) { return this.langChanges$.pipe(switchMap((lang) => load(lang))); } lang = Array.isArray(lang) ? lang[lang.length - 1] : lang; if (isScopeObject(lang)) { // it's a scope object. const providerScope = lang; lang = providerScope.scope; inlineLoader = resolveInlineLoader(providerScope, providerScope.scope); } lang = lang; if (this.isLang(lang) || this.isScopeWithLang(lang)) { return load(lang); } // it's a scope const scope = lang; return this.langChanges$.pipe(switchMap((lang) => load(`${scope}/${lang}`, { inlineLoader }))); } /** * Whether the scope with lang * * @example * * todos/en => true * todos => false */ isScopeWithLang(lang) { return this.isLang(getLangFromScope(lang)); } translateObject(key, params = {}, lang = this.getActiveLang()) { if (isString(key) || Array.isArray(key)) { const { resolveLang, scope } = this.resolveLangAndScope(lang); if (Array.isArray(key)) { return key.map((k) => this.translateObject(this.config.scopes.autoPrefixKeys && scope ? `${scope}.${k}` : k, params, resolveLang)); } const translation = this.getTranslation(resolveLang); key = this.config.scopes.autoPrefixKeys && scope ? `${scope}.${key}` : key; const value = unflatten(this.getObjectByKey(translation, key)); /* If an empty object was returned we want to try and translate the key as a string and not an object */ return isEmpty(value) ? this.translate(key, params, lang) : this.parser.transpile({ value, params: params, translation, key }); } const translations = []; for (const [_key, _params] of this.getEntries(key)) { translations.push(this.translateObject(_key, _params, lang)); } return translations; } selectTranslateObject(key, params, lang) { if (isString(key) || Array.isArray(key)) { return this.selectTranslate(key, params, lang, true); } const [[firstKey, firstParams], ...rest] = this.getEntries(key); /* In order to avoid subscribing multiple times to the load language event by calling selectTranslateObject for each pair, * we listen to when the first key has been translated (the language is loaded) and translate the rest synchronously */ return this.selectTranslateObject(firstKey, firstParams, lang).pipe(map((value) => { const translations = [value]; for (const [_key, _params] of rest) { translations.push(this.translateObject(_key, _params, lang)); } return translations; })); } getTranslation(langOrScope) { if (langOrScope) { if (this.isLang(langOrScope)) { return this.translations.get(langOrScope) || {}; } else { // This is a scope, build the scope value from the translation object const { scope, resolveLang } = this.resolveLangAndScope(langOrScope); const translation = this.translations.get(resolveLang) || {}; return this.getObjectByKey(translation, scope); } } return this.translations; } /** * Gets an object of translations for a given language * * @example * * selectTranslation().subscribe() - will return the current lang translation * selectTranslation('es').subscribe() * selectTranslation('admin-page').subscribe() - will return the current lang scope translation * selectTranslation('admin-page/es').subscribe() */ selectTranslation(lang) { let language$ = this.langChanges$; if (lang) { const scopeLangSpecified = getLangFromScope(lang) !== lang; if (this.isLang(lang) || scopeLangSpecified) { language$ = of(lang); } else { language$ = this.langChanges$.pipe(map((currentLang) => `${lang}/${currentLang}`)); } } return language$.pipe(switchMap((language) => this.load(language).pipe(map(() => this.getTranslation(language))))); } /** * Sets or merge a given translation object to current lang * * @example * * setTranslation({ ... }) * setTranslation({ ... }, 'en') * setTranslation({ ... }, 'es', { merge: false } ) * setTranslation({ ... }, 'todos/en', { merge: false } ) */ setTranslation(translation, lang = this.getActiveLang(), options = {}) { const defaults = { merge: true, emitChange: true }; const mergedOptions = { ...defaults, ...options }; const scope = getScopeFromLang(lang); /** * If this isn't a scope we use the whole translation as is * otherwise we need to flat the scope and use it */ let flattenScopeOrTranslation = translation; // Merged the scoped language into the active language if (scope) { const key = this.getMappedScope(scope); flattenScopeOrTranslation = flatten({ [key]: translation }); } const currentLang = scope ? getLangFromScope(lang) : lang; const mergedTranslation = { ...(mergedOptions.merge && this.getTranslation(currentLang)), ...flattenScopeOrTranslation, }; const flattenTranslation = this.config.flatten.aot ? mergedTranslation : flatten(mergedTranslation); const withHook = this.interceptor.preSaveTranslation(flattenTranslation, currentLang); this.translations.set(currentLang, withHook); mergedOptions.emitChange && this.setActiveLang(this.getActiveLang()); } /** * Sets translation key with given value * * @example * * setTranslationKey('key', 'value') * setTranslationKey('key.nested', 'value') * setTranslationKey('key.nested', 'value', 'en') * setTranslationKey('key.nested', 'value', 'en', { emitChange: false } ) */ setTranslationKey(key, value, options = {}) { const lang = options.lang || this.getActiveLang(); const withHook = this.interceptor.preSaveTranslationKey(key, value, lang); const newValue = { [key]: withHook, }; this.setTranslation(newValue, lang, { ...options, merge: true }); } /** * Sets the fallback lang for the currently active language * @param fallbackLang */ setFallbackLangForMissingTranslation({ fallbackLang, }) { const lang = Array.isArray(fallbackLang) ? fallbackLang[0] : fallbackLang; if (fallbackLang && this.useFallbackTranslation(lang)) { this.firstFallbackLang = lang; } } /** * @internal */ _handleMissingKey(key, value, params) { if (this.config.missingHandler.allowEmpty && value === '') { return ''; } if (!this.isResolvedMissingOnce && this.useFallbackTranslation()) { // We need to set it to true to prevent a loop this.isResolvedMissingOnce = true; const fallbackValue = this.translate(key, params, this.firstFallbackLang); this.isResolvedMissingOnce = false; return fallbackValue; } return this.missingHandler.handle(key, this.getMissingHandlerData(), params); } /** * @internal */ _isLangScoped(lang) { return this.getAvailableLangsIds().indexOf(lang) === -1; } /** * Checks if a given string is one of the specified available languages. * @returns * True if the given string is an available language. * False if the given string is not an available language. */ isLang(lang) { return this.getAvailableLangsIds().indexOf(lang) !== -1; } /** * @internal * * We always want to make sure the global lang is loaded * before loading the scope since you can access both via the pipe/directive. */ _loadDependencies(path, inlineLoader) { const mainLang = getLangFromScope(path); if (this._isLangScoped(path) && !this.isLoadedTranslation(mainLang)) { return combineLatest([ this.load(mainLang), this.load(path, { inlineLoader }), ]); } return this.load(path, { inlineLoader }); } /** * @internal */ _completeScopeWithLang(langOrScope) { if (this._isLangScoped(langOrScope) && !this.isLang(getLangFromScope(langOrScope))) { return `${langOrScope}/${this.getActiveLang()}`; } return langOrScope; } /** * @internal */ _setScopeAlias(scope, alias) { if (!this.config.scopeMapping) { this.config.scopeMapping = {}; } this.config.scopeMapping[scope] = alias; } isLoadedTranslation(lang) { return size(this.getTranslation(lang)); } getAvailableLangsIds() { const first = this.getAvailableLangs()[0]; if (isString(first)) { return this.getAvailableLangs(); } return this.getAvailableLangs().map((l) => l.id); } getMissingHandlerData() { return { ...this.config, activeLang: this.getActiveLang(), availableLangs: this.availableLangs, defaultLang: this.defaultLang, }; } /** * Use a fallback translation set for missing keys of the primary language * This is unrelated to the fallback language (which changes the active language) */ useFallbackTranslation(lang) { return (this.config.missingHandler.useFallbackTranslation && lang !== this.firstFallbackLang); } handleSuccess(lang, translation) { this.setTranslation(translation, lang, { emitChange: false }); this.events.next({ wasFailure: !!this.failedLangs.size, type: 'translationLoadSuccess', payload: getEventPayload(lang), }); this.failedLangs.forEach((l) => this.cache.delete(l)); this.failedLangs.clear(); } handleFailure(lang, loadOptions) { // When starting to load a first choice language, initialize // the failed counter and resolve the fallback langs. if (isNil(loadOptions.failedCounter)) { loadOptions.failedCounter = 0; if (!loadOptions.fallbackLangs) { loadOptions.fallbackLangs = this.fallbackStrategy.getNextLangs(lang); } } const splitted = lang.split('/'); const fallbacks = loadOptions.fallbackLangs; const nextLang = fallbacks[loadOptions.failedCounter]; this.failedLangs.add(lang); // This handles the case where a loaded fallback language is requested again if (this.cache.has(nextLang)) { this.handleSuccess(nextLang, this.getTranslation(nextLang)); return EMPTY; } const isFallbackLang = nextLang === splitted[splitted.length - 1]; if (!nextLang || isFallbackLang) { throw new TranslationLoadError(lang, fallbacks ?? [], splitted.length > 1); } let resolveLang = nextLang; // if it's scoped lang if (splitted.length > 1) { // We need to resolve it to: // todos/langNotExists => todos/nextLang splitted[splitted.length - 1] = nextLang; resolveLang = splitted.join('/'); } loadOptions.failedCounter++; this.events.next({ type: 'translationLoadFailure', payload: getEventPayload(lang), }); return this.load(resolveLang, loadOptions); } getMappedScope(scope) { const { scopeMapping = {}, scopes = { keepCasing: false } } = this.config; return (scopeMapping[scope] || (scopes.keepCasing ? scope : toCamelCase(scope))); } /** * If lang is scope we need to check the following cases: * todos/es => in this case we should take `es` as lang * todos => in this case we should set the active lang as lang */ resolveLangAndScope(lang) { let resolveLang = lang; let scope; if (this._isLangScoped(lang)) { // en for example const langFromScope = getLangFromScope(lang); // en is lang const hasLang = this.isLang(langFromScope); // take en resolveLang = hasLang ? langFromScope : this.getActiveLang(); // find the scope scope = this.getMappedScope(hasLang ? getScopeFromLang(lang) : lang); } return { scope, resolveLang }; } getObjectByKey(translation, key) { const result = {}; const prefix = `${key}.`; for (const currentKey in translation) { if (currentKey.startsWith(prefix)) { result[currentKey.replace(prefix, '')] = translation[currentKey]; } } return result; } getEntries(key) { return key instanceof Map ? key.entries() : Object.entries(key); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: TranslocoService, deps: [{ token: TRANSLOCO_LOADER, optional: true }, { token: TRANSLOCO_TRANSPILER }, { token: TRANSLOCO_MISSING_HANDLER }, { token: TRANSLOCO_INTERCEPTOR }, { token: TRANSLOCO_CONFIG }, { token: TRANSLOCO_FALLBACK_STRATEGY }], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: TranslocoService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: TranslocoService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [TRANSLOCO_LOADER] }] }, { type: undefined, decorators: [{ type: Inject, args: [TRANSLOCO_TRANSPILER] }] }, { type: undefined, decorators: [{ type: Inject, args: [TRANSLOCO_MISSING_HANDLER] }] }, { type: undefined, decorators: [{ type: Inject, args: [TRANSLOCO_INTERCEPTOR] }] }, { type: undefined, decorators: [{ type: Inject, args: [TRANSLOCO_CONFIG] }] }, { type: undefined, decorators: [{ type: Inject, args: [TRANSLOCO_FALLBACK_STRATEGY] }] }] }); class TranslocoLoaderComponent { html; static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: TranslocoLoaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.9", type: TranslocoLoaderComponent, isStandalone: true, selector: "ng-component", inputs: { html: "html" }, ngImport: i0, template: ` <div class="transloco-loader-template" [innerHTML]="html"></div> `, isInline: true }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: TranslocoLoaderComponent, decorators: [{ type: Component, args: [{ template: ` <div class="transloco-loader-template" [innerHTML]="html"></div> `, standalone: true, }] }], propDecorators: { html: [{ type: Input }] } }); class TemplateHandler { view; vcr; constructor(view, vcr) { this.view = view; this.vcr = vcr; } attachView() { if (this.view instanceof TemplateRef) { this.vcr.createEmbeddedView(this.view); } else if (isString(this.view)) { const componentRef = this.vcr.createComponent(TranslocoLoaderComponent); componentRef.instance.html = this.view; componentRef.hostView.detectChanges(); } else { this.vcr.createComponent(this.view); } } detachView() { this.vcr.clear(); } } const TRANSLOCO_LANG = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_LANG' : ''); const TRANSLOCO_LOADING_TEMPLATE = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_LOADING_TEMPLATE' : ''); const TRANSLOCO_SCOPE = /* @__PURE__ */ new InjectionToken(ngDevMode ? 'TRANSLOCO_SCOPE' : ''); /** * @example * * getPipeValue('todos|scoped', 'scoped') [true, 'todos'] * getPipeValue('en|static', 'static') [true, 'en'] * getPipeValue('en', 'static') [false, 'en'] */ function getPipeValue(str, value, char = '|') { if (isString(str)) { const splitted = str.split(char); const lastItem = splitted.pop(); return lastItem === value ? [true, splitted.toString()] : [false, lastItem]; } return [false, '']; } function shouldListenToLangChanges(service, lang) { const [hasStatic] = getPipeValue(lang, 'static'); if (!hasStatic) { // If we didn't get 'lang|static' check if it's set in the global level return !!service.config.reRenderOnLangChange; } // We have 'lang|static' so don't listen to lang changes return false; } function listenOrNotOperator(listenToLangChange) { return listenToLangChange ? (source) => source : take(1); } class LangResolver { initialized = false; // inline => provider => active resolve({ inline, provider, active }) { let lang = active; /** * When the user changes the lang we need to update * the view. Otherwise, the lang will remain the inline/provided lang */ if (this.initialized) { lang = active; return lang; } if (provider) { const [, extracted] = getPipeValue(provider, 'static'); lang = extracted; } if (inline) { const [, extracted] = getPipeValue(inline, 'static'); lang = extracted; } this.initialized = true; return lang; } /** * * Resolve the lang * * @example * * resolveLangBasedOnScope('todos/en') => en * resolveLangBasedOnScope('en') => en * */ resolveLangBasedOnScope(lang) { const scope = getScopeFromLang(lang); return scope ? getLangFromScope(lang) : lang; } /** * * Resolve the lang path for loading * * @example * * resolveLangPath('todos', 'en') => todos/en * resolveLangPath('en') => en * */ resolveLangPath(lang, scope) { return scope ? `${scope}/${lang}` : lang; } } class ScopeResolver { service; constructor(service) { this.service = service; } // inline => provider resolve(params) { const { inline, provider } = params; if (inline) { return inline; } if (provider) { if (isScopeObject(provider)) { const { scope, alias = this.service.config.scopes.keepCasing ? scope : toCamelCase(scope), } = provider; this.service._setScopeAlias(scope, alias); return scope; } return provider; } return undefined; } } class TranslocoDirective { destroyRef = inject(DestroyRef); service = inject(TranslocoService); tpl = inject(TemplateRef, { optional: true, }); providerLang = inject(TRANSLOCO_LANG, { optional: true }); providerScope = inject(TRANSLOCO_SCOPE, { optional: true }); providedLoadingTpl = inject(TRANSLOCO_LOADING_TEMPLATE, { optional: true, }); cdr = inject(ChangeDetectorRef); host = inject(ElementRef); vcr = inject(ViewContainerRef); renderer = inject(Renderer2); view; memo = new Map(); key; params = {}; inlineScope; /** @deprecated use prefix instead, will be removed in Transloco v9 */ inlineRead; prefix; inlineLang; inlineTpl; currentLang; loaderTplHandler; // Whether we already rendered the view once initialized = false; path; langResolver = new LangResolver(); scopeResolver = new ScopeResolver(this.service); strategy = this.tpl === null ? 'attribute' : 'structural'; static ngTemplateContextGuard(dir, ctx) { return true; } ngOnInit() { const listenToLangChange = shouldListenToLangChanges(this.service, this.providerLang || this.inlineLang); this.service.langChanges$ .pipe(switchMap((activeLang) => { const lang = this.langResolver.resolve({ inline: this.inlineLang, provider: this.providerLang, active: activeLang, }); return Array.isArray(this.providerScope) ? forkJoin(this.providerScope.map((providerScope) => this.resolveScope(lang, providerScope))) : this.resolveScope(lang, this.providerScope); }), listenOrNotOperator(listenToLangChange), takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.currentLang = this.langResolver.resolveLangBasedOnScope(this.path); this.strategy === 'attribute' ? this.attributeStrategy() : this.structuralStrategy(this.currentLang, this.prefix || this.inlineRead); this.cdr.markForCheck(); this.initialized = true; }); if (!this.initialized) { const loadingContent = this.resolveLoadingContent(); if (loadingContent) { this.loaderTplHandler = new TemplateHandler(loadingContent, this.vcr); this.loaderTplHandler.attachView(); } } } ngOnChanges(changes) { // We need to support dynamic keys/params, so if this is not the first change CD cycle // we need to run the function again in order to update the value if (this.strategy === 'attribute') { const notInit = Object.keys(changes).some((v) => !changes[v].firstChange); notInit && this.attributeStrategy(); } } attributeStrategy() { this.detachLoader(); this.renderer.setProperty(this.host.nativeElement, 'innerText', this.service.translate(this.key, this.params, this.currentLang)); } structuralStrategy(lang, prefix) { this.memo.clear(); const translateFn = this.getTranslateFn(lang, prefix); if (this.view) { // when the lang changes we need to change the reference so Angular will update the view this.view.context['$implicit'] = translateFn; this.view.context['currentLang'] = this.currentLang; } else { this.detachLoader(); this.view = this.vcr.createEmbeddedView(this.tpl, { $implicit: translateFn, currentLang: this.currentLang, }); } } getTranslateFn(lang, prefix) { return (key, params) => { const withPrefix = prefix ? `${prefix}.${key}` : key; const memoKey = params ? `${withPrefix}${JSON.stringify(params)}` : withPrefix; if (!this.memo.has(memoKey)) { this.memo.set(memoKey, this.service.translate(withPrefix, params, lang)); } return this.memo.get(memoKey); }; } resolveLoadingContent() { return this.inlineTpl || this.providedLoadingTpl; } ngOnDestroy() { this.memo.clear(); } detachLoader() { this.loaderTplHandler?.detachView(); } resolveScope(lang, providerScope) { const resolvedScope = this.scopeResolver.resolve({ inline: this.inlineScope, provider: providerScope, }); this.path = this.langResolver.resolveLangPath(lang, resolvedScope); const inlineLoader = resolveInlineLoader(providerScope, resolvedScope); return this.service._loadDependencies(this.path, inlineLoader); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: TranslocoDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "18.2.9", type: TranslocoDirective, isStandalone: true, selector: "[transloco]", inputs: { key: ["transloco", "key"], params: ["translocoParams", "params"], inlineScope: ["translocoScope", "inlineScope"], inlineRead: ["translocoRead", "inlineRead"], prefix: ["translocoPrefix", "prefix"], inlineLang: ["translocoLang", "inlineLang"], inlineTpl: ["translocoLoadingTpl", "inlineTpl"] }, usesOnChanges: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.9", ngImport: i0, type: TranslocoDirective, decorators: [{ type: Directive, args: [{ selector: '[transloco]', standalone: true, }] }], propDecorators: { key: [{ type: Input, args: ['transloco'] }], params: [{ type: Input, args: ['translocoParams'] }], inlineScope: [{ type: Input, args: ['translocoScope'] }], inlineRead: [{ type: Input, args: ['translocoRead'] }], prefix: [{ type: Input, args: ['translocoPrefix'] }], inlineLang: [{ type: Input, args: ['translocoLang'] }], inlineTpl: [{ type: Input, args: ['translocoLoadingTpl'] }] } }); class TranslocoPipe { service; providerScope; providerLang; cdr; subscription = null; lastValue = ''; lastKey; path; langResolver = new LangResolver(); scopeResolver; constructor(service, providerScope, providerLang, cdr) { this.service = service; this.providerScope = providerScope; this.providerLang = providerLang; this.cdr = cdr; this.scopeResolver = new ScopeResolver(this.service); } // null is for handling strict mode + async pipe types https://github.com/jsverse/transloco/issues/311 // null is for handling strict mode + optional chaining types https://github.com/jsverse/transloco/issues/488 transform(key, params, inlineLang) { if (!key) { return key; } const keyName = params ? `${key}${JSON.stringify(params)}` : key; if (keyName === this.lastKey) { return this.lastValue; } this.lastKey = keyName; this.subscription?.unsubscribe(); const listenToLangChange = shouldListenToLan