@jsverse/transloco
Version:
The internationalization (i18n) library for Angular
1,347 lines (1,324 loc) • 64.8 kB
JavaScript
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