UNPKG

@sixbell-telco/sdk

Version:

A collection of reusable components designed for use in Sixbell Telco Angular projects

972 lines (963 loc) 39.2 kB
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