Last active
May 16, 2024 07:30
-
-
Save leo6104/1d8da771f1cb3da237ca3ba06fe4fdea to your computer and use it in GitHub Desktop.
Angular Translate library with Signal API (for optimized rendering performance)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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