@sixbell-telco/sdk
Version:
A collection of reusable components designed for use in Sixbell Telco Angular projects
972 lines (963 loc) • 39.2 kB
JavaScript
import * as i0 from '@angular/core';
import { Inject, Injectable, inject, DestroyRef, signal, computed, Injector, runInInjectionContext, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
import { TranslateService, provideTranslateService, provideMissingTranslationHandler, provideTranslateLoader } from '@ngx-translate/core';
export * from '@ngx-translate/core';
import { LoggerService } from '@sixbell-telco/sdk/utils/logger';
import { RuntimeHttpClient, RuntimeConfigStore, RuntimeConfigLoader, RuntimeUpdateAdapter } from '@sixbell-telco/sdk/utils/runtime-config';
import { firstValueFrom, from } from 'rxjs';
import { DOCUMENT } from '@angular/common';
export * from '@ngx-translate/http-loader';
export * from 'ngx-translate-multi-http-loader';
/**
* Translation Constants and Injection Tokens
* Centralized configuration values and DI tokens following Single Responsibility Principle
*/
/**
* Default base language - always available unless explicitly excluded
* This is the foundational language that comes with the SDK
*/
const DEFAULT_BASE_LANGUAGE = 'en';
/**
* List of default languages available out of the box
*/
const DEFAULT_LANGUAGES_LIST = ['en', 'es', 'pt'];
/**
* Type-safe default language codes for IDE IntelliSense
*/
const DEFAULT_LANGUAGE_CODES = DEFAULT_LANGUAGES_LIST;
/**
* LocalStorage namespace to prevent collisions with other apps
*/
const TRANSLATION_STORAGE_NAMESPACE = 'translation:';
/**
* LocalStorage key for persisting selected language
*/
const TRANSLATION_STORAGE_LANGUAGE_KEY = 'selectedLanguage';
/**
* Default translation file paths
*/
const DEFAULT_TRANSLATION_PATHS = ['/assets/i18n/'];
/**
* HTML attribute name for setting the active language on document element
*/
const LANGUAGE_ATTRIBUTE_NAME = 'lang';
/**
* Translation-specific IndexedDB database name for resource caching
*/
const TRANSLATION_INDEXED_DB_NAME = 'sixbell-translation';
/**
* Schema version for runtime translation configuration payloads.
* Bump this when the JSON structure changes in a breaking way.
*/
const TRANSLATION_SCHEMA_VERSION = '2';
const DEFAULT_LANGUAGES = [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Español' },
{ code: 'pt', name: 'Português' },
];
const DEFAULT_LANGUAGE_CODE = 'en';
const FALLBACK_RUNTIME_TRANSLATION_CONFIG = {
meta: {
version: 'fallback',
updatedAt: new Date().toISOString(),
hash: 'fallback-hash',
schemaVersion: TRANSLATION_SCHEMA_VERSION,
},
additionalLanguages: [],
defaultLang: DEFAULT_LANGUAGE_CODE,
translationPaths: [...DEFAULT_TRANSLATION_PATHS],
};
/**
* Translation DOM Service
* Handles DOM operations related to language/localization
*/
/**
* Service responsible for DOM manipulation related to language/localization
* Decouples DOM operations from business logic
*/
class TranslationDomService {
rendererFactory;
document;
renderer;
constructor(rendererFactory, document) {
this.rendererFactory = rendererFactory;
this.document = document;
this.renderer = this.rendererFactory.createRenderer(null, null);
}
/**
* Sets the language on the document element
* Updates the HTML lang attribute for accessibility and SEO
* @param language - Language code (e.g., 'en', 'es', 'pt')
*/
setDocumentLanguage(language) {
if (!language) {
return;
}
const htmlElement = this.document.documentElement;
if (htmlElement) {
this.renderer.setAttribute(htmlElement, LANGUAGE_ATTRIBUTE_NAME, language);
}
}
/**
* Gets the current language from the document element
* Reads the HTML lang attribute
* @returns Current language code or empty string if not set
*/
getDocumentLanguage() {
const htmlElement = this.document.documentElement;
if (!htmlElement) {
return '';
}
return htmlElement.getAttribute(LANGUAGE_ATTRIBUTE_NAME) || '';
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationDomService, deps: [{ token: i0.RendererFactory2 }, { token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationDomService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationDomService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: i0.RendererFactory2 }, { type: Document, decorators: [{
type: Inject,
args: [DOCUMENT]
}] }] });
/**
* Translation Storage Service
* Handles persistent language preferences using localStorage
*/
/**
* Helper function to check if localStorage is available
* Returns false in SSR or when storage is disabled
*/
function hasLocalStorage() {
try {
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
return false;
}
// Try to access localStorage to catch any errors
localStorage.getItem('test');
return true;
}
catch {
return false;
}
}
/**
* Service for managing language preferences in local storage
* Provides safe access to localStorage with error handling
* Works gracefully in non-browser environments (SSR)
*/
class TranslationStorageService {
logger = inject(LoggerService);
/**
* Retrieves the saved language preference from localStorage
* Returns null if no saved preference or storage unavailable
* @returns Saved language code or null
*/
getSavedLanguage() {
try {
if (!hasLocalStorage()) {
return null;
}
const key = this.getStorageKey(TRANSLATION_STORAGE_LANGUAGE_KEY);
return localStorage.getItem(key);
}
catch (error) {
this.logger.error('Failed to retrieve saved language', error, {
component: 'TranslationStorageService',
action: 'getSavedLanguage',
});
return null;
}
}
/**
* Saves the current language preference to localStorage
* Fails silently if storage is unavailable
* @param language - Language code to save
*/
saveLanguage(language) {
try {
if (!hasLocalStorage()) {
return;
}
const key = this.getStorageKey(TRANSLATION_STORAGE_LANGUAGE_KEY);
localStorage.setItem(key, language);
}
catch (error) {
this.logger.error('Failed to save language', error, {
component: 'TranslationStorageService',
action: 'saveLanguage',
language,
});
}
}
/**
* Clears all saved language preferences from localStorage
* Useful for resetting to defaults
*/
clear() {
try {
if (!hasLocalStorage()) {
return;
}
const key = this.getStorageKey(TRANSLATION_STORAGE_LANGUAGE_KEY);
localStorage.removeItem(key);
}
catch (error) {
this.logger.error('Failed to clear storage', error, {
component: 'TranslationStorageService',
action: 'clear',
});
}
}
/**
* Constructs a namespaced storage key to avoid collisions
* @param key - The base storage key
* @returns Namespaced key (e.g., "translation:selectedLanguage")
*/
getStorageKey(key) {
return `${TRANSLATION_STORAGE_NAMESPACE}${key}`;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationStorageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationStorageService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationStorageService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* Translation Service
* Main service for handling language selection, translation loading, and management
* Mirrors ThemeService - config via DI token + multi-path loading
*/
function createLanguage(config) {
return {
code: config.code,
name: config.name,
};
}
function extendDefaultLanguages(additionalLanguages) {
return [...DEFAULT_LANGUAGES, ...additionalLanguages];
}
class PlaceholderMissingTranslationHandler {
handle(params) {
return params.key;
}
}
class TranslationService {
translate = inject(TranslateService);
storageService = inject(TranslationStorageService);
domService = inject(TranslationDomService);
logger = inject(LoggerService);
destroyRef = inject(DestroyRef);
initialized = false;
currentTranslationPaths = [...DEFAULT_TRANSLATION_PATHS];
translationHttpClient = null;
hasAppliedInitialLanguage = false;
isUsingFallbackConfig = false;
prefetchLanguages = true;
isApplyingRuntimeConfig = false;
runtimeHash = null;
runtimeLoader;
runtimeMeta;
pendingUpdatedLanguages = new Set();
bootstrapValidated = false;
translationTick = signal(0);
updateAvailable = signal(false);
lastRefreshFailed = signal(false);
config = {
availableLanguages: [...DEFAULT_LANGUAGES],
defaultLanguage: DEFAULT_LANGUAGE_CODE,
};
currentLanguage = signal(DEFAULT_LANGUAGE_CODE);
onLanguageChange$ = this.translate.onLangChange;
onLanguageChange = signal(0);
ready = signal(false);
changes = computed(() => this.translationTick());
constructor() {
this.currentLanguage.set(this.getCurrentLanguage());
}
configure(config) {
this.logger.debug('configure - starting', {
component: 'TranslationService',
action: 'configure',
configProvidedPaths: config.translationPaths,
configDefaultLang: config.defaultLang,
configAdditionalLanguages: config.additionalLanguages?.length,
configExcludedLanguages: config.excludeLanguages,
});
let availableLanguages = [...DEFAULT_LANGUAGES];
if (config.additionalLanguages) {
availableLanguages = [...availableLanguages, ...config.additionalLanguages];
}
if (config.excludeLanguages) {
availableLanguages = availableLanguages.filter((lang) => !config.excludeLanguages?.includes(lang.code));
}
const defaultLang = config.defaultLang || DEFAULT_LANGUAGE_CODE;
const newPaths = (config.translationPaths || DEFAULT_TRANSLATION_PATHS);
if (JSON.stringify(newPaths) !== JSON.stringify(this.currentTranslationPaths)) {
this.currentTranslationPaths = newPaths;
this.logger.debug('Translation paths updated', {
component: 'TranslationService',
action: 'configure',
newPaths,
});
const targetLang = this.getCurrentLanguage();
const preloadPromises = this.currentTranslationPaths.map((path) => {
const normalized = path.endsWith('/') ? path : `${path}/`;
const url = `${normalized}${targetLang}.json`;
return this.fetchTranslation(url)
.then(() => undefined)
.catch(() => undefined);
});
Promise.all(preloadPromises).then(() => {
this.translate.reloadLang(targetLang);
});
}
this.config = {
availableLanguages,
defaultLanguage: defaultLang,
};
const availableCodes = this.config.availableLanguages.map((lang) => lang.code);
if (!availableCodes.includes(this.config.defaultLanguage)) {
this.logger.warn('Default language not available in configured languages', {
component: 'TranslationService',
action: 'configure',
defaultLang: this.config.defaultLanguage,
availableCodes,
});
this.config.defaultLanguage = availableCodes[0] || DEFAULT_LANGUAGE_CODE;
}
this.logger.debug('Configuration complete', {
component: 'TranslationService',
action: 'configure',
finalDefaultLang: this.config.defaultLanguage,
availableLangCodes: availableCodes,
});
this.resolveStoredLanguage();
if (this.initialized) {
// This is a runtime config update (not initial construction)
// Keep current language resolved from storage unless we're doing a fresh init
if (!this.isApplyingRuntimeConfig) {
this.currentLanguage.set(this.config.defaultLanguage);
this.initializeFromStorage();
}
// Mark that we've applied the initial language so init() can apply it on next call
this.hasAppliedInitialLanguage = true;
}
else {
this.initialized = true;
// IMPORTANT: Do NOT call applyLanguage() here during construction!
// This causes circular dependency because ngx-translate tries to use
// the loader during provider setup phase. Instead, applyLanguage()
// will be deferred until setRuntimeConfig() is called.
}
}
resolveStoredLanguage() {
const availableCodes = this.config.availableLanguages.map((lang) => lang.code);
const savedLanguage = this.storageService.getSavedLanguage();
if (savedLanguage && availableCodes.includes(savedLanguage)) {
this.currentLanguage.set(savedLanguage);
return;
}
if (savedLanguage && !this.isUsingFallbackConfig) {
this.storageService.clear();
}
this.currentLanguage.set(this.config.defaultLanguage);
}
async setRuntimeConfig(config) {
this.ready.set(false);
this.logger.debug('setRuntimeConfig - starting', {
component: 'TranslationService',
action: 'setRuntimeConfig',
configPath: config.translationPaths,
});
// Update configuration (this will update currentLanguage signal and restore from storage)
this.isApplyingRuntimeConfig = true;
this.configure(config);
// Apply language now that runtime config has loaded
// This saves to storage with the correct default from config file
const targetLang = this.currentLanguage();
await firstValueFrom(this.translate.reloadLang(targetLang));
this.applyLanguage();
await this.loadSelectedLanguage();
if (this.prefetchLanguages) {
this.prefetchRemainingLanguages();
}
this.isApplyingRuntimeConfig = false;
this.translationTick.update((value) => value + 1);
this.logger.debug('setRuntimeConfig - complete', {
component: 'TranslationService',
action: 'setRuntimeConfig',
currentLanguage: this.getCurrentLanguage(),
});
}
enablePrefetch(enable) {
this.prefetchLanguages = enable;
}
async loadSelectedLanguage() {
const targetLang = this.currentLanguage();
await this.loadLanguageResources(targetLang);
}
prefetchRemainingLanguages() {
const currentLang = this.currentLanguage();
const remaining = this.getAvailableLanguagesConfig()
.map((lang) => lang.code)
.filter((code) => code !== currentLang);
for (const lang of remaining) {
this.loadLanguageResources(lang).catch(() => undefined);
}
}
async loadLanguageResources(lang) {
const loadPromises = this.currentTranslationPaths.map((path) => {
const normalized = path.endsWith('/') ? path : `${path}/`;
const url = `${normalized}${lang}.json`;
return this.fetchTranslation(url);
});
await Promise.allSettled(loadPromises);
}
queueUpdatedLanguage(lang) {
if (!lang) {
return;
}
this.pendingUpdatedLanguages.add(lang);
}
async reloadLanguages(languages) {
const unique = Array.from(new Set(languages)).filter(Boolean);
if (!unique.length) {
return;
}
const available = new Set(this.getAvailableLanguageCodes());
await Promise.all(unique.filter((lang) => available.has(lang)).map((lang) => firstValueFrom(this.translate.reloadLang(lang)).catch(() => undefined)));
}
async refreshInactiveLanguages() {
const current = this.currentLanguage();
const nonActive = this.getAvailableLanguageCodes().filter((lang) => lang !== current);
await this.reloadLanguages(nonActive);
}
async validateBootstrapCache() {
if (this.bootstrapValidated) {
return;
}
this.bootstrapValidated = true;
const runtimeLoader = this.runtimeLoader;
if (!runtimeLoader || !this.runtimeHash || this.isUsingFallbackConfig) {
return;
}
if (this.runtimeMeta?.schemaVersion && this.runtimeMeta.schemaVersion !== TRANSLATION_SCHEMA_VERSION) {
return;
}
try {
const check = await runtimeLoader.checkForUpdates(this.runtimeHash);
if (!check.updated) {
return;
}
const result = await runtimeLoader.refresh();
this.isUsingFallbackConfig = result.source === 'fallback';
this.runtimeHash = result.hash;
this.runtimeMeta = result.meta;
this.updateAvailable.set(false);
await this.setRuntimeConfig(result.data);
this.ready.set(true);
const current = this.currentLanguage();
await this.reloadLanguages([current]);
void this.refreshInactiveLanguages();
}
catch (error) {
this.logger.warn('Translation bootstrap validation failed', {
component: 'TranslationService',
action: 'validateBootstrapCache',
error: error instanceof Error ? error.message : String(error),
});
}
}
initializeFromStorage() {
try {
if (this.isApplyingRuntimeConfig) {
this.logger.debug('Skipping storage restore during runtime config apply', {
component: 'TranslationService',
action: 'initializeFromStorage',
});
return;
}
const saved = this.storageService.getSavedLanguage();
const availableCodes = this.config.availableLanguages.map((l) => l.code);
if (saved && availableCodes.includes(saved)) {
this.logger.debug('Restoring saved language', {
component: 'TranslationService',
action: 'initializeFromStorage',
savedLanguage: saved,
});
this.currentLanguage.set(saved);
}
this.applyLanguage();
}
catch (error) {
this.logger.error('Error initializing from storage', error, {
component: 'TranslationService',
action: 'initializeFromStorage',
});
}
}
applyLanguage() {
const targetLang = this.currentLanguage();
this.translate.setFallbackLang(this.config.defaultLanguage);
this.translate.use(targetLang);
this.domService.setDocumentLanguage(targetLang);
// Always save to storage - keeps state consistent from the start
// This mirrors theme service behavior: saves defaults on init,
// then saves user preferences when they change language
this.storageService.saveLanguage(targetLang);
}
setLanguage(lang) {
if (lang) {
const availableCodes = this.config.availableLanguages.map((l) => l.code);
if (!availableCodes.includes(lang)) {
this.logger.warn('Language not available, using default', {
component: 'TranslationService',
action: 'setLanguage',
requestedLang: lang,
defaultLang: this.config.defaultLanguage,
availableCodes,
});
lang = this.config.defaultLanguage;
}
this.logger.debug('setLanguage', {
component: 'TranslationService',
action: 'setLanguage',
language: lang,
});
this.currentLanguage.set(lang);
this.applyLanguage();
}
}
getCurrentLanguage() {
const saved = this.storageService.getSavedLanguage();
const availableCodes = this.config.availableLanguages.map((l) => l.code);
return saved && availableCodes.includes(saved) ? saved : this.config.defaultLanguage;
}
getAvailableLanguages() {
return [...this.translate.getLangs()];
}
getDefaultLanguage() {
return this.config.defaultLanguage;
}
instant(key, params) {
const result = params ? this.translate.instant(key, params) : this.translate.instant(key);
if (result === key) {
const currentLang = this.getCurrentLanguage();
this.logger.warn('Translation key not found', {
component: 'TranslationService',
action: 'instant',
key,
currentLanguage: currentLang,
hasParams: !!params,
availableLanguages: this.translate.getLangs(),
});
}
return result;
}
get(key, params) {
if (params) {
return this.translate.get(key, params);
}
return this.translate.get(key);
}
stream(key, params) {
if (params) {
return this.translate.stream(key, params);
}
return this.translate.stream(key);
}
getAvailableLanguagesConfig() {
return this.config.availableLanguages;
}
getAvailableLanguageCodes() {
return this.config.availableLanguages.map((lang) => lang.code);
}
getLanguageByCode(code) {
return this.config.availableLanguages.find((lang) => lang.code === code);
}
getLanguageDisplayName(code) {
const language = this.getLanguageByCode(code);
return language ? language.name : code.toUpperCase();
}
getLocalizedLanguageName(code) {
const translationKey = `sdk.language.languages.${code}`;
const translatedName = this.translate.instant(translationKey);
if (translatedName && translatedName !== translationKey) {
return translatedName;
}
const language = this.getLanguageByCode(code);
return language ? language.name : code.toUpperCase();
}
isReady() {
return this.ready.asReadonly();
}
async waitForTranslations(timeoutMs = 10000) {
return new Promise((resolve) => {
if (this.ready()) {
resolve();
return;
}
const startTime = Date.now();
const checkInterval = setInterval(() => {
if (this.ready()) {
clearInterval(checkInterval);
resolve();
}
else if (Date.now() - startTime > timeoutMs) {
clearInterval(checkInterval);
this.logger.warn('waitForTranslations timeout', {
component: 'TranslationService',
action: 'waitForTranslations',
timeoutMs,
isReady: this.ready(),
});
this.ready.set(true);
resolve();
}
}, 50);
});
}
init() {
this.getAvailableLanguagesConfig();
// IMPORTANT: Do NOT apply the language here on first init!
// The runtime config hasn't loaded yet, so we'd save the hardcoded default to storage.
// Instead, applyLanguage() will be called from setRuntimeConfig() after config loads.
if (this.hasAppliedInitialLanguage) {
this.applyLanguage();
}
const subscription = this.translate.onLangChange.subscribe({
next: (event) => {
this.currentLanguage.set(event.lang);
this.onLanguageChange.update((val) => val + 1);
this.translationTick.update((value) => value + 1);
this.ready.set(true);
this.logger.debug('Translations ready - language loaded', {
component: 'TranslationService',
action: 'init - onLangChange',
ready: true,
language: event.lang,
});
},
error: (err) => {
this.logger.error('Error loading translations', err, {
component: 'TranslationService',
action: 'init - onLangChange',
});
this.ready.set(true);
},
});
this.destroyRef.onDestroy(() => {
subscription.unsubscribe();
});
}
async applyRuntimeConfig(result, loader) {
if (loader) {
this.runtimeLoader = loader;
}
this.isUsingFallbackConfig = result.source === 'fallback';
this.runtimeHash = result.hash;
this.runtimeMeta = result.meta;
this.updateAvailable.set(false);
await this.setRuntimeConfig(result.data);
this.ready.set(true);
if (result.source === 'cache') {
void this.validateBootstrapCache();
}
}
setRuntimeLoader(loader, hash) {
this.runtimeLoader = loader;
this.runtimeHash = hash ?? null;
this.updateAvailable.set(false);
this.lastRefreshFailed.set(false);
}
async checkForTranslationUpdates(loader, event) {
this.runtimeLoader = loader;
if (event?.resource && event.resource !== 'translation' && event.resource !== 'both') {
return;
}
const eventHash = event?.hash;
const eventLang = event && 'lang' in event ? event.lang : undefined;
if (eventHash) {
if (eventHash === this.runtimeHash) {
return;
}
this.queueUpdatedLanguage(eventLang);
if (!this.updateAvailable()) {
this.updateAvailable.set(true);
}
return;
}
const currentHash = this.runtimeHash;
if (currentHash) {
const check = await loader.checkForUpdates(currentHash);
if (check.updated && !this.updateAvailable()) {
this.updateAvailable.set(true);
}
return;
}
if (!this.updateAvailable()) {
this.updateAvailable.set(true);
}
}
async refreshTranslation(loader) {
const runtimeLoader = loader ?? this.runtimeLoader;
if (!runtimeLoader) {
this.logger.warn('Translation refresh requested without runtime loader', {
component: 'TranslationService',
action: 'refreshTranslation',
});
return;
}
try {
const result = await runtimeLoader.refresh();
await this.applyRuntimeConfig(result, runtimeLoader);
const pendingLanguages = Array.from(this.pendingUpdatedLanguages);
this.pendingUpdatedLanguages.clear();
const current = this.currentLanguage();
if (pendingLanguages.length > 0) {
await this.reloadLanguages(pendingLanguages.filter((lang) => lang !== current));
}
this.updateAvailable.set(false);
this.lastRefreshFailed.set(false);
}
catch (error) {
this.lastRefreshFailed.set(true);
this.logger.error('Translation refresh failed', error, {
component: 'TranslationService',
action: 'refreshTranslation',
});
}
}
withTranslationsReady(fn) {
this.changes();
if (!this.ready()) {
return undefined;
}
return fn();
}
getOrCreateHttpClient() {
if (!this.translationHttpClient) {
this.translationHttpClient = new RuntimeHttpClient({
retries: 2,
retryDelayMs: 400,
timeoutMs: 6000,
});
}
return this.translationHttpClient;
}
async fetchTranslation(url) {
try {
const response = await this.getOrCreateHttpClient().fetch(url, { cache: 'no-store' });
return (await response.json());
}
catch {
return {};
}
}
createTranslateLoader() {
const suffix = '.json';
return {
getTranslation: (lang) => {
this.logger.debug('TranslateLoader.getTranslation called', {
language: lang,
pathsToUse: this.currentTranslationPaths,
});
const pathsToUse = this.currentTranslationPaths;
if (pathsToUse.length === 0) {
this.logger.warn('No translation paths configured', { language: lang });
return from(Promise.resolve({}));
}
const loadPromises = pathsToUse.map((path) => {
const url = `${path.endsWith('/') ? path : `${path}/`}${lang}${suffix}`;
return this.fetchTranslation(url);
});
return from(Promise.all(loadPromises).then((results) => {
const merged = results.reduce((acc, result) => {
return this.deepMergeTranslations(acc, result ?? {});
}, {});
this.logger.debug('Translations loaded and merged', {
language: lang,
filesLoaded: results.length,
totalKeys: Object.keys(merged).length,
});
return merged;
}));
},
};
}
isTranslationObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
deepMergeTranslations(target, source) {
const result = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = result[key];
if (this.isTranslationObject(sourceValue) && this.isTranslationObject(targetValue)) {
result[key] = this.deepMergeTranslations(targetValue, sourceValue);
}
else {
result[key] = sourceValue;
}
}
}
return result;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: TranslationService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [] });
// ============================================================
// CUSTOM LOADER - Deferred injection pattern
// ============================================================
/**
* Custom TranslateLoader that defers injection of TranslationService.
*
* This loader is instantiated by ngx-translate when first needed.
* By deferring the inject() call until getTranslation is called,
* we avoid circular dependency issues during DI setup.
*
* Key: DO NOT use providedIn: 'root' - let ngx-translate manage the lifecycle.
*/
class RuntimeTranslationCustomLoader {
injector = inject(Injector);
getTranslation(lang) {
// Use runInInjectionContext to safely inject TranslationService
// even when called outside the normal DI context
return runInInjectionContext(this.injector, () => {
const translationService = inject(TranslationService);
return translationService.createTranslateLoader().getTranslation(lang);
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: RuntimeTranslationCustomLoader, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: RuntimeTranslationCustomLoader });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.0", ngImport: i0, type: RuntimeTranslationCustomLoader, decorators: [{
type: Injectable
}] });
// ============================================================
// RUNTIME PROVIDER - WITH NGXTRANSLATE SETUP
// ============================================================
/**
* Configuration options for the runtime translation provider (like themes).
*
* @property appId - Optional namespace to avoid cache collisions across MFEs
* @property sse - Optional SSE config for runtime update checks (disabled by default)
* @property updateStream - Optional Observable for runtime update checks (disabled by default)
*
* @example
* ```typescript
* // Default mode
* provideRuntimeTranslation('/assets/config/translation-config.json')
*
* // SSE updates (optional)
* provideRuntimeTranslation('/assets/config/translation-config.json', {
* sse: { url: '/api/updates' }
* })
* ```
*/
/**
* Build runtime translation loader options with defaults
*/
/**
* Provide Translation service with configuration loaded from a JSON file at runtime (like themes).
*
* This is the ONLY provider that sets up ngx-translate. It:
* - Loads translation configuration from a JSON file
* - Sets up ngx-translate with custom loader that loads from multiple paths
* - Supports hot-reload on navigation
* - Supports offline scenarios with caching
*
* @param configPath - Path to translation configuration JSON file
* @param options - Optional loader configuration
* @returns Environment providers
*
* @example
* ```typescript
* bootstrapApplication(AppComponent, [
* provideRuntimeTranslation('/assets/translation/translation-config.json')
* ]);
* ```
*/
function provideRuntimeTranslation(configPath, options) {
return makeEnvironmentProviders([
// NOW provide ngx-translate - TranslationService will be injected when needed
// Do NOT provide TranslationService directly since it's providedIn: 'root'
provideTranslateService({
loader: provideTranslateLoader(RuntimeTranslationCustomLoader),
missingTranslationHandler: provideMissingTranslationHandler(PlaceholderMissingTranslationHandler),
}),
// Load runtime config and initialize service
provideAppInitializer(() => {
const translationService = inject(TranslationService);
const logger = inject(LoggerService);
const store = new RuntimeConfigStore('translation-runtime-config', options?.appId);
const loader = new RuntimeConfigLoader(configPath, {
fallbackData: FALLBACK_RUNTIME_TRANSLATION_CONFIG,
schemaVersion: TRANSLATION_SCHEMA_VERSION,
resource: 'translation',
store,
logger,
parser: async (response) => (await response.json()),
appId: options?.appId,
});
const updateAdapter = new RuntimeUpdateAdapter(async (event) => {
if (event.resource && event.resource !== 'translation' && event.resource !== 'both') {
return;
}
await translationService.checkForTranslationUpdates(loader, event);
});
if (options?.sse) {
updateAdapter.connectSse(options.sse.url, {
resource: 'translation',
withCredentials: options.sse.withCredentials,
eventType: options.sse.eventType,
transform: options.sse.transform,
logger,
});
}
if (options?.updateStream) {
updateAdapter.connectStream(options.updateStream);
}
return (async () => {
let result = await loader.loadLatest();
if (result.source === 'cache') {
try {
const check = await loader.checkForUpdates(result.hash);
if (check.updated) {
result = await loader.refresh();
}
}
catch (error) {
logger.warn('Translation config bootstrap validation failed', {
component: 'TranslationProvider',
action: 'bootstrapValidation',
error: error instanceof Error ? error.message : String(error),
});
}
}
if (!result.meta.schemaVersion) {
throw new Error('Translation config schemaVersion missing');
}
if (result.meta.schemaVersion !== TRANSLATION_SCHEMA_VERSION) {
throw new Error('Translation config schemaVersion mismatch');
}
await translationService.applyRuntimeConfig(result, loader);
translationService.setRuntimeLoader(loader, result.hash);
translationService.init();
})();
}),
]);
}
// Export main translation service and types
/**
* Generated bundle index. Do not edit.
*/
export { DEFAULT_BASE_LANGUAGE, DEFAULT_LANGUAGES_LIST, DEFAULT_LANGUAGE_CODES, FALLBACK_RUNTIME_TRANSLATION_CONFIG, LANGUAGE_ATTRIBUTE_NAME, PlaceholderMissingTranslationHandler, TRANSLATION_SCHEMA_VERSION, TRANSLATION_STORAGE_LANGUAGE_KEY, TRANSLATION_STORAGE_NAMESPACE, TranslationDomService, TranslationService, TranslationStorageService, createLanguage, extendDefaultLanguages, provideRuntimeTranslation };
//# sourceMappingURL=sixbell-telco-sdk-utils-translation.mjs.map