Skip to content

Instantly share code, notes, and snippets.

@leo6104
Last active May 16, 2024 07:30
Show Gist options
  • Save leo6104/1d8da771f1cb3da237ca3ba06fe4fdea to your computer and use it in GitHub Desktop.
Save leo6104/1d8da771f1cb3da237ca3ba06fe4fdea to your computer and use it in GitHub Desktop.
Angular Translate library with Signal API (for optimized rendering performance)
import {
computed,
effect,
Inject,
Injectable,
Pipe,
PipeTransform,
PLATFORM_ID,
signal,
SkipSelf
} from '@angular/core';
import { Subscription } from 'rxjs';
import { TranslateService } from './translate.service';
import { equals, interpolate } from './util';
import { TRANSLATE_PREFIX } from './token';
import { isPlatformServer } from '@angular/common';
@Pipe({
name: 'translate',
})
export class TranslatePipe {
private value = signal<string>('');
private lastQuery = signal<string>(null, { equal: equals });
private lastArgs = signal<any[]>([], { equal: equals }); // most case will have empty array so lastParams initial value is empty array
private interpolateParams = computed(() => interpolate(this.lastArgs()), { equal: equals });
constructor() {
const translate = inject(TranslateService);
effect((onCleanup) => {
// it will be run in instantiating time. So it will ignore empty value
const query = this.lastQuery();
if (!isDefined(query) || !query.length) {
return;
}
const sub: Subscription = translate.get(query, this.interpolateParams()).pipe(
tap((res: string) => {
this.value.set(res ?? query);
})
).subscribe();
onCleanup(() => sub.unsubscribe());
}, { allowSignalWrites: true });
}
transform(_query: string | number, ...args: any[]): any {
const query = (typeof _query === 'number') ? `${_query}` : _query;
if (!query || !query.length) {
return query;
}
// store query+args, in case they change.
this.lastQuery.set(query);
this.lastArgs.set(args);
return this.value();
}
}
import { effect, inject, Inject, Injectable, InjectionToken, PLATFORM_ID, TransferState } from "@angular/core";
import { catchError, forkJoin, isObservable, Observable, of, tap, throwError } from "rxjs";
import { map, shareReplay, startWith, take } from "rxjs/operators";
import { MissingTranslationHandler, MissingTranslationHandlerParams } from "./missing-translation-handler";
import { TranslateCompiler } from "./translate.compiler";
import { TranslateLoader } from "./translate.loader";
import { TranslateParser } from "./translate.parser";
import { TranslateStore } from "./translate.store";
import { isDefined, mergeDeep } from "./util";
import { isPlatformServer } from '@angular/common';
import { USED_TRANSLATIONS_FROM_SSR } from './token';
export const USE_STORE = new InjectionToken<string>('USE_STORE');
export const USE_DEFAULT_LANG = new InjectionToken<string>('USE_DEFAULT_LANG');
export const DEFAULT_LANGUAGE = new InjectionToken<string>('DEFAULT_LANGUAGE');
export const USE_EXTEND = new InjectionToken<string>('USE_EXTEND');
export interface TranslationChangeEvent {
translations: any;
lang: string;
}
export interface LangChangeEvent {
lang: string;
translations: any;
}
export interface DefaultLangChangeEvent {
lang: string;
translations: any;
}
declare interface Window {
navigator: any;
}
declare const window: Window;
@Injectable()
export class TranslateService {
private _translationRequests: any = {};
private readonly isolatedStore: TranslateStore;
/**
* An EventEmitter to listen to translation change events
* onTranslationChange.subscribe((params: TranslationChangeEvent) => {
* // do something
* });
*/
get onTranslationChange(): Observable<TranslationChangeEvent> {
return this.store.onTranslationChange;
}
/**
* An EventEmitter to listen to lang change events
* onLangChange.subscribe((params: LangChangeEvent) => {
* // do something
* });
*/
get onLangChange(): Observable<LangChangeEvent> {
return this.store.onLangChange;
}
get currentLang$(): Observable<string> {
return this.onLangChange.pipe(
startWith(this.currentLang),
map(() => this.currentLang),
);
}
/**
* An EventEmitter to listen to default lang change events
* onDefaultLangChange.subscribe((params: DefaultLangChangeEvent) => {
* // do something
* });
*/
get onDefaultLangChange() {
return this.store.onDefaultLangChange;
}
/**
* The default lang to fallback when translations are missing on the current lang
*/
get defaultLang() {
return this.store.defaultLang();
}
set defaultLang(lang: string) {
this.store.defaultLang.set(lang);
}
/**
* The lang currently used
*/
get currentLang() {
return this.store.currentLang();
}
set currentLang(lang: string) {
this.store.currentLang.set(lang);
}
/**
* an array of langs
*/
get langs() {
return this.store.langs();
}
get store() {
return this.isolate ? this.isolatedStore : this.globalStore;
}
/**
* a list of translations per lang
*/
get translations(): any {
return this.store.translations();
}
get usedTranslations() {
const result = {};
Object.keys(this.globalStore.usedKeys()).forEach(key => {
const value = this.instant(key);
if (key !== value && typeof value === 'string') {
Object.assign(result, {[key]: value});
}
});
return result;
}
/**
*
* @param globalStore an instance of the store (that is supposed to be unique)
* @param currentLoader An instance of the loader currently used
* @param compiler An instance of the compiler currently used
* @param parser An instance of the parser currently used
* @param missingTranslationHandler A handler for missing translations.
* @param platformId 서버사이드렌더링 체크 전용 값
* @param useDefaultLang whether we should use default language translation when current language translation is missing.
* @param isolate whether this service should use the store or not
* @param extend To make a child module extend (and use) translations from parent modules.
* @param defaultLanguage Set the default language using configuration
*/
constructor(private globalStore: TranslateStore,
public currentLoader: TranslateLoader,
public compiler: TranslateCompiler,
public parser: TranslateParser,
public missingTranslationHandler: MissingTranslationHandler,
@Inject(PLATFORM_ID) private platformId: string,
@Inject(USE_DEFAULT_LANG) private useDefaultLang: boolean = true,
@Inject(USE_STORE) private isolate: boolean = false,
@Inject(USE_EXTEND) private extend: boolean = false,
@Inject(DEFAULT_LANGUAGE) defaultLanguage: string) {
// 독립 된 스토어 구성
if (this.isolate) {
this.isolatedStore = new TranslateStore();
}
/** set the default language from configuration */
if (defaultLanguage) {
this.setDefaultLang(defaultLanguage);
}
effect(() => {
this.retrieveTranslations(this.defaultLang);
}, { allowSignalWrites: true });
effect(() => {
this.retrieveTranslations(this.currentLang);
}, { allowSignalWrites: true });
// 독립 된 스토어를 사용하는게 아니라면, 서버사이드렌더링 과정에서 사용한 번역 값들을 불러옴
if (!this.isolate) {
const transferState = inject(TransferState);
if (isPlatformServer(this.platformId)) {
// 서버사이드렌더링 과정에서 이용한 번역 값들은 전부 기록해둠
transferState.onSerialize(USED_TRANSLATIONS_FROM_SSR, () => this.usedTranslations);
} else {
// 서버사이드렌더링 과정에서 이용한 번역 값들을 일단 현재 언어에서 바로 사용할 수 있게 설정
const usedTranslations = transferState.get(USED_TRANSLATIONS_FROM_SSR, {});
if (typeof usedTranslations === 'object' && Object.keys(usedTranslations).length > 0) {
console.log(usedTranslations, ` 번역이 SSR로부터 반영됨`);
this.setTranslation(this.defaultLang, usedTranslations, true);
}
}
}
}
/**
* Sets the default language to use as a fallback
*/
public setDefaultLang(lang: string): void {
this.defaultLang = lang;
}
/**
* Changes the lang currently used
*/
public use(lang: string): Observable<any> {
this.currentLang = lang;
return this.store.onTranslationChange.pipe(
map(({ translations }) => translations[lang])
);
}
/**
* Retrieves the given translations
*/
private retrieveTranslations(lang: string): Observable<any> {
let pending: Observable<any>;
// if this language is unavailable or extend is true, ask for it
if (!isDefined(this._translationRequests[lang]) || this.extend) {
this._translationRequests[lang] ??= this.getTranslation(lang).pipe(
catchError((err) => {
this._translationRequests[lang] = undefined; // reset because it failed
return throwError(() => err);
})
);
pending = this._translationRequests[lang];
}
return pending;
}
/**
* Gets an object of translations for a given language with the current loader
* and passes it through the compiler
*/
public getTranslation(lang: string): Observable<any> {
const loadingTranslations = this.currentLoader.getTranslation(lang).pipe(
shareReplay(1),
take(1),
);
loadingTranslations.pipe(
map((res: Object) => this.compiler.compileTranslations(res, lang)),
take(1),
tap((res) => {
this.store.translations.update(trans => {
const shouldMerge = this.extend && this.translations[lang];
return Object.assign(trans, {
[lang]: shouldMerge ? mergeDeep(this.translations[lang], res) : res
});
});
})
).subscribe();
return loadingTranslations;
}
/**
* Manually sets an object of translations for a given language
* after passing it through the compiler
*/
public setTranslation(lang: string, translations: Object, shouldMerge: boolean = false): void {
translations = this.compiler.compileTranslations(translations, lang);
shouldMerge = (shouldMerge || this.extend) && this.translations[lang];
translations = shouldMerge ? mergeDeep(this.translations[lang], translations) : translations;
this.store.translations.update(trans => {
return Object.assign(trans, { [lang]: translations });
});
}
/**
* Returns an array of currently available langs
*/
public getLangs(): Array<string> {
return this.langs;
}
/**
* Add available langs
*/
public addLangs(langs: Array<string>): void {
const newLangs = langs.filter(l => !this.langs.includes(l));
if (newLangs.length) {
this.store.langs.set([...this.langs, ...newLangs]);
}
}
/**
* Returns the parsed result of the translations
*/
public getParsedResult(translations: any, key: any, interpolateParams?: Object): any {
let res: string | Observable<string>;
if (key instanceof Array) {
let result: any = {},
observables: boolean = false;
for (let k of key) {
result[k] = this.getParsedResult(translations, k, interpolateParams);
if (isObservable(result[k])) {
observables = true;
}
}
if (observables) {
const sources = key.map(k => isObservable(result[k]) ? result[k] : of(result[k] as string));
return forkJoin(sources).pipe(
map((arr: Array<string>) => {
let obj: any = {};
arr.forEach((value: string, index: number) => {
obj[key[index]] = value;
});
return obj;
})
);
}
return key.map(k => result[k]).join(', ');
}
if (translations) {
res = this.parser.interpolate(this.parser.getValue(translations, key), interpolateParams);
}
if (typeof res === "undefined" && this.defaultLang != null && this.defaultLang !== this.currentLang && this.useDefaultLang) {
res = this.parser.interpolate(this.parser.getValue(this.translations[this.defaultLang], key), interpolateParams);
}
if (typeof res === "undefined") {
let params: MissingTranslationHandlerParams = { key, translateService: this };
if (typeof interpolateParams !== 'undefined') {
params.interpolateParams = interpolateParams;
}
res = this.missingTranslationHandler.handle(params);
}
return typeof res !== "undefined" ? res : key;
}
/**
* Gets the translated value of a key (or an array of keys)
* @returns the translated key, or an object of translated keys
*/
public get(key: string | Array<string>, interpolateParams?: Object): Observable<string | any> {
if (!isDefined(key) || !key.length) {
throw new Error(`Parameter "key" required`);
}
if (isPlatformServer(this.platformId) && !this.isolate) {
this.store.markAsUsed(key);
}
let res = this.getParsedResult(this.translations[this.currentLang], key, interpolateParams);
return isObservable(res) ? res : of(res);
}
/**
* Returns a translation instantly from the internal state of loaded translation.
* All rules regarding the current language, the preferred language of even fallback languages will be used except any promise handling.
*/
public instant(key: string | Array<string>, interpolateParams?: Object): string | any {
if (!isDefined(key) || !key.length) {
throw new Error(`Parameter "key" required`);
}
let res = this.getParsedResult(this.translations[this.currentLang], key, interpolateParams);
if (isObservable(res)) {
if (key instanceof Array) {
let obj: any = {};
key.forEach((value: string, index: number) => {
obj[key[index]] = key[index];
});
return obj;
}
return key;
} else {
if (isPlatformServer(this.platformId) && !this.isolate) {
this.store.markAsUsed(key);
}
return res;
}
}
/**
* Sets the translated value of a key, after compiling it
*/
public set(key: string, value: string, lang: string = this.currentLang): void {
this.store.translations.update(translations => {
return Object.assign(translations, {
[lang]: Object.assign(translations[lang] || {}, {
[key]: this.compiler.compile(value, lang),
}),
})
});
}
/**
* Allows to reload the lang file from the file
*/
public reloadLang(lang: string): Observable<any> {
this.resetLang(lang);
return this.getTranslation(lang);
}
/**
* Deletes inner translation
*/
public resetLang(lang: string): void {
this._translationRequests[lang] = undefined;
this.store.translations.update(translations => {
return Object.assign(translations, { [lang]: undefined });
});
}
/**
* Returns the language code name from the browser, e.g. "de"
*/
public getBrowserLang(): string {
if (typeof window === 'undefined' || typeof window.navigator === 'undefined') {
return undefined;
}
let browserLang: any = window.navigator.languages ? window.navigator.languages[0] : null;
browserLang = browserLang || window.navigator.language || window.navigator.browserLanguage || window.navigator.userLanguage;
if (typeof browserLang === 'undefined') {
return undefined
}
if (browserLang.indexOf('-') !== -1) {
browserLang = browserLang.split('-')[0];
}
if (browserLang.indexOf('_') !== -1) {
browserLang = browserLang.split('_')[0];
}
return browserLang;
}
/**
* Returns the culture language code name from the browser, e.g. "de-DE"
*/
public getBrowserCultureLang(): string {
if (typeof window === 'undefined' || typeof window.navigator === 'undefined') {
return undefined;
}
let browserCultureLang: any = window.navigator.languages ? window.navigator.languages[0] : null;
browserCultureLang = browserCultureLang || window.navigator.language || window.navigator.browserLanguage || window.navigator.userLanguage;
return browserCultureLang;
}
}
import { effect, Injectable, signal } from "@angular/core";
import { DefaultLangChangeEvent, LangChangeEvent, TranslationChangeEvent } from "./translate.service";
import { toObservable } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { equals } from './util';
@Injectable({
providedIn: 'root',
})
export class TranslateStore {
/**
* The default lang to fallback when translations are missing on the current lang
*/
public defaultLang = signal<string | null>(null);
/**
* The lang currently used
*/
public currentLang = signal<string | null>(null);
/**
* a list of translations per lang
*/
public translations = signal<{ [key: string]: any }>({}, { equal: equals });
/**
* an array of langs
*/
public langs = signal<string[]>([]);
/**
* the keys marked as 'used'
*/
public usedKeys = signal<{ [key: string]: boolean }>({}, { equal: equals });
/**
* An EventEmitter to listen to translation change events
* onTranslationChange.subscribe((params: TranslationChangeEvent) => {
* // do something
* });
*/
public onTranslationChange: Observable<TranslationChangeEvent> = toObservable(this.translations).pipe(
map((translations) => {
return { lang: this.currentLang(), translations } as TranslationChangeEvent;
}),
);
/**
* An EventEmitter to listen to lang change events
* onLangChange.subscribe((params: LangChangeEvent) => {
* // do something
* });
*/
public onLangChange: Observable<LangChangeEvent> = toObservable(this.currentLang).pipe(
map((lang) => {
return { lang, translations: this.translations } as LangChangeEvent;
}),
);
/**
* An EventEmitter to listen to default lang change events
* onDefaultLangChange.subscribe((params: DefaultLangChangeEvent) => {
* // do something
* });
*/
public onDefaultLangChange: Observable<DefaultLangChangeEvent> = toObservable(this.defaultLang).pipe(
map((lang) => {
return { lang, translations: this.translations } as LangChangeEvent;
}),
);
constructor() {
effect(() => {
// if there is no current lang, use the one that we just set
if (this.defaultLang() && !this.currentLang()) {
this.currentLang.set(this.defaultLang());
}
}, { allowSignalWrites: true });
effect(() => {
// if there is no default lang, use the one that we just set
if (!this.defaultLang() && this.currentLang()) {
this.defaultLang.set(this.currentLang());
}
}, { allowSignalWrites: true });
effect(() => {
const allLangs = this.langs();
const activeLangs = Object.keys(this.translations());
if (activeLangs.length !== allLangs.length || activeLangs.some(l => !allLangs.includes(l))) {
this.langs.update(_langs => {
return Array.from(new Set([..._langs, ...activeLangs]));
});
}
}, { allowSignalWrites: true });
}
markAsUsed(key: string | string[]) {
if (typeof key !== 'string' && !Array.isArray(key)) {
return; // do nothing for empty key
}
this.usedKeys.update(usedKeys => {
const keys = Array.isArray(key) ? key : [key];
keys.filter(k => k.includes('.')).forEach(k => {
usedKeys[k] = true;
});
return usedKeys;
});
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment