UNPKG

@o3r/localization

Version:

This module provides a runtime dynamic language/translation support and debug tools.

1,061 lines (1,036 loc) 57.4 kB
import { immutablePrimitive, deepFill, otterComponentInfoPropertyName, sendOtterMessage, filterMessageContent } from '@o3r/core'; import * as i0 from '@angular/core'; import { InjectionToken, inject, Injectable, NgModule, ElementRef, ChangeDetectorRef, Input, Directive, Pipe, RendererFactory2, Optional, LOCALE_ID, Injector, ApplicationRef, DestroyRef } from '@angular/core'; import { TranslateCompiler, TranslateService, TranslateDirective, TranslatePipe, TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { IntlMessageFormat } from 'intl-messageformat'; import { BehaviorSubject, combineLatest, firstValueFrom, of, from, lastValueFrom, fromEvent } from 'rxjs'; import * as i1 from '@ngrx/store'; import { createAction, props, on, createReducer, StoreModule, createFeatureSelector, createSelector, Store, select } from '@ngrx/store'; import { LoggerService } from '@o3r/logger'; import { startWith, switchMap, map, distinctUntilChanged, shareReplay, catchError } from 'rxjs/operators'; import { Directionality, DIR_DOCUMENT, BidiModule } from '@angular/cdk/bidi'; import { CurrencyPipe, DatePipe, DecimalPipe, CommonModule } from '@angular/common'; import { DynamicContentModule, DynamicContentService } from '@o3r/dynamic-content'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; /** * Decorator to pass localization url * @param _url */ // eslint-disable-next-line @typescript-eslint/naming-convention -- decorator should start with a capital letter function Localization(_url) { return (target, key) => { const privateField = _url || `_${key}`; const privateValue = target[key]; if (delete target[key]) { Object.defineProperty(target, key, { get: function () { return this[privateField]; }, set: function (value) { const currentField = this[privateField] || privateValue; this[privateField] = typeof currentField === 'undefined' ? immutablePrimitive(value) : deepFill(currentField, value); if (this[otterComponentInfoPropertyName]) { this[otterComponentInfoPropertyName].translations = this[privateField]; } }, enumerable: true, configurable: true }); } }; } /** * Default configuration for LocalizationModule */ const DEFAULT_LOCALIZATION_CONFIGURATION = { supportedLocales: [], endPointUrl: '', useDynamicContent: false, rtlLanguages: ['ar', 'he'], fallbackLanguage: 'en', bundlesOutputPath: '', debugMode: false, enableTranslationDeactivation: false, mergeWithLocalTranslations: false }; /** * Message format configuration default value */ const lazyMessageDefaultConfig = { enableCache: true, ignoreTag: true }; /** Message Format configuration Token */ const MESSAGE_FORMAT_CONFIG = new InjectionToken('Message Format configuration'); /** * This compiler expects ICU syntax and compiles the expressions with messageformat.js * Compare to ngx-translate-messageformat-compiler package, the compilation of the translation is done only on demand */ class TranslateMessageFormatLazyCompiler extends TranslateCompiler { constructor() { const config = inject(MESSAGE_FORMAT_CONFIG, { optional: true }); super(); /** Cache of compiled translations */ this.cache = {}; this.config = config ? { ...lazyMessageDefaultConfig, ...config } : lazyMessageDefaultConfig; } /** * Clear the cache of the compiled translations */ clearCache() { this.cache = {}; } /** @inheritDoc */ compile(value, lang) { return (params) => new IntlMessageFormat(value, lang, undefined, this.config).format(params); } /** @inheritDoc */ compileTranslations(translations, lang) { const compilingStrategy = this.config.enableCache ? (acc, key) => { acc[key] = (params) => { const cached = this.cache[`${lang}_${key}`]; if (cached) { return cached.format(params); } const newCachedItem = new IntlMessageFormat(translations[key], lang, undefined, this.config); this.cache[`${lang}_${key}`] = newCachedItem; return newCachedItem.format(params); }; return acc; } : (acc, key) => { acc[key] = (params) => new IntlMessageFormat(translations[key], lang, undefined, this.config).format(params); return acc; }; return Object.keys(translations).reduce((acc, key) => compilingStrategy(acc, key), {}); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TranslateMessageFormatLazyCompiler, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TranslateMessageFormatLazyCompiler }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TranslateMessageFormatLazyCompiler, decorators: [{ type: Injectable }], ctorParameters: () => [] }); /** Actions */ const ACTION_SET = '[LocalizationOverride] set'; /** * Clear all overrides and fill the store with the payload */ const setLocalizationOverride = createAction(ACTION_SET, props()); /** * LocalizationOverride Store initial value */ const localizationOverrideInitialState = { localizationOverrides: {} }; /** * List of basic actions for LocalizationOverride Store */ const localizationOverrideReducerFeatures = [ on(setLocalizationOverride, (_state, payload) => ({ ...payload.state })) ]; /** * LocalizationOverride Store reducer */ const localizationOverrideReducer = createReducer(localizationOverrideInitialState, ...localizationOverrideReducerFeatures); /** * Name of the LocalizationOverride Store */ const LOCALIZATION_OVERRIDE_STORE_NAME = 'localizationOverride'; /** Token of the LocalizationOverride reducer */ const LOCALIZATION_OVERRIDE_REDUCER_TOKEN = new InjectionToken('Feature LocalizationOverride Reducer'); /** Provide default reducer for LocalizationOverride store */ function getDefaultLocalizationOverrideReducer() { return localizationOverrideReducer; } class LocalizationOverrideStoreModule { static forRoot(reducerFactory) { return { ngModule: LocalizationOverrideStoreModule, providers: [ { provide: LOCALIZATION_OVERRIDE_REDUCER_TOKEN, useFactory: reducerFactory } ] }; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationOverrideStoreModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } /** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: LocalizationOverrideStoreModule, imports: [i1.StoreFeatureModule] }); } /** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationOverrideStoreModule, providers: [ { provide: LOCALIZATION_OVERRIDE_REDUCER_TOKEN, useFactory: getDefaultLocalizationOverrideReducer } ], imports: [StoreModule.forFeature(LOCALIZATION_OVERRIDE_STORE_NAME, LOCALIZATION_OVERRIDE_REDUCER_TOKEN)] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationOverrideStoreModule, decorators: [{ type: NgModule, args: [{ imports: [ StoreModule.forFeature(LOCALIZATION_OVERRIDE_STORE_NAME, LOCALIZATION_OVERRIDE_REDUCER_TOKEN) ], providers: [ { provide: LOCALIZATION_OVERRIDE_REDUCER_TOKEN, useFactory: getDefaultLocalizationOverrideReducer } ] }] }] }); /** Select LocalizationOverride State */ const selectLocalizationOverrideState = createFeatureSelector(LOCALIZATION_OVERRIDE_STORE_NAME); /** Select all localization override map */ const selectLocalizationOverride = createSelector(selectLocalizationOverrideState, (state) => state?.localizationOverrides || {}); const localizationOverrideStorageDeserializer = (rawObject) => { if (!rawObject) { return localizationOverrideInitialState; } return rawObject; }; const localizationOverrideStorageSync = { deserialize: localizationOverrideStorageDeserializer }; /** Localization Configuration Token */ const LOCALIZATION_CONFIGURATION_TOKEN = new InjectionToken('Localization Configuration injection token'); /** * Service which is wrapping the configuration logic of TranslateService from ngx-translate * Any application willing to use localization just needs to inject LocalizationService * in the root component and call its configure() method. */ class LocalizationService { constructor() { this.translateService = inject(TranslateService); this.logger = inject(LoggerService); this.configuration = inject(LOCALIZATION_CONFIGURATION_TOKEN); this.store = inject(Store, { optional: true }); this.localeSplitIdentifier = '-'; /** * Internal subject that we use to track changes between keys only and translation mode */ this._showKeys$ = new BehaviorSubject(false); /** * _showKeys$ exposed as an Observable */ this.showKeys$ = this._showKeys$.asObservable(); void this.configure(); if (this.store) { this.keyMapping$ = this.store.pipe(select(selectLocalizationOverride)); } } /** * This will handle the fallback language hierarchy to find out fallback language. * supportedLocales language has highest priority, next priority goes to fallbackLocalesMap and default would be * fallbackLanguage. * @param language Selected language. * @returns selected language if supported, fallback language otherwise. */ checkFallbackLocalesMap(language) { if (language && !this.configuration.supportedLocales.includes(language)) { const closestSupportedLanguageCode = this.getFirstClosestSupportedLanguageCode(language); const fallbackForLanguage = this.getFallbackMapLangCode(language); const fallbackStrategyDebug = (fallbackForLanguage && ' associated fallback language ') || (closestSupportedLanguageCode && ' closest supported language ') || (this.configuration.fallbackLanguage && ' configured default language '); const fallbackLang = fallbackForLanguage || closestSupportedLanguageCode || this.configuration.fallbackLanguage || language; if (language !== fallbackLang) { this.logger.debug(`Non supported languages ${language} will fallback to ${fallbackStrategyDebug} ${fallbackLang}`); } return fallbackLang; } else if (!language) { this.logger.debug('Language is not defined'); } return language; } /** * This function checks if fallback language can be provided from fallbackLocalesMap. * supportedLocales: ['en-GB', 'en-US', 'fr-FR'], fallbackLocalesMap: {'en-CA': 'en-US', 'de': 'fr-FR'} * translate to en-CA -> fallback to en-US, translate to de-DE -> fallback to fr-FR * translate to en-NZ -> fallback to en-GB * @param language Selected language. * @returns Fallback language if available, undefined otherwise. */ getFallbackMapLangCode(language) { const fallbackLocalesMap = this.configuration.fallbackLocalesMap; const [locale] = language.split(this.localeSplitIdentifier); return fallbackLocalesMap && (fallbackLocalesMap[language] || fallbackLocalesMap[locale]); } /** * This function checks if closest supported language available incase of selected language is not * supported language. * supportedLocales: ['en-GB', 'en-US', 'fr-FR'] * translate to en-CA -> fallback to en-GB * @param language Selected language. * @returns Closest supported language if available, undefined otherwise. */ getFirstClosestSupportedLanguageCode(language) { const [locale] = language.split(this.localeSplitIdentifier); const firstClosestRegx = new RegExp(`^${locale}${this.localeSplitIdentifier}?`, 'i'); return this.configuration.supportedLocales.find((supportedLang) => firstClosestRegx.test(supportedLang)); } /** * Returns a stream of translated values of a key which updates whenever the language changes. * @param translationKey Key to translate * @param interpolateParams Object to use in translation binding * @returns A stream of the translated key */ getTranslationStream(translationKey, interpolateParams) { const translation$ = this.translateService.onTranslationChange.pipe(startWith(undefined), switchMap(() => this.translateService.stream(translationKey, interpolateParams)), map((value) => this.configuration.debugMode ? `${translationKey} - ${value}` : value), distinctUntilChanged()); if (!this.configuration.enableTranslationDeactivation) { return translation$; } return combineLatest([ translation$, this.showKeys$ ]).pipe(map(([value, showKeys]) => showKeys ? translationKey : value)); } /** * Configures TranslateService and registers locales. This method is called from the application level. */ async configure() { const language = this.checkFallbackLocalesMap(this.configuration.language || this.configuration.fallbackLanguage); this.translateService.addLangs(this.configuration.supportedLocales); this.translateService.setDefaultLang(language); await firstValueFrom(this.useLanguage(language)); } /** * Is the translation deactivation enabled */ isTranslationDeactivationEnabled() { return this.configuration.enableTranslationDeactivation; } /** * Wrapper to call the ngx-translate service TranslateService method getLangs(). */ getLanguages() { return this.translateService.getLangs(); } /** * Wrapper to call the ngx-translate service TranslateService method use(language). * @param language */ useLanguage(language) { language = this.checkFallbackLocalesMap(language); return this.translateService.use(language); } /** * Wrapper to get the ngx-translate service TranslateService currentLang. */ getCurrentLanguage() { return this.translateService.currentLang; } /** * Get the instance of the ngx-translate TranslateService used by LocalizationService. */ getTranslateService() { return this.translateService; } /** * Toggle the ShowKeys mode between active and inactive. * @param value if specified, set the ShowKeys mode to value. If not specified, toggle the ShowKeys mode. */ toggleShowKeys(value) { if (!this.configuration.enableTranslationDeactivation) { throw new Error('Translation deactivation is not enabled. Please set the LocalizationConfiguration property "enableTranslationDeactivation" accordingly.'); } const newValue = value === undefined ? !this.showKeys : value; this._showKeys$.next(newValue); } /** * Return the current value of debug show/hide translation keys. */ get showKeys() { return this._showKeys$.value; } /** * Get an observable of translation key after global mapping * @param requestedKey Original translation key */ getKey(requestedKey) { return this.keyMapping$ ? this.keyMapping$.pipe(map((keyMapping) => keyMapping?.[requestedKey] || requestedKey), distinctUntilChanged()) : of(requestedKey); } /** * Returns a stream of translated values of a key which updates whenever the language changes. * @param key Key to translate * @param interpolateParams Object to use in translation binding * @returns A stream of the translated key */ translate(key, interpolateParams) { return this.getKey(key).pipe(switchMap((translationKey) => this.getTranslationStream(translationKey, interpolateParams)), shareReplay({ refCount: true, bufferSize: 1 })); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationService, decorators: [{ type: Injectable }], ctorParameters: () => [] }); /** * TranslateDirective class adding debug functionality */ class LocalizationTranslateDirective extends TranslateDirective { /** @inheritdoc */ set translate(key) { if (key && key !== this.key) { if (this.onKeyChange) { this.onKeyChange.unsubscribe(); } this.onKeyChange = this.localizationService.getKey(key).subscribe((newKey) => { this.key = newKey; this.checkNodes(); }); } } constructor() { const translateService = inject(TranslateService); const element = inject(ElementRef); const ref = inject(ChangeDetectorRef); super(translateService, element, ref); this.localizationService = inject(LocalizationService); this.localizationConfig = inject(LOCALIZATION_CONFIGURATION_TOKEN); /** * Should we display keys instead of translations */ this.showKeys = false; const localizationService = this.localizationService; const localizationConfig = this.localizationConfig; if (localizationConfig.enableTranslationDeactivation) { this.onShowKeysChange = localizationService.showKeys$.subscribe((showKeys) => { this.showKeys = showKeys; this.checkNodes(true); }); } } /** * Overriding parent's setContent to plug debugging feature * @param node * @param content */ setContent(node, content) { const key = node.originalContent; const newContent = this.showKeys ? key : (this.localizationConfig.debugMode && key ? `${key} - ${content}` : content); if (typeof node.textContent !== 'undefined' && node.textContent !== null) { node.textContent = newContent; } else { node.data = newContent; } } ngOnDestroy() { super.ngOnDestroy(); if (this.onShowKeysChange) { this.onShowKeysChange.unsubscribe(); } if (this.onKeyChange) { this.onKeyChange?.unsubscribe(); } } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationTranslateDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.3.7", type: LocalizationTranslateDirective, isStandalone: false, selector: "[translate],[ngx-translate]", inputs: { translate: "translate" }, usesInheritance: true, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationTranslateDirective, decorators: [{ type: Directive, args: [{ selector: '[translate],[ngx-translate]', standalone: false }] }], ctorParameters: () => [], propDecorators: { translate: [{ type: Input }] } }); /** * TranslatePipe class adding debug functionality */ class O3rLocalizationTranslatePipe extends TranslatePipe { constructor() { super(inject(TranslateService), inject(ChangeDetectorRef)); /** Localization service instance */ this.localizationService = inject(LocalizationService); /** Change detector service instance */ this.changeDetector = inject(ChangeDetectorRef); /** Localization config token */ this.localizationConfig = inject(LOCALIZATION_CONFIGURATION_TOKEN); /** * Should we display keys instead of translations */ this.showKeys = false; if (this.localizationConfig.enableTranslationDeactivation) { this.onShowKeysChange = this.localizationService.showKeys$.subscribe((showKeys) => { this.showKeys = showKeys; this.changeDetector.markForCheck(); }); } } /** * Calls original transform method and eventually outputs the key if debugMode (in LocalizationConfiguration) is enabled * @inheritdoc */ transform(query, ...args) { if (this.showKeys) { return query; } if (query !== this.lastQueryKey) { this.lastQueryKey = query; if (this.onKeyChange) { this.onKeyChange.unsubscribe(); } this.onKeyChange = this.localizationService.getKey(query).subscribe((key) => { this.lastResolvedKey = key; this.changeDetector.markForCheck(); }); } if (this.lastResolvedKey) { const value = super.transform(this.lastResolvedKey, ...args); if (this.localizationConfig.debugMode) { return `${this.lastResolvedKey} - ${value}`; } return value; } return this.value; } ngOnDestroy() { super.ngOnDestroy(); if (this.onShowKeysChange) { this.onShowKeysChange.unsubscribe(); } if (this.onKeyChange) { this.onKeyChange.unsubscribe(); } } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: O3rLocalizationTranslatePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } /** @nocollapse */ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: O3rLocalizationTranslatePipe, isStandalone: false, name: "o3rTranslate", pure: false }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: O3rLocalizationTranslatePipe, decorators: [{ type: Pipe, args: [{ name: 'o3rTranslate', pure: false, standalone: false }] }], ctorParameters: () => [] }); /** * Native angular CurrencyPipe taking the current lang into consideration */ class LocalizedCurrencyPipe extends CurrencyPipe { constructor() { super(inject(LocalizationService).getCurrentLanguage()); this.localizationService = inject(LocalizationService); this.changeDetectorRef = inject(ChangeDetectorRef); this.onLangChange = this.localizationService.getTranslateService().onLangChange.subscribe(() => this.changeDetectorRef.markForCheck()); } transform(value, currencyCode, display, digitsInfo, locale) { return super.transform(value, currencyCode, display, digitsInfo, locale || this.localizationService.getCurrentLanguage()); } ngOnDestroy() { this.onLangChange.unsubscribe(); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizedCurrencyPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } /** @nocollapse */ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: LocalizedCurrencyPipe, isStandalone: false, name: "currency", pure: false }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizedCurrencyPipe, decorators: [{ type: Pipe, args: [{ name: 'currency', pure: false, standalone: false }] }], ctorParameters: () => [] }); /** * Native angular DatePipe taking the current lang into consideration */ class LocalizedDatePipe extends DatePipe { constructor() { super(inject(LocalizationService).getCurrentLanguage()); this.localizationService = inject(LocalizationService); this.changeDetectorRef = inject(ChangeDetectorRef); this.onLangChange = this.localizationService.getTranslateService().onLangChange.subscribe(() => this.changeDetectorRef.markForCheck()); } transform(value, format = 'mediumDate', timezone, locale) { return this.localizationService.showKeys ? format : super.transform(value, format, timezone, locale || this.localizationService.getCurrentLanguage()); } ngOnDestroy() { this.onLangChange.unsubscribe(); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizedDatePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } /** @nocollapse */ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: LocalizedDatePipe, isStandalone: false, name: "date", pure: false }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizedDatePipe, decorators: [{ type: Pipe, args: [{ name: 'date', pure: false, standalone: false }] }], ctorParameters: () => [] }); /** * Native angular DecimalPipe taking the current lang into consideration */ class LocalizedDecimalPipe extends DecimalPipe { constructor() { super(inject(LocalizationService).getCurrentLanguage()); this.localizationService = inject(LocalizationService); this.changeDetectorRef = inject(ChangeDetectorRef); this.onLangChange = this.localizationService.getTranslateService().onLangChange.subscribe(() => this.changeDetectorRef.markForCheck()); } transform(value, digitsInfo, locale) { return super.transform(value, digitsInfo, locale || this.localizationService.getCurrentLanguage()); } ngOnDestroy() { this.onLangChange.unsubscribe(); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizedDecimalPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } /** @nocollapse */ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: LocalizedDecimalPipe, isStandalone: false, name: "decimal", pure: false }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizedDecimalPipe, decorators: [{ type: Pipe, args: [{ name: 'decimal', pure: false, standalone: false }] }], ctorParameters: () => [] }); /** * Service for handling the text direction based on the LocalizationConfiguration */ class TextDirectionService { constructor() { this.translateService = inject(TranslateService); this.configuration = inject(LOCALIZATION_CONFIGURATION_TOKEN); this.rendererFactory = inject(RendererFactory2); this.directionality = inject(Directionality); this.renderer = this.rendererFactory.createRenderer(null, null); } /** * Updates the dir attribute on body HTML tag. * @returns a subscription that updates the dir attribute */ onLangChangeSubscription() { if (this.subscription && !this.subscription.closed) { return this.subscription; } this.subscription = this.translateService.onLangChange.subscribe((event) => { const direction = this.configuration.rtlLanguages.includes(event.lang.split('-')[0]) ? 'rtl' : 'ltr'; this.renderer.setAttribute(document.body, 'dir', direction); this.directionality.change.emit(direction); }); return this.subscription; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TextDirectionService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TextDirectionService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TextDirectionService, decorators: [{ type: Injectable }], ctorParameters: () => [] }); /** * @deprecated The value of Directionality is no longer readonly and can be updated, this class will be removed in v16 */ class TextDirectionality extends Directionality { get value() { return this._value; } set value(value) { this._value = value; } constructor() { super(inject(DIR_DOCUMENT, { optional: true })); /** * The current 'ltr' or 'rtl' value. * @override */ this._value = 'ltr'; this.change .pipe(startWith(this._value)) .subscribe((value) => this._value = value); } ngOnDestroy() { this.change.complete(); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TextDirectionality, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TextDirectionality }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TextDirectionality, decorators: [{ type: Injectable }], ctorParameters: () => [] }); /** * creates LocalizationConfiguration, which is used if the application * @param configuration Localization configuration */ function createLocalizationConfiguration(configuration) { return { ...DEFAULT_LOCALIZATION_CONFIGURATION, ...configuration }; } /** * Factory to inject the LOCALE_ID token with the current language into Angular context * @param localizationService Localization service */ function localeIdNgBridge(localizationService) { return localizationService.getCurrentLanguage(); } /** Custom Localization Configuration Token to override default localization configuration */ const CUSTOM_LOCALIZATION_CONFIGURATION_TOKEN = new InjectionToken('Partial Localization configuration'); class LocalizationModule { /** * forRoot method should be called only once from the application index.ts * It will do several things: * - provide the configuration for the whole application * - register all locales specified in the LocalizationConfiguration * - configure TranslateService * - inject LOCALE_ID token * @param configuration LocalizationConfiguration */ static forRoot(configuration) { return { ngModule: LocalizationModule, providers: [ LocalizationService, ...(configuration ? [{ provide: CUSTOM_LOCALIZATION_CONFIGURATION_TOKEN, useFactory: configuration }] : []) ] }; } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } /** @nocollapse */ static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.3.7", ngImport: i0, type: LocalizationModule, declarations: [O3rLocalizationTranslatePipe, LocalizationTranslateDirective, LocalizedDatePipe, LocalizedDecimalPipe, LocalizedCurrencyPipe], imports: [TranslateModule, BidiModule, DynamicContentModule, CommonModule], exports: [TranslateModule, O3rLocalizationTranslatePipe, LocalizationTranslateDirective, LocalizedDatePipe, LocalizedDecimalPipe, LocalizedCurrencyPipe] }); } /** @nocollapse */ static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationModule, providers: [ { provide: LOCALIZATION_CONFIGURATION_TOKEN, useFactory: createLocalizationConfiguration, deps: [[new Optional(), CUSTOM_LOCALIZATION_CONFIGURATION_TOKEN]] }, { provide: LOCALE_ID, useFactory: localeIdNgBridge, deps: [LocalizationService] }, { provide: Directionality, useClass: TextDirectionality }, { provide: DatePipe, useClass: LocalizedDatePipe }, { provide: DecimalPipe, useClass: LocalizedDecimalPipe }, { provide: CurrencyPipe, useClass: LocalizedCurrencyPipe }, TextDirectionService ], imports: [TranslateModule, BidiModule, DynamicContentModule, CommonModule, TranslateModule] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationModule, decorators: [{ type: NgModule, args: [{ declarations: [O3rLocalizationTranslatePipe, LocalizationTranslateDirective, LocalizedDatePipe, LocalizedDecimalPipe, LocalizedCurrencyPipe], imports: [TranslateModule, BidiModule, DynamicContentModule, CommonModule], exports: [TranslateModule, O3rLocalizationTranslatePipe, LocalizationTranslateDirective, LocalizedDatePipe, LocalizedDecimalPipe, LocalizedCurrencyPipe], providers: [ { provide: LOCALIZATION_CONFIGURATION_TOKEN, useFactory: createLocalizationConfiguration, deps: [[new Optional(), CUSTOM_LOCALIZATION_CONFIGURATION_TOKEN]] }, { provide: LOCALE_ID, useFactory: localeIdNgBridge, deps: [LocalizationService] }, { provide: Directionality, useClass: TextDirectionality }, { provide: DatePipe, useClass: LocalizedDatePipe }, { provide: DecimalPipe, useClass: LocalizedDecimalPipe }, { provide: CurrencyPipe, useClass: LocalizedCurrencyPipe }, TextDirectionService ] }] }] }); const JSON_EXT = '.json'; /** * This class is responsible for loading translation bundles from remote or local endpoints depending on the LocalizationConfiguration. * Fallback mechanism ensures that if a bundle in some language cannot be fetched remotely * we try to fetch the same language bundle locally (bundles stored inside the application) * and finally load the fallback language bundle (if all previous fetches failed) */ class TranslationsLoader { constructor() { this.localizationConfiguration = inject(LOCALIZATION_CONFIGURATION_TOKEN); this.logger = inject(LoggerService, { optional: true }); this.dynamicContentService = inject(DynamicContentService, { optional: true }); } /** * Download a language bundle file * @param url Url to the bundle file */ downloadLanguageBundle$(url) { const queryParams = this.localizationConfiguration.queryParams; let queryString = ''; if (queryParams) { queryString = '?' + Object.keys(queryParams).map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(queryParams[key])).join('&'); } return from(fetch(url + queryString, this.localizationConfiguration.fetchOptions)).pipe(switchMap((response) => from(response.json()))); } /** * @inheritdoc */ getTranslation(lang) { const fallback = this.localizationConfiguration.fallbackLanguage; let localizationPath$ = of(this.localizationConfiguration.endPointUrl); if (this.localizationConfiguration.useDynamicContent) { if (!this.dynamicContentService) { throw new Error('Dynamic Content is not available. Please verify you have imported the module DynamicContentModule in your application'); } localizationPath$ = this.dynamicContentService.getContentPathStream(this.localizationConfiguration.endPointUrl); } return localizationPath$.pipe(switchMap((localizationPath) => { if (localizationPath) { const localizationBundle$ = this.downloadLanguageBundle$(localizationPath + lang + JSON_EXT); if (this.localizationConfiguration.mergeWithLocalTranslations) { return combineLatest([ localizationBundle$.pipe(catchError(() => of({}))), this.getTranslationFromLocal(lang, fallback).pipe(map((translations) => { Object.keys(translations).forEach((key) => translations[key] = `[local] ${translations[key]}`); return translations; })) ]).pipe(map(([dynamicTranslations, localTranslations]) => ({ ...localTranslations, ...dynamicTranslations }))); } /* * if endPointUrl is specified by the configuration then: * 1. try to load lang from endPointUrl * 2. if 1 fails then try to load from the app (local file) */ return localizationBundle$.pipe(catchError(() => { this.logger?.warn(`Failed to load the localization resource from ${localizationPath + lang + JSON_EXT}, trying from the application resources`); return this.getTranslationFromLocal(lang, fallback); })); } /* * else if endPointUrl NOT specified by then configuration then: * 1. try to load from the app (local file) */ this.logger?.warn('No localization endpoint specified, localization fetch from application resources'); return this.getTranslationFromLocal(lang, fallback); })); } /** * *Fetches localization bundles from published folder (internal to application) * *1. try to load lang from local *2. if 1 fails try to load fallback lang but only if it's different from lang in 1 * @param lang - language of the bundle * @param fallbackLanguage - fallback language in case bundle in language not found */ getTranslationFromLocal(lang, fallbackLanguage) { const pathPrefix = this.localizationConfiguration.bundlesOutputPath; return this.downloadLanguageBundle$(pathPrefix + lang + JSON_EXT).pipe(catchError(() => { if (lang === fallbackLanguage) { this.logger?.warn(`Failed to load ${lang} from ${pathPrefix + lang + JSON_EXT}.`); return of({}); } else { this.logger?.warn(`Failed to load ${lang} from ${pathPrefix + lang + JSON_EXT}. Application will fallback to ${fallbackLanguage}`); return this.downloadLanguageBundle$(pathPrefix + fallbackLanguage + JSON_EXT).pipe(catchError(() => of({}))); } })); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TranslationsLoader, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TranslationsLoader }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: TranslationsLoader, decorators: [{ type: Injectable }] }); /** * Creates a loader of translations bundles based on the configuration * (endPointUrl and language determine which bundle we load and where do we fetch it from) * @param localizationConfiguration * @param logger service to handle the log of warning and errors * @param dynamicContentService (optional) */ function createTranslateLoader(localizationConfiguration, logger, dynamicContentService) { const injector = Injector.create({ providers: [ { provide: LOCALIZATION_CONFIGURATION_TOKEN, useValue: localizationConfiguration }, { provide: LoggerService, useValue: logger }, { provide: DynamicContentService, useValue: dynamicContentService }, { provide: TranslationsLoader, deps: [[LoggerService, new Optional()], [DynamicContentService, new Optional()], LOCALIZATION_CONFIGURATION_TOKEN] } ] }); return injector.get(TranslationsLoader); } /** * TranslateLoader provider, using framework's TranslationsLoader class */ const translateLoaderProvider = { provide: TranslateLoader, useFactory: createTranslateLoader, deps: [LOCALIZATION_CONFIGURATION_TOKEN, [new Optional(), LoggerService], [new Optional(), DynamicContentService]] }; class OtterLocalizationDevtools { constructor() { this.localizationService = inject(LocalizationService); this.translateCompiler = inject(TranslateCompiler); this.appRef = inject(ApplicationRef); } /** * Is the translation deactivation enabled */ isTranslationDeactivationEnabled() { return this.localizationService.isTranslationDeactivationEnabled(); } /** * Show localization keys * @param value value enforced by the DevTools extension */ showLocalizationKeys(value) { this.localizationService.toggleShowKeys(value); this.appRef.tick(); } /** * Returns the current language */ getCurrentLanguage() { return this.localizationService.getCurrentLanguage(); } /** * Setup a listener on language change * @param fn called when the language is changed in the app */ onLanguageChange(fn) { return this.localizationService .getTranslateService() .onLangChange .subscribe(({ lang }) => { fn(lang); }); } /** * Switch the current language to the specified value * @param language new language to switch to */ async switchLanguage(language) { if (!language) { return; } await lastValueFrom(this.localizationService.useLanguage(language)); this.appRef.tick(); } /** * Updates the specified localization key/values for the current language. * * Recommendation: To be used with a small number of keys to update to avoid performance issues. * @param keyValues key/values to update * @param language if not provided, the current language value */ updateLocalizationKeys(keyValues, language) { const lang = language || this.getCurrentLanguage(); const translateService = this.localizationService.getTranslateService(); Object.entries(keyValues).forEach(([key, value]) => { translateService.set(key, value, lang); }); this.appRef.tick(); } /** * Reload a language from the language file * @see https://github.com/ngx-translate/core/blob/master/packages/core/lib/translate.service.ts#L490 * @param language language to reload */ async reloadLocalizationKeys(language) { const lang = language || this.getCurrentLanguage(); if (this.translateCompiler.clearCache) { this.translateCompiler.clearCache(); } const initialLocs = await lastValueFrom(this.localizationService .getTranslateService() .reloadLang(lang)); this.localizationService.getTranslateService().setTranslation(language || this.getCurrentLanguage(), initialLocs); this.appRef.tick(); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: OtterLocalizationDevtools, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: OtterLocalizationDevtools }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: OtterLocalizationDevtools, decorators: [{ type: Injectable }] }); const OTTER_LOCALIZATION_DEVTOOLS_DEFAULT_OPTIONS = { isActivatedOnBootstrap: false, isActivatedOnBootstrapWhenCMSContext: true, metadataFilePath: './metadata/localisation.metadata.json' }; const OTTER_LOCALIZATION_DEVTOOLS_OPTIONS = new InjectionToken('Otter Localization Devtools options'); /* eslint-disable no-console -- This is the purpose of this service */ class LocalizationDevtoolsConsoleService { /** Name of the Window property to access to the devtools */ static { this.windowModuleName = 'localization'; } constructor() { this.localizationDevtools = inject(OtterLocalizationDevtools); this.options = inject(OTTER_LOCALIZATION_DEVTOOLS_OPTIONS, { optional: true }) ?? OTTER_LOCALIZATION_DEVTOOLS_DEFAULT_OPTIONS; if (this.options.isActivatedOnBootstrap || (this.options.isActivatedOnBootstrapWhenCMSContext && document.body.dataset.cmscontext === 'true')) { this.activate(); } } /** @inheritDoc */ activate() { const windowWithDevtools = window; windowWithDevtools._OTTER_DEVTOOLS_ ||= {}; windowWithDevtools._OTTER_DEVTOOLS_[LocalizationDevtoolsConsoleService.windowModuleName] = this; console.info(`Otter localization Devtools is now accessible via the _OTTER_DEVTOOLS_.${LocalizationDevtoolsConsoleService.windowModuleName} variable`); } /** * @inheritdoc */ isTranslationDeactivationEnabled() { return this.localizationDevtools.isTranslationDeactivationEnabled(); } /** * @inheritdoc */ showLocalizationKeys(value) { this.localizationDevtools.showLocalizationKeys(value); } /** * @inheritdoc */ getCurrentLanguage() { const currentLanguage = this.localizationDevtools.getCurrentLanguage(); return currentLanguage; } /** * @inheritdoc */ async switchLanguage(language) { const previous = this.localizationDevtools.getCurrentLanguage(); await this.localizationDevtools.switchLanguage(language); const current = this.localizationDevtools.getCurrentLanguage(); return { requested: language, previous, current }; } /** * @inheritdoc */ onLanguageChange(fn) { return this.localizationDevtools.onLanguageChange(fn); } /** * @inheritdoc */ updateLocalizationKeys(keyValues, language) { return this.localizationDevtools.updateLocalizationKeys(keyValues, language); } /** * @inheritdoc */ reloadLocalizationKeys(language) { return this.localizationDevtools.reloadLocalizationKeys(language); } /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationDevtoolsConsoleService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationDevtoolsConsoleService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.7", ngImport: i0, type: LocalizationDevtoolsConsoleService, decorators: [{ type: Injectable }], ctorParameters: () => [] }); const isLocalizationMessage = (message) => { return message && (message.dataType === 'displayLocalizationKeys' || message.dataType === 'languages' || message.dataType === 'switchLanguage' || message.dataType === 'localizations' || message.dataType === 'updateLocalization' || message.dataType === 'requestMessages' || message.dataType === 'connect' || message.dataType === 'reloadLocalizationKeys' || message.dataType === 'isTranslationDeactivationEnabled' || message.dataType === 'getTranslationValuesContentMessage'); }; class LocalizationDevtoolsMessageService { constructor() { this.logger = inject(LoggerService); this.localizationDevTools = inject(OtterLocalizationDevtools); this.localizationService = inject(LocalizationService); this.options = inject(OTTER_LOCALIZATION_DEVTOOLS_OPTIONS, { optional: true }) ?? OTTER_LOCALIZATION_DEVTOOLS_DEFAULT_OPTIONS; this.sendMessage = (sendOtterMessage); this.destroyRef = inject(DestroyRef); this.options = { ...OTTER_LOCALIZATION_DEVTOOLS_DEFAULT_OPTIONS, ...this.options }; if (this.options.isActivatedOnBootstrap) { this.activate(); } } async sendLocalizationsMetadat