core-app-api: implement custom resource loader for i18n

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-09-14 15:24:29 +02:00
parent e149123fa3
commit a76aaf87bf
2 changed files with 139 additions and 74 deletions
@@ -270,19 +270,15 @@ describe('AppTranslationApiImpl', () => {
});
it('should handle interrupted loads gracefully', async () => {
const delayedLoader = (msg: string) => () =>
new Promise<{ default: { foo: string } }>(resolve =>
setTimeout(() => resolve({ default: { foo: msg } }), 100),
);
const translationApi = AppTranslationApiImpl.create({
supportedLanguages: ['en', 'sv', 'no'],
resources: [
createTranslationResource({
ref: plainRef,
translations: {
en: delayedLoader('foo'),
sv: delayedLoader('Föö'),
no: delayedLoader('Føø'),
en: () => Promise.resolve({ default: { foo: 'foo' } }),
sv: () => Promise.resolve({ default: { foo: 'Föö' } }),
no: () => Promise.resolve({ default: { foo: 'Føø' } }),
},
}),
],
@@ -301,4 +297,43 @@ describe('AppTranslationApiImpl', () => {
const snapshot = assertReady(await nextPromise);
expect(snapshot.t('foo')).toBe('Føø');
});
it('should only emit changes', async () => {
const translationApi = AppTranslationApiImpl.create({
supportedLanguages: ['en', 'dk', 'sv', 'no'],
resources: [
createTranslationResource({
ref: plainRef,
translations: {
en: () => Promise.resolve({ default: { foo: 'foo' } }),
dk: () => Promise.resolve({ default: { foo: 'F🥔🥔' } }),
sv: () => Promise.resolve({ default: { foo: 'Föö' } }),
no: () => Promise.resolve({ default: { foo: 'Føø' } }),
},
}),
],
});
const translations = new Array<string | null>();
await new Promise<void>(resolve => {
const subscription = translationApi.translation$(plainRef).subscribe({
next(snapshot) {
const translation = snapshot.ready ? snapshot.t('foo') : null;
translations.push(translation);
if (translation === 'foo') {
translationApi.changeLanguage('dk'); // Not visible
translationApi.changeLanguage('sv');
} else if (translation === 'Föö') {
translationApi.changeLanguage('no');
} else if (translation === 'Føø') {
resolve();
subscription.unsubscribe();
}
},
});
});
expect(translations).toEqual(['foo', null, 'Föö', null, 'Føø']);
});
});
@@ -20,11 +20,7 @@ import {
TranslationRef,
TranslationResource,
} from '@backstage/core-plugin-api/alpha';
import {
BackendModule,
createInstance as createI18n,
type i18n,
} from 'i18next';
import { createInstance as createI18n, type i18n as I18n } from 'i18next';
import ObservableImpl from 'zen-observable';
// Internal import to avoid code duplication, this will lead to duplication in build output
@@ -59,17 +55,30 @@ function removeNulls(
}
/**
* A wrapper around a i18next plugin that helps us lazy load resources that have been
* registered in the app or provided through translation refs.
*
* Since all resources are registered before use it is safe to just look at the
* existing resources when loading a namespace + language tuple.
* The built-in i18next backend loading logic doesn't handle on the fly switches
* of language very well. It gets a bit confused about whether resources are actually
* loaded or not, so instead we implement our own resource loader.
*/
class LazyResources {
class ResourceLoader {
/** The resources that have been registered */
#seen = new Set<TranslationResource>();
/** Loaded resources by loader key */
#loaded = new Set<string>();
/** Resource loading promises by loader key */
#loading = new Map<string, Promise<void>>();
/** Loaders for each resource language */
#loaders = new Map<string, InternalTranslationResourceLoader>();
addResource(resource: TranslationResource) {
constructor(
private readonly onLoad: (loaded: {
language: string;
namespace: string;
messages: Record<string, string | null>;
}) => void,
) {}
addTranslationResource(resource: TranslationResource) {
if (this.#seen.has(resource)) {
return;
}
@@ -86,30 +95,45 @@ class LazyResources {
}
}
hasResource(lng: string, ns: string) {
return this.#loaders.has(this.#getLoaderKey(lng, ns));
#getLoaderKey(language: string, namespace: string) {
return `${language}/${namespace}`;
}
async #loadResource(lng: string, ns: string) {
const loader = this.#loaders.get(this.#getLoaderKey(lng, ns));
needsLoading(language: string, namespace: string) {
const key = this.#getLoaderKey(language, namespace);
const loader = this.#loaders.get(key);
if (!loader) {
return undefined;
return false;
}
return loader().then(result => removeNulls(result.messages));
return !this.#loaded.has(key);
}
#getLoaderKey(lng: string, ns: string) {
return `${lng}/${ns}`;
}
async load(language: string, namespace: string): Promise<void> {
const key = this.#getLoaderKey(language, namespace);
plugin: BackendModule = {
type: 'backend',
init() {},
read: (lng, ns) => this.#loadResource(lng, ns),
save() {},
create() {},
};
const loader = this.#loaders.get(key);
if (!loader) {
return;
}
if (this.#loaded.has(key)) {
return;
}
const loading = this.#loading.get(key);
if (loading) {
await loading;
return;
}
const load = loader().then(result => {
this.onLoad({ language, namespace, messages: result.messages });
this.#loaded.add(key);
});
this.#loading.set(key, load);
await load;
}
}
/** @alpha */
@@ -135,7 +159,6 @@ export class AppTranslationApiImpl implements AppTranslationApi {
if (!languages.includes(DEFAULT_LANGUAGE)) {
throw new Error(`Supported languages must include '${DEFAULT_LANGUAGE}'`);
}
const lazyResources = new LazyResources();
const i18n = createI18n({
fallbackLng: DEFAULT_LANGUAGE,
supportedLngs: languages,
@@ -145,43 +168,49 @@ export class AppTranslationApiImpl implements AppTranslationApi {
ns: [],
defaultNS: false,
fallbackNS: false,
}).use(lazyResources.plugin);
});
i18n.init();
const loader = new ResourceLoader(loaded => {
i18n.addResourceBundle(
loaded.language,
loaded.namespace,
removeNulls(loaded.messages),
false, // do not merge with existing translations
true, // overwrite translations
);
});
const resources = options?.resources || [];
// Iterate in reverse, giving higher priority to resources registered later
for (let i = resources.length - 1; i >= 0; i--) {
const resource = resources[i];
if (resource.$$type === '@backstage/TranslationResource') {
lazyResources.addResource(resource);
loader.addTranslationResource(resource);
} else if (resource.$$type === '@backstage/TranslationMessages') {
// Overrides for default messages, created with createTranslationMessages and installed via app
i18n.addResourceBundle(
DEFAULT_LANGUAGE,
resource.id,
removeNulls(resource.messages),
true,
false,
true, // merge with existing translations
false, // do not overwrite translations
);
}
}
return new AppTranslationApiImpl(i18n, lazyResources, languages);
return new AppTranslationApiImpl(i18n, loader, languages);
}
#i18n: i18n;
#lazyResources: LazyResources;
#i18n: I18n;
#loader: ResourceLoader;
#language: string;
#languages: string[];
private constructor(
i18n: i18n,
lazyResources: LazyResources,
languages: string[],
) {
private constructor(i18n: I18n, loader: ResourceLoader, languages: string[]) {
this.#i18n = i18n;
this.#lazyResources = lazyResources;
this.#loader = loader;
this.#language = DEFAULT_LANGUAGE;
this.#languages = languages;
}
@@ -227,25 +256,28 @@ export class AppTranslationApiImpl implements AppTranslationApi {
const loadResource = () => {
loadTicket = {};
const ticket = loadTicket;
this.#i18n.loadNamespaces(internalRef.id, error => {
if (ticket !== loadTicket) {
return;
}
if (error) {
subscriber.error(Array.isArray(error) ? error[0] : error);
} else {
const snapshot = this.#createSnapshot(internalRef);
if (snapshot.ready || lastSnapshotWasReady) {
lastSnapshotWasReady = snapshot.ready;
subscriber.next(snapshot);
this.#loader.load(this.#language, internalRef.id).then(
() => {
if (ticket === loadTicket) {
const snapshot = this.#createSnapshot(internalRef);
if (snapshot.ready || lastSnapshotWasReady) {
lastSnapshotWasReady = snapshot.ready;
subscriber.next(snapshot);
}
}
}
});
},
error => {
if (ticket === loadTicket) {
subscriber.error(Array.isArray(error) ? error[0] : error);
}
},
);
};
const onChange = () => {
const snapshot = this.#createSnapshot(internalRef);
if (lastSnapshotWasReady && !snapshot.ready) {
lastSnapshotWasReady = snapshot.ready;
subscriber.next(snapshot);
}
@@ -254,15 +286,20 @@ export class AppTranslationApiImpl implements AppTranslationApi {
}
};
this.#i18n.on('initialized', onChange);
const wasInitialized = this.#i18n.isInitialized;
if (!wasInitialized) {
this.#i18n.on('initialized', onChange);
}
this.#i18n.on('languageChanged', onChange);
if (this.#needsToLoadResource(internalRef)) {
if (this.#loader.needsLoading(this.#language, internalRef.id)) {
loadResource();
}
return () => {
this.#i18n.off('initialized', onChange);
if (!wasInitialized) {
this.#i18n.off('initialized', onChange);
}
this.#i18n.off('languageChanged', onChange);
};
});
@@ -271,7 +308,7 @@ export class AppTranslationApiImpl implements AppTranslationApi {
#createSnapshot<TMessages extends { [key in string]: string }>(
internalRef: InternalTranslationRef<string, TMessages>,
): TranslationSnapshot<TMessages> {
if (this.#needsToLoadResource(internalRef)) {
if (this.#loader.needsLoading(this.#language, internalRef.id)) {
return { ready: false };
}
@@ -289,17 +326,10 @@ export class AppTranslationApiImpl implements AppTranslationApi {
};
}
#needsToLoadResource({ id }: InternalTranslationRef): boolean {
if (!this.#lazyResources.hasResource(this.#language, id)) {
return false;
}
return !this.#i18n.hasResourceBundle(this.#language, id);
}
#registerDefaultResource(internalRef: InternalTranslationRef): void {
const defaultResource = internalRef.getDefaultResource();
if (defaultResource) {
this.#lazyResources.addResource(defaultResource);
this.#loader.addTranslationResource(defaultResource);
}
}
}