Source

lib/i18n/Loader.ts

/**
 * @module module:lib/i18n/Loader
 * @description Internationalization loader and helpers for the for-angular package.
 * @summary Provides an implementation of TranslateLoader (I18nLoader) and helper factories
 * to load translation resources. Also exposes locale utilities used by components to resolve
 * localized keys.
 *
 * @link {@link I18nLoader}
 */
import { inject } from '@angular/core';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { provideTranslateParser, provideTranslateService, RootTranslateServiceConfig, TranslateLoader, TranslateParser, TranslationObject } from '@ngx-translate/core';
import { Primitives, sf } from '@decaf-ts/decorator-validation';
import { forkJoin,  Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {I18nResourceConfig} from '../engine/interfaces';
import { FunctionLike, I18nResourceConfigType, KeyValue } from '../engine/types';
import { cleanSpaces, getLocaleFromClassName } from '../utils';
import en from './data/en.json';
import { I18N_CONFIG_TOKEN } from '../for-angular-common.module';

const libLanguage: Record<string, TranslationObject> = {en};

/**
 * @description Retrieves the locale context for a given class, object, or string.
 * @summary Resolves the locale context by extracting the class name or using the provided suffix.
 *
 * @param {FunctionLike | object | string} clazz - The class, object, or string to derive the locale context from.
 * @param {string} [suffix] - An optional suffix to append to the locale context.
 * @returns {string} - The resolved locale context string.
 */
export function getLocaleContext(clazz: FunctionLike | object | string, suffix?: string): string {
  return getLocaleFromClassName(clazz, suffix);
}


/**
 * @description Generates a localized string by combining locale and phrase
 * @summary This utility function creates a properly formatted locale string by combining
 * a locale identifier with a phrase. It handles edge cases such as empty phrases,
 * missing locales, and phrases that already include the locale prefix. This function
 * is useful for ensuring consistent formatting of localized strings throughout the application.
 *
 * @param {string} locale - The locale identifier (e.g., 'en', 'fr')
 * @param {string | undefined} phrase - The phrase to localize
 * @return {string} The formatted locale string, or empty string if phrase is undefined
 *
 * @function generateLocaleFromString
 * @memberOf module:for-angular
 */
export function getLocaleContextByKey(
  locale: string,
  phrase: string | undefined
): string {
  if (!phrase)
    return locale;
  if (!locale || phrase.includes(`${locale}.`))
    return phrase;
  const parts = phrase.split(' ');
  return `${locale}.${cleanSpaces(parts.join('.'), true)}`;
}

/**
 * @description Factory function for creating an instance of I18nLoader.
 * @summary Configures and returns an I18nLoader instance with the specified HTTP client and translation resources.
 *
 * @param {HttpClient} http - The HTTP client used to fetch translation resources.
 * @returns {TranslateLoader} - An instance of I18nLoader configured with the provided HTTP client and resources.
 */
export function I18nLoaderFactory(http: HttpClient): TranslateLoader {
  const { resources, versionedSuffix } = inject(I18N_CONFIG_TOKEN, { optional: true }) ?? provideI18nLoader().useValue;
  return new I18nLoader(http, resources?.length ? resources : [{ prefix: './app/assets/i18n/', suffix: '.json' }], versionedSuffix);
}

/**
 * @description Provides the I18nLoader configuration.
 * @summary Configures the translation resources and versioned suffix for the I18nLoader.
 *
 * @param {I18nResourceConfigType} [resources=[]] - The translation resources to be used by the loader.
 * @param {boolean} [versionedSuffix=false] - Whether to append a versioned suffix to resource URLs.
 * @returns {object} - The configuration object for the I18nLoader.
 */
export function provideI18nLoader(resources: I18nResourceConfigType = [], versionedSuffix: boolean = false) {
  if (!Array.isArray(resources)) {
    resources = [resources];
  }
  return {
    provide: I18N_CONFIG_TOKEN,
    useValue: { resources: [...resources], versionedSuffix },
  };
}

/**
 * @description Custom implementation of TranslateLoader for loading translations.
 * @summary Fetches and merges translation resources, supporting versioned suffixes and recursive merging.
 */
export class I18nLoader implements TranslateLoader {
  /**
   * @param {HttpClient} http - The HTTP client used to fetch translation resources.
   * @param {I18nResourceConfig[]} [resources=[]] - The translation resources to be loaded.
   * @param {boolean} [versionedSuffix=false] - Whether to append a versioned suffix to resource URLs.
   */
  constructor(private http: HttpClient, private resources: I18nResourceConfig[] = [], private versionedSuffix: boolean = false) {}

  /**
   * @description Appends a versioned suffix to the resource URL if enabled.
   * @summary Generates a versioned suffix based on the current date.
   *
   * @param {string} suffix - The original suffix of the resource URL.
   * @returns {string} - The modified suffix with a version string appended.
   */
  private getSuffix(suffix: string): string {
    if (!this.versionedSuffix) {
      return suffix;
    }
    const today = new Date();
    return `${suffix}?version=${today.getFullYear()}${today.getMonth()}${today.getDay()}`;
  }

  /**
   * @description Fetches and merges translations for the specified language.
   * @summary Loads translation resources, merges them recursively, and includes library keys.
   *
   * @param {string} lang - The language code for the translations to load.
   * @returns {Observable<TranslationObject>} - An observable that emits the merged translation object.
   */
  getTranslation(lang: string): Observable<TranslationObject> {
    const libKeys: KeyValue = libLanguage[lang] || libLanguage['en'] || {};
    const httpRequests$ = forkJoin(
      this.resources.map(config =>
        this.http.get<TranslationObject>(`${config.prefix}${lang}${this.getSuffix(config.suffix || '.json')}`)
      )
    );

    /**
     * @description Recursively merges two translation objects.
     * @summary Combines the properties of the source object into the target object.
     *
     * @param {KeyValue} target - The target object to merge into.
     * @param {KeyValue} source - The source object to merge from.
     * @returns {KeyValue} - The merged object.
     */
    function recursiveMerge(target: KeyValue, source: KeyValue): KeyValue {
      for (const key of Object.keys(source)) {
        if (source[key] instanceof Object) {
          if (!target[key]) Object.assign(target, { [key]: {} });
          recursiveMerge(target[key], source[key]);
        } else {
          Object.assign(target, { [key]: source[key] });
        }
      }
      return target;
    }

    return httpRequests$.pipe(
      map(res => {
        const merged = {
          ...libKeys,
          ...res.reduce((acc: KeyValue, current: KeyValue) => {
            for (const key in current) {
              let value = current[key] || {};
              if (libKeys[key]) {
                value = { ...libKeys[key], ...recursiveMerge(libKeys[key] as KeyValue, current[key] as KeyValue) };
              }
              acc[key] = value;
            }
            return acc;
          }, {}),
        };
        return merged;
      })
    );
  }
}

/**
 * @description Custom implementation of TranslateParser for interpolation.
 * @summary Extends TranslateParser to support string formatting with parameters.
 */
export class I18nParser extends TranslateParser {
  /**
   * @description Interpolates a translation string with parameters.
   * @summary Replaces placeholders in the translation string with parameter values.
   *
   * @param {string} value - The translation string to interpolate.
   * @param {object | string} [params={}] - The parameters to replace placeholders with.
   * @returns {string} - The interpolated translation string.
   */
  interpolate(value: string, params: object | string = {}): string {
    if (typeof params === Primitives.STRING) {
      params = { '0': params };
    }
    return sf(value, ...Object.values(params));
  }
}

/**
 * @description Provides the internationalization (i18n) configuration for the application.
 * @summary Configures the translation service with a fallback language, default language, custom parser, and loader.
 *
 * @param {RootTranslateServiceConfig} [config={fallbackLang: 'en', lang: 'en'}] - The configuration for the translation service, including fallback and default languages.
 * @param {I18nResourceConfigType} [resources=[]] - The translation resources to be used by the loader.
 * @param {boolean} [versionedSuffix=false] - Whether to append a versioned suffix to resource URLs.
 * @returns {Array} - An array of providers for the translation service and loader.
 */
export function provideI18n(
  config: RootTranslateServiceConfig = { fallbackLang: 'en', lang: 'en' },
  resources: I18nResourceConfigType = [],
  versionedSuffix: boolean = false
) {
  return [
    provideHttpClient(),
    provideTranslateService({
      fallbackLang: config.fallbackLang,
      lang: config.lang,
      parser: provideTranslateParser(I18nParser),
      loader: {
        provide: TranslateLoader,
        useFactory: I18nLoaderFactory,
        deps: [HttpClient],
      },
    }),
    provideI18nLoader(resources, versionedSuffix),
  ];
}