Merge pull request #19898 from backstage/rugvip/i18n-resources

i18n: refactor TranslationRef and rework TranslationMessages and TranslationResource
This commit is contained in:
Patrik Oldsberg
2023-09-14 11:52:04 +02:00
committed by GitHub
29 changed files with 937 additions and 511 deletions
-85
View File
@@ -1,85 +0,0 @@
## API Report File for "@backstage/core-app-api"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AppTranslationApi } from '@backstage/core-plugin-api/alpha';
import { i18n } from 'i18next';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
export class AppTranslationApiImpl implements AppTranslationApi {
// (undocumented)
addLazyResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: Record<
string,
() => Promise<{
messages: TranslationMessages<TranslationRef>;
}>
>,
): void;
// (undocumented)
addResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: Record<
string,
TranslationMessages<TranslationRef<Messages>>
>,
): void;
// (undocumented)
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void;
// (undocumented)
static create(options?: ExperimentalI18n): AppTranslationApiImpl;
// (undocumented)
getI18n(): i18n;
// (undocumented)
initMessages(options?: ExperimentalI18n): void;
}
// @alpha (undocumented)
export function createTranslationResource<T extends TranslationRef>(options: {
ref: T;
messages?: Record<string, TranslationMessages<T>>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<T>;
}>
>;
}): {
ref: T;
messages?: Record<string, TranslationMessages<T>> | undefined;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<T>;
}>
>;
};
// @alpha (undocumented)
export type ExperimentalI18n = {
supportedLanguages: string[];
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: Record<string, TranslationMessages<TranslationRef>>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<TranslationRef>;
}>
>;
}>;
};
// @alpha (undocumented)
export type TranslationMessages<T> = T extends TranslationRef<infer R>
? Partial<R>
: never;
// (No @packageDocumentation comment for this package)
```
+5 -13
View File
@@ -64,7 +64,8 @@ import { SessionState } from '@backstage/core-plugin-api';
import { StorageApi } from '@backstage/core-plugin-api';
import { StorageValueSnapshot } from '@backstage/core-plugin-api';
import { SubRouteRef } from '@backstage/core-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
import { TranslationMessages } from '@backstage/core-plugin-api/alpha';
import { TranslationResource } from '@backstage/core-plugin-api/alpha';
// @public
export class AlertApiForwarder implements AlertApi {
@@ -221,19 +222,10 @@ export type AppOptions = {
themes: (Partial<AppTheme> & Omit<AppTheme, 'theme'>)[];
configLoader?: AppConfigLoader;
bindRoutes?(context: { bind: AppRouteBinder }): void;
__experimentalI18n?: {
supportedLanguages: string[];
__experimentalTranslations?: {
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: Record<string, TranslationMessages<TranslationRef>>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<TranslationRef>;
}>
>;
}>;
supportedLanguages?: string[];
resources?: Array<TranslationMessages | TranslationResource>;
};
};
-4
View File
@@ -7,14 +7,10 @@
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
createTranslationMessages,
createTranslationRef,
createTranslationResource,
} from '@backstage/core-plugin-api/alpha';
import { AppTranslationApiImpl } from './AppTranslationImpl';
import i18next from 'i18next';
@@ -70,112 +74,68 @@ describe('AppTranslationApiImpl', () => {
});
it('should init messages correctly', () => {
const useResourcesMock = jest.spyOn(
const addMessagesMock = jest.spyOn(
AppTranslationApiImpl.prototype,
'addResources',
'addMessages',
);
const useLazyResourcesMock = jest.spyOn(
const addLazyResourcesMock = jest.spyOn(
AppTranslationApiImpl.prototype,
'addLazyResources',
);
const ref = createTranslationRef({
id: 'ref-id',
messages: {
key1: '',
key: '',
},
});
const options = {
supportedLanguages: ['en'],
messages: [
{
ref,
messages: {
en: { key1: 'value1' },
},
lazyMessages: {
en: () => Promise.resolve({ key2: 'value2' }),
} as any,
},
],
};
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en'],
const overrides = createTranslationMessages({
ref,
messages: { key: 'value1' },
});
const resource = createTranslationResource({
ref,
translations: {
en: () =>
Promise.resolve({
default: {
key: 'value2',
},
}),
},
});
instance.initMessages(options);
expect(useResourcesMock).toHaveBeenCalledWith(
options.messages[0].ref,
options.messages[0].messages,
);
expect(useLazyResourcesMock).toHaveBeenCalledWith(
options.messages[0].ref,
options.messages[0].lazyMessages,
);
AppTranslationApiImpl.create({
supportedLanguages: ['en'],
resources: [overrides, resource],
});
expect(addMessagesMock).toHaveBeenCalledWith(overrides);
expect(addLazyResourcesMock).toHaveBeenCalledWith(resource);
});
it('should useResources correctly', () => {
const i18nMock = i18next.createInstance() as any;
const useReturnMock = i18nMock.use();
jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock);
jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock);
const ref = createTranslationRef({
id: 'ref-id',
messages: {
key1: 'value1',
},
resources: {
en: {
key1: 'value2',
},
},
});
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en'],
});
instance.addResources(ref);
expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith(
'en',
'ref-id',
{ key1: 'value2' },
true,
false,
const addLazyResourcesMock = jest.spyOn(
AppTranslationApiImpl.prototype,
'addLazyResources',
);
});
it('should useLazyResources correctly', () => {
const i18nMock = i18next.createInstance() as any;
const useReturnMock = i18nMock.use();
jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock);
jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock);
const ref = createTranslationRef({
id: 'ref-id',
messages: {
key1: 'value1',
},
lazyResources: {
en: () => Promise.resolve({ messages: { key1: 'value2' } }),
translations: {
en: () => Promise.resolve({ default: { key1: 'value2' } }),
},
});
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en'],
});
instance.addLazyResources(ref);
setTimeout(() => {
expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith(
'en',
'ref-id',
{ key1: 'value2' },
true,
false,
);
});
instance.addResource(ref);
expect(addLazyResourcesMock).toHaveBeenCalledTimes(1);
expect(addLazyResourcesMock.mock.calls[0][0].id).toBe('ref-id');
});
});
@@ -16,28 +16,39 @@
import {
AppTranslationApi,
TranslationMessages,
TranslationRef,
TranslationResource,
} from '@backstage/core-plugin-api/alpha';
import i18next, { type i18n } from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import { TranslationMessages } from '../../../alpha';
// Internal import to avoid code duplication, this will lead to duplication in build output
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalTranslationResource } from '../../../../../core-plugin-api/src/translation/TranslationResource';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalTranslationRef } from '../../../../../core-plugin-api/src/translation/TranslationRef';
const DEFAULT_LANGUAGE = 'en';
/** @alpha */
export type ExperimentalI18n = {
supportedLanguages: string[];
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: Record<string, TranslationMessages<TranslationRef>>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>;
}>;
supportedLanguages?: string[];
resources?: Array<TranslationMessages | TranslationResource>;
};
function removeNulls(
messages: Record<string, string | null>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(messages).filter(
(e): e is [string, string] => e[1] !== null,
),
);
}
/** @alpha */
export class AppTranslationApiImpl implements AppTranslationApi {
static create(options?: ExperimentalI18n) {
@@ -46,8 +57,8 @@ export class AppTranslationApiImpl implements AppTranslationApi {
i18n.use(LanguageDetector);
i18n.init({
fallbackLng: options?.fallbackLanguage || 'en',
supportedLngs: options?.supportedLanguages || ['en'],
fallbackLng: options?.fallbackLanguage || DEFAULT_LANGUAGE,
supportedLngs: options?.supportedLanguages || [DEFAULT_LANGUAGE],
interpolation: {
escapeValue: false,
},
@@ -59,69 +70,52 @@ export class AppTranslationApiImpl implements AppTranslationApi {
return new AppTranslationApiImpl(i18n, options);
}
private readonly cache = new WeakSet<TranslationRef>();
private readonly lazyCache = new WeakMap<TranslationRef, Set<string>>();
private readonly cache = new Set<string>();
private readonly lazyCache = new Map<string, Set<string>>();
getI18n() {
return this.i18n;
}
initMessages(options?: ExperimentalI18n) {
if (options?.messages?.length) {
options.messages.forEach(appMessage => {
if (appMessage.messages) {
this.addResources(appMessage.ref, appMessage.messages);
}
if (appMessage.lazyMessages) {
this.addLazyResources(appMessage.ref, appMessage.lazyMessages);
}
});
for (const resource of options?.resources || []) {
if (resource.$$type === '@backstage/TranslationResource') {
this.addLazyResources(resource);
} else if (resource.$$type === '@backstage/TranslationMessages') {
// Overrides for default messages, created with createTranslationMessages and installed via app
this.addMessages(resource);
}
}
}
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void {
this.addResources(translationRef);
this.addLazyResources(translationRef);
addResource(translationRef: TranslationRef): void {
const internalRef = toInternalTranslationRef(translationRef);
const defaultResource = internalRef.getDefaultResource();
if (defaultResource) {
this.addLazyResources(defaultResource);
}
}
addResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: Record<
string,
TranslationMessages<TranslationRef<Messages>>
>,
) {
const resources = initResources || translationRef.getResources();
if (!resources || this.cache.has(translationRef)) {
addMessages(messages: TranslationMessages) {
if (this.cache.has(messages.id)) {
return;
}
this.cache.add(translationRef);
Object.entries(resources).forEach(([language, messages]) => {
this.i18n.addResourceBundle(
language,
translationRef.getId(),
messages,
true,
false,
);
});
this.cache.add(messages.id);
this.i18n.addResourceBundle(
DEFAULT_LANGUAGE,
messages.id,
removeNulls(messages.messages),
true,
false,
);
}
addLazyResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>,
) {
let cache = this.lazyCache.get(translationRef);
addLazyResources(resource: TranslationResource) {
let cache = this.lazyCache.get(resource.id);
if (!cache) {
cache = new Set();
this.lazyCache.set(translationRef, cache);
this.lazyCache.set(resource.id, cache);
}
const {
@@ -136,8 +130,8 @@ export class AppTranslationApiImpl implements AppTranslationApi {
return;
}
const namespace = translationRef.getId();
const lazyResources = initResources || translationRef.getLazyResources();
const internalResource = toInternalTranslationResource(resource);
const namespace = internalResource.id;
Promise.allSettled((options.supportedLngs || []).map(addLanguage)).then(
results => {
@@ -160,7 +154,9 @@ export class AppTranslationApiImpl implements AppTranslationApi {
loadBackend = reloadResources([language], [namespace]);
}
const loadLazyResources = lazyResources?.[language];
const loadLazyResources = internalResource.resources.find(
entry => entry.language === language,
)?.loader;
if (!loadLazyResources) {
await loadBackend;
@@ -260,7 +260,7 @@ describe('Integration Test', () => {
expect(screen.getByText('extLink4: <none>')).toBeInTheDocument();
});
it('runs success with __experimentalI18n', async () => {
it('runs success with __experimentalTranslations', async () => {
const app = new AppManager({
apis: [noOpAnalyticsApi],
defaultApis: [],
@@ -275,7 +275,7 @@ describe('Integration Test', () => {
extRouteRef2: plugin2RouteRef,
});
},
__experimentalI18n: {
__experimentalTranslations: {
supportedLanguages: ['en'],
},
});
+1 -1
View File
@@ -179,7 +179,7 @@ export class AppManager implements BackstageApp {
this.bindRoutes = options.bindRoutes;
this.apiFactoryRegistry = new ApiFactoryRegistry();
this.appTranslationApi = AppTranslationApiImpl.create(
options.__experimentalI18n,
options.__experimentalTranslations,
);
}
+8 -25
View File
@@ -27,7 +27,10 @@ import {
FeatureFlag,
} from '@backstage/core-plugin-api';
import { AppConfig } from '@backstage/config';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
import {
TranslationMessages,
TranslationResource,
} from '@backstage/core-plugin-api/alpha';
/**
* Props for the `BootErrorPage` component of {@link AppComponents}.
@@ -178,16 +181,6 @@ export type AppRouteBinder = <
>,
) => void;
/**
* TODO: To be remove when TranslationMessages in packages/core-app-api/src/app/TranslationResource.ts
* come to be public
*
* @ignore
* */
type TranslationMessages<T> = T extends TranslationRef<infer R>
? Partial<R>
: never;
/**
* The options accepted by {@link createSpecializedApp}.
*
@@ -290,21 +283,11 @@ export type AppOptions = {
*/
bindRoutes?(context: { bind: AppRouteBinder }): void;
/**
* TODO: Change to ExperimentalI18n type when packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts
* become to public
*/
__experimentalI18n?: {
supportedLanguages: string[];
// TODO: Change to ExperimentalI18n type when packages/core-app-api/src/apis/implementations/AppTranslationApi/AppTranslationImpl.ts
__experimentalTranslations?: {
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: Record<string, TranslationMessages<TranslationRef>>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>;
}>;
supportedLanguages?: string[];
resources?: Array<TranslationMessages | TranslationResource>;
};
};
+142 -61
View File
@@ -7,24 +7,64 @@ import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { i18n } from 'i18next';
import { ReactNode } from 'react';
import { TranslationMessages as TranslationMessages_2 } from '@backstage/core-plugin-api/alpha';
import { TranslationRef as TranslationRef_2 } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
export type AppTranslationApi = {
getI18n(): i18n;
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void;
addResource(resource: TranslationRef): void;
};
// @alpha (undocumented)
export const appTranslationApiRef: ApiRef<AppTranslationApi>;
// @alpha (undocumented)
export const createTranslationRef: <
Messages extends Record<keyof Messages, string> = {},
// @alpha
export function createTranslationMessages<
TId extends string,
TMessages extends {
[key in string]: string;
},
TFull extends boolean,
>(
config: TranslationRefConfig<Messages>,
) => TranslationRef<Messages>;
options: TranslationMessagesOptions<TId, TMessages, TFull>,
): TranslationMessages<TId, TMessages, TFull>;
// @alpha (undocumented)
export function createTranslationRef<
TId extends string,
TMessages extends {
[key in string]: string;
},
TTranslations extends {
[language in string]: () => Promise<{
default: {
[key in keyof TMessages]: string | null;
};
}>;
},
>(
config: TranslationRefOptions<TId, TMessages, TTranslations>,
): TranslationRef<TId, TMessages>;
// @alpha (undocumented)
export function createTranslationResource<
TId extends string,
TMessages extends {
[key in string]: string;
},
TTranslations extends {
[language in string]: () => Promise<{
default:
| TranslationMessages_2<TId>
| {
[key in keyof TMessages]: string | null;
};
}>;
},
>(
options: TranslationResourceOptions<TId, TMessages, TTranslations>,
): TranslationResource<TId>;
// @alpha
export interface PluginOptionsProviderProps {
@@ -37,76 +77,115 @@ export interface PluginOptionsProviderProps {
// @alpha
export const PluginProvider: (props: PluginOptionsProviderProps) => JSX.Element;
// @alpha
export interface TranslationMessages<
TId extends string = string,
TMessages extends {
[key in string]: string;
} = {
[key in string]: string;
},
TFull extends boolean = boolean,
> {
// (undocumented)
$$type: '@backstage/TranslationMessages';
full: TFull;
id: TId;
messages: TMessages;
}
// @alpha
export interface TranslationMessagesOptions<
TId extends string,
TMessages extends {
[key in string]: string;
},
TFull extends boolean,
> {
// (undocumented)
full?: TFull;
// (undocumented)
messages: false extends TFull
? {
[key in keyof TMessages]?: string | null;
}
: {
[key in keyof TMessages]: string | null;
};
// (undocumented)
ref: TranslationRef_2<TId, TMessages>;
}
// @alpha (undocumented)
export type TranslationOptions<
Messages extends Record<keyof Messages, string> = Record<string, string>,
> = Messages;
export interface TranslationOptions {}
// @alpha (undocumented)
export interface TranslationRef<
Messages extends Record<keyof Messages, string> = Record<string, string>,
TId extends string = string,
TMessages extends {
[key in string]: string;
} = {
[key in string]: string;
},
> {
// (undocumented)
getDefaultMessages(): Messages;
$$type: '@backstage/TranslationRef';
// (undocumented)
getId(): string;
id: TId;
// (undocumented)
getLazyResources():
| Record<
string,
() => Promise<{
messages: Messages;
}>
>
| undefined;
// (undocumented)
getResources(): Record<string, Messages> | undefined;
T: TMessages;
}
// @alpha (undocumented)
export interface TranslationRefConfig<
Messages extends Record<keyof Messages, string>,
export interface TranslationRefOptions<
TId extends string,
TMessages extends {
[key in string]: string;
},
TTranslations extends {
[language in string]: () => Promise<{
default: {
[key in keyof TMessages]: string | null;
};
}>;
},
> {
// (undocumented)
id: string;
id: TId;
// (undocumented)
lazyResources?: Record<
string,
() => Promise<{
messages: Messages;
}>
>;
messages: TMessages;
// (undocumented)
messages: Messages;
// (undocumented)
resources?: Record<string, Messages>;
translations?: TTranslations;
}
// @alpha (undocumented)
export class TranslationRefImpl<Messages extends Record<keyof Messages, string>>
implements TranslationRef<Messages>
{
export interface TranslationResource<TId extends string = string> {
// (undocumented)
static create<Messages extends Record<keyof Messages, string>>(
config: TranslationRefConfig<Messages>,
): TranslationRefImpl<Messages>;
$$type: '@backstage/TranslationResource';
// (undocumented)
getDefaultMessages(): Messages;
id: TId;
}
// @alpha (undocumented)
export interface TranslationResourceOptions<
TId extends string,
TMessages extends {
[key in string]: string;
},
TTranslations extends {
[language in string]: () => Promise<{
default:
| TranslationMessages_2<TId>
| {
[key in keyof TMessages]: string | null;
};
}>;
},
> {
// (undocumented)
getId(): string;
ref: TranslationRef_2<TId, TMessages>;
// (undocumented)
getLazyResources():
| Record<
string,
() => Promise<{
messages: Messages;
}>
>
| undefined;
// (undocumented)
getResources(): Record<string, Messages> | undefined;
// (undocumented)
toString(): string;
translations: TTranslations;
}
// @alpha
@@ -116,13 +195,15 @@ export function usePluginOptions<
// @alpha (undocumented)
export const useTranslationRef: <
Messages extends Record<keyof Messages, string>,
TMessages extends {
[x: string]: string;
},
>(
translationRef: TranslationRef<Messages>,
) => <Tkey extends keyof Messages>(
key: Tkey,
translationRef: TranslationRef<string, TMessages>,
) => <TKey extends keyof TMessages & string>(
key: TKey,
options?: TranslationOptions,
) => Messages[Tkey];
) => TMessages[TKey];
// (No @packageDocumentation comment for this package)
```
@@ -22,14 +22,12 @@ import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
export type AppTranslationApi = {
getI18n(): i18n;
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void;
addResource(resource: TranslationRef): void;
};
/**
* @alpha
*/
export const appTranslationApiRef: ApiRef<AppTranslationApi> = createApiRef({
id: 'core.apptranslation',
id: 'core.translation',
});
@@ -0,0 +1,92 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createTranslationMessages } from './TranslationMessages';
import { createTranslationRef } from './TranslationRef';
const ref = createTranslationRef({
id: 'counting',
messages: {
one: 'one',
two: 'two',
three: 'three',
} as const,
});
describe('createTranslationMessages', () => {
it('should create a partial message set', () => {
expect(
createTranslationMessages({
ref,
messages: {
one: 'uno',
},
}),
).toEqual({
$$type: '@backstage/TranslationMessages',
id: 'counting',
full: false,
messages: {
one: 'uno',
},
});
});
it('should create a full message set', () => {
expect(
createTranslationMessages({
ref,
full: true,
messages: {
one: 'uno',
two: 'dos',
three: null,
},
}),
).toEqual({
$$type: '@backstage/TranslationMessages',
id: 'counting',
full: true,
messages: {
one: 'uno',
two: 'dos',
three: null,
},
});
});
it('should require all messages in a full set', () => {
expect(
createTranslationMessages({
ref,
full: true,
// @ts-expect-error
messages: {
one: 'uno',
two: 'dos',
},
}),
).toEqual({
$$type: '@backstage/TranslationMessages',
id: 'counting',
full: true,
messages: {
one: 'uno',
two: 'dos',
},
});
});
});
@@ -0,0 +1,80 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
/**
* Represents a collection of messages to be provided for a given translation ref.
*
* @alpha
* @remarks
*
* This collection of messages can either be used directly as an override for the
* default messages, or it can be used to provide translations for a language by
* by being referenced by a {@link TranslationResource}.
*/
export interface TranslationMessages<
TId extends string = string,
TMessages extends { [key in string]: string } = { [key in string]: string },
TFull extends boolean = boolean,
> {
$$type: '@backstage/TranslationMessages';
/** The ID of the translation ref that these messages are for */
id: TId;
/** Whether or not these messages override all known messages */
full: TFull;
/** The messages provided for the given translation ref */
messages: TMessages;
}
/**
* Options for {@link createTranslationMessages}.
*
* @alpha
*/
export interface TranslationMessagesOptions<
TId extends string,
TMessages extends { [key in string]: string },
TFull extends boolean,
> {
ref: TranslationRef<TId, TMessages>;
full?: TFull;
messages: false extends TFull
? { [key in keyof TMessages]?: string | null }
: { [key in keyof TMessages]: string | null };
}
/**
* Creates a collection of messages for a given translation ref.
*
* @alpha
*/
export function createTranslationMessages<
TId extends string,
TMessages extends { [key in string]: string },
TFull extends boolean,
>(
options: TranslationMessagesOptions<TId, TMessages, TFull>,
): TranslationMessages<TId, TMessages, TFull> {
return {
$$type: '@backstage/TranslationMessages',
id: options.ref.id,
full: Boolean(options.full) as TFull,
messages: options.messages as TMessages,
};
}
@@ -13,48 +13,61 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TranslationRefImpl, createTranslationRef } from './TranslationRef';
import {
createTranslationRef,
toInternalTranslationRef,
} from './TranslationRef';
import { toInternalTranslationResource } from './TranslationResource';
describe('TranslationRefImpl', () => {
it('should create a TranslationRef instance', () => {
const config = {
id: 'testId',
messages: { key: 'value' },
};
const translationRef = TranslationRefImpl.create(config);
expect(translationRef.getId()).toBe('testId');
expect(translationRef.getDefaultMessages()).toEqual({ key: 'value' });
});
it('should create a TranslationRef instance using the factory function', () => {
const config = {
id: 'testId',
const ref = createTranslationRef({
id: 'test',
messages: { key: 'value' },
};
});
const translationRef = createTranslationRef(config);
const internalRef = toInternalTranslationRef(ref);
expect(translationRef.getId()).toBe('testId');
expect(translationRef.getDefaultMessages()).toEqual({ key: 'value' });
expect(internalRef.$$type).toBe('@backstage/TranslationRef');
expect(internalRef.version).toBe('v1');
expect(internalRef.id).toBe('test');
expect(internalRef.getDefaultMessages()).toEqual({ key: 'value' });
});
it('should get lazy resources', async () => {
const config = {
id: 'testId',
it('should be created with lazy translations', async () => {
const ref = createTranslationRef({
id: 'test',
messages: { key: 'value' },
lazyResources: {
en: () => Promise.resolve({ messages: { key: 'value' } }),
translations: {
de: () => Promise.resolve({ default: { key: 'other-value' } }),
},
};
});
const translationRef = TranslationRefImpl.create(config);
const internalRef = toInternalTranslationRef(ref);
const lazyResources = translationRef.getLazyResources();
expect(internalRef.$$type).toBe('@backstage/TranslationRef');
expect(internalRef.version).toBe('v1');
expect(internalRef.id).toBe('test');
expect(internalRef.getDefaultMessages()).toEqual({ key: 'value' });
const messages = await lazyResources?.en();
expect(messages).toEqual({ messages: { key: 'value' } });
const internalResource = toInternalTranslationResource(
internalRef.getDefaultResource()!,
);
expect(internalResource).toEqual({
$$type: '@backstage/TranslationResource',
version: 'v1',
id: 'test',
resources: [
{
language: 'de',
loader: expect.any(Function),
},
],
});
await expect(internalResource.resources[0].loader()).resolves.toEqual({
messages: {
key: 'other-value',
},
});
});
});
@@ -14,48 +14,131 @@
* limitations under the License.
*/
import { TranslationRef, TranslationRefConfig } from './types';
import {
createTranslationResource,
TranslationResource,
} from './TranslationResource';
/** @alpha */
export class TranslationRefImpl<Messages extends Record<keyof Messages, string>>
implements TranslationRef<Messages>
{
static create<Messages extends Record<keyof Messages, string>>(
config: TranslationRefConfig<Messages>,
) {
return new TranslationRefImpl(config);
}
export interface TranslationRef<
TId extends string = string,
TMessages extends { [key in string]: string } = { [key in string]: string },
> {
$$type: '@backstage/TranslationRef';
getId() {
return this.config.id;
}
id: TId;
getDefaultMessages(): Messages {
return this.config.messages;
}
T: TMessages;
}
getLazyResources():
| Record<string, () => Promise<{ messages: Messages }>>
| undefined {
return this.config.lazyResources;
}
/** @internal */
type AnyMessages = { [key in string]: string };
getResources(): Record<string, Messages> | undefined {
return this.config.resources;
}
/** @internal */
export interface InternalTranslationRef<
TId extends string = string,
TMessages extends { [key in string]: string } = { [key in string]: string },
> extends TranslationRef<TId, TMessages> {
version: 'v1';
toString() {
return `TranslationRef(${this.getId()})`;
}
getDefaultMessages(): AnyMessages;
private constructor(
private readonly config: TranslationRefConfig<Messages>,
) {}
getDefaultResource(): TranslationResource | undefined;
}
/** @alpha */
export const createTranslationRef = <
Messages extends Record<keyof Messages, string> = {},
export interface TranslationRefOptions<
TId extends string,
TMessages extends { [key in string]: string },
TTranslations extends {
[language in string]: () => Promise<{
default: { [key in keyof TMessages]: string | null };
}>;
},
> {
id: TId;
messages: TMessages;
translations?: TTranslations;
}
/** @internal */
class TranslationRefImpl<
TId extends string,
TMessages extends { [key in string]: string },
> implements InternalTranslationRef<TId, TMessages>
{
#id: TId;
#messages: TMessages;
#resources: TranslationResource | undefined;
constructor(options: TranslationRefOptions<TId, TMessages, any>) {
this.#id = options.id;
this.#messages = options.messages;
}
$$type = '@backstage/TranslationRef' as const;
version = 'v1' as const;
get id(): TId {
return this.#id;
}
get T(): never {
throw new Error('Not implemented');
}
getDefaultMessages(): AnyMessages {
return this.#messages;
}
setDefaultResource(resources: TranslationResource): void {
this.#resources = resources;
}
getDefaultResource(): TranslationResource | undefined {
return this.#resources;
}
toString() {
return `TranslationRef{id=${this.id}}`;
}
}
/** @alpha */
export function createTranslationRef<
TId extends string,
TMessages extends { [key in string]: string },
TTranslations extends {
[language in string]: () => Promise<{
default: { [key in keyof TMessages]: string | null };
}>;
},
>(
config: TranslationRefConfig<Messages>,
): TranslationRef<Messages> => TranslationRefImpl.create(config);
config: TranslationRefOptions<TId, TMessages, TTranslations>,
): TranslationRef<TId, TMessages> {
const ref = new TranslationRefImpl(config);
if (config.translations) {
ref.setDefaultResource(
createTranslationResource({
ref,
translations: config.translations as any,
}),
);
}
return ref;
}
/** @internal */
export function toInternalTranslationRef(
ref: TranslationRef,
): InternalTranslationRef {
const r = ref as InternalTranslationRef;
if (r.$$type !== '@backstage/TranslationRef') {
throw new Error(`Invalid translation ref, bad type '${r.$$type}'`);
}
if (r.version !== 'v1') {
throw new Error(`Invalid translation ref, bad version '${r.version}'`);
}
return r;
}
@@ -0,0 +1,116 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createTranslationResource,
toInternalTranslationResource,
} from './TranslationResource';
import { countingTranslationRef } from './__fixtures__/refs';
describe('createTranslationResource', () => {
it('should create a simple resource', async () => {
const resource = createTranslationResource({
ref: countingTranslationRef,
translations: {
sv: () =>
Promise.resolve({
default: {
one: 'ett',
two: 'två',
three: 'tre',
},
}),
},
});
expect(toInternalTranslationResource(resource)).toEqual({
$$type: '@backstage/TranslationResource',
version: 'v1',
id: 'counting',
resources: [
{
language: 'sv',
loader: expect.any(Function),
},
],
});
await expect(
toInternalTranslationResource(resource).resources[0].loader(),
).resolves.toEqual({
messages: {
one: 'ett',
two: 'två',
three: 'tre',
},
});
});
it('should create a resource with lazy loaded messages', async () => {
const resource = createTranslationResource({
ref: countingTranslationRef,
translations: {
de: () => import('./__fixtures__/counting-de'),
sv: () => import('./__fixtures__/counting-sv.json'),
// @ts-expect-error
deBad: () => import('./__fixtures__/fruits-de.json'),
// @ts-expect-error
svBad: () => import('./__fixtures__/fruits-sv'),
},
});
expect(toInternalTranslationResource(resource)).toEqual({
$$type: '@backstage/TranslationResource',
version: 'v1',
id: 'counting',
resources: [
{
language: 'de',
loader: expect.any(Function),
},
{
language: 'sv',
loader: expect.any(Function),
},
{
language: 'deBad',
loader: expect.any(Function),
},
{
language: 'svBad',
loader: expect.any(Function),
},
],
});
await expect(
toInternalTranslationResource(resource).resources[0].loader(),
).resolves.toEqual({
messages: {
one: 'eins',
two: 'zwei',
three: 'polizei',
},
});
await expect(
toInternalTranslationResource(resource).resources[1].loader(),
).resolves.toEqual({
messages: {
one: 'ett',
two: 'två',
three: 'tre',
},
});
});
});
@@ -0,0 +1,104 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
TranslationMessages,
TranslationRef,
} from '@backstage/core-plugin-api/alpha';
/** @alpha */
export interface TranslationResource<TId extends string = string> {
$$type: '@backstage/TranslationResource';
id: TId;
}
/** @internal */
export interface InternalTranslationResource<TId extends string = string>
extends TranslationResource<TId> {
version: 'v1';
resources: Array<{
language: string;
loader(): Promise<{ messages: { [key in string]: string | null } }>;
}>;
}
/** @internal */
export function toInternalTranslationResource<TId extends string>(
resource: TranslationResource<TId>,
): InternalTranslationResource<TId> {
const r = resource as InternalTranslationResource<TId>;
if (r.$$type !== '@backstage/TranslationResource') {
throw new Error(`Invalid translation resource, bad type '${r.$$type}'`);
}
if (r.version !== 'v1') {
throw new Error(`Invalid translation resource, bad version '${r.version}'`);
}
return r;
}
/** @alpha */
export interface TranslationResourceOptions<
TId extends string,
TMessages extends { [key in string]: string },
TTranslations extends {
[language in string]: () => Promise<{
default:
| TranslationMessages<TId>
| { [key in keyof TMessages]: string | null };
}>;
},
> {
ref: TranslationRef<TId, TMessages>;
translations: TTranslations;
}
/** @alpha */
export function createTranslationResource<
TId extends string,
TMessages extends { [key in string]: string },
TTranslations extends {
[language in string]: () => Promise<{
default:
| TranslationMessages<TId>
| { [key in keyof TMessages]: string | null };
}>;
},
>(
options: TranslationResourceOptions<TId, TMessages, TTranslations>,
): TranslationResource<TId> {
return {
$$type: '@backstage/TranslationResource',
version: 'v1',
id: options.ref.id,
resources: Object.entries(options.translations).map(
([language, loader]) => ({
language,
loader: () =>
loader().then(m => {
const value = m.default;
return {
messages:
value?.$$type === '@backstage/TranslationMessages'
? value.messages
: value,
};
}),
}),
),
} as InternalTranslationResource<TId>;
}
@@ -0,0 +1,27 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createTranslationMessages } from '../TranslationMessages';
import { countingTranslationRef } from './refs';
export default createTranslationMessages({
ref: countingTranslationRef,
messages: {
one: 'eins',
two: 'zwei',
three: 'polizei',
},
});
@@ -0,0 +1,5 @@
{
"one": "ett",
"two": "två",
"three": "tre"
}
@@ -0,0 +1,4 @@
{
"apple": "apfel",
"orange": "apfelsine"
}
@@ -13,5 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './apis/implementations/AppTranslationApi';
export * from './app/TranslationResource';
import { createTranslationMessages } from '../TranslationMessages';
import { fruitsTranslationRef } from './refs';
export default createTranslationMessages({
ref: fruitsTranslationRef,
messages: {
apple: 'äpple',
orange: 'apelsin',
},
});
@@ -14,21 +14,24 @@
* limitations under the License.
*/
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
import { createTranslationRef } from '../TranslationRef';
/** @alpha */
export type TranslationMessages<T> = T extends TranslationRef<infer R>
? Partial<R>
: never;
export const countingTranslationRef = createTranslationRef({
id: 'counting',
messages: {
one: 'one',
two: 'two',
three: 'three',
},
});
/** @alpha */
export function createTranslationResource<T extends TranslationRef>(options: {
ref: T;
messages?: Record<string, TranslationMessages<T>>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<T> }>
>;
}) {
return options;
}
export const fruitsTranslationRef = createTranslationRef({
id: 'fruits',
messages: {
apple: 'apple',
orange: 'orange',
},
translations: {
de: () => import('./fruits-de.json'),
},
});
@@ -14,6 +14,22 @@
* limitations under the License.
*/
export * from './TranslationRef';
export * from './types';
export * from './useTranslationRef';
export {
type TranslationMessages,
type TranslationMessagesOptions,
createTranslationMessages,
} from './TranslationMessages';
export {
type TranslationResource,
type TranslationResourceOptions,
createTranslationResource,
} from './TranslationResource';
export {
type TranslationRef,
type TranslationRefOptions,
createTranslationRef,
} from './TranslationRef';
export {
type TranslationOptions,
useTranslationRef,
} from './useTranslationRef';
@@ -1,45 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @alpha */
export interface TranslationRefConfig<
Messages extends Record<keyof Messages, string>,
> {
id: string;
messages: Messages;
lazyResources?: Record<string, () => Promise<{ messages: Messages }>>;
resources?: Record<string, Messages>;
}
/** @alpha */
export interface TranslationRef<
Messages extends Record<keyof Messages, string> = Record<string, string>,
> {
getId(): string;
getDefaultMessages(): Messages;
getResources(): Record<string, Messages> | undefined;
getLazyResources():
| Record<string, () => Promise<{ messages: Messages }>>
| undefined;
}
/** @alpha */
export type TranslationOptions<
Messages extends Record<keyof Messages, string> = Record<string, string>,
> = Messages;
@@ -51,7 +51,7 @@ describe('useTranslationRef', () => {
};
(useApi as jest.Mock).mockReturnValue({
addResourcesByRef: jest.fn(),
addResource: jest.fn(),
});
(useTranslation as jest.Mock).mockReturnValue(i18nMock);
@@ -16,26 +16,34 @@
import { useTranslation } from 'react-i18next';
import { TranslationOptions, TranslationRef } from './types';
import { useApi } from '../apis';
import { appTranslationApiRef } from '../apis/alpha';
import { toInternalTranslationRef, TranslationRef } from './TranslationRef';
/** @alpha */
export interface TranslationOptions {
/* no options supported for now */
}
/** @alpha */
export const useTranslationRef = <
Messages extends Record<keyof Messages, string>,
TMessages extends { [key in string]: string },
>(
translationRef: TranslationRef<Messages>,
translationRef: TranslationRef<string, TMessages>,
) => {
const appTranslationApi = useApi(appTranslationApiRef);
const translationApi = useApi(appTranslationApiRef);
appTranslationApi.addResourcesByRef(translationRef);
const internalRef = toInternalTranslationRef(translationRef);
translationApi.addResource(translationRef);
const { t } = useTranslation(translationRef.getId());
const { t } = useTranslation(internalRef.id, {
useSuspense: process.env.NODE_ENV !== 'test',
});
const defaulteMessage = translationRef.getDefaultMessages();
const defaultMessages = internalRef.getDefaultMessages();
return <Tkey extends keyof Messages>(
key: Tkey,
return <TKey extends keyof TMessages & string>(
key: TKey,
options?: TranslationOptions,
): Messages[Tkey] => t(key as string, defaulteMessage[key], options);
): TMessages[TKey] => t(key, defaultMessages[key], options);
};
+8 -5
View File
@@ -6,11 +6,14 @@
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
export const adrTranslationRef: TranslationRef<{
readonly content_header_title: 'Architecture Decision Records';
readonly failed_to_fetch: 'Failed to fetch ADRs';
readonly no_adrs: 'No ADRs found';
}>;
export const adrTranslationRef: TranslationRef<
'adr',
{
readonly content_header_title: 'Architecture Decision Records';
readonly failed_to_fetch: 'Failed to fetch ADRs';
readonly no_adrs: 'No ADRs found';
}
>;
// (No @packageDocumentation comment for this package)
```
+18 -15
View File
@@ -6,21 +6,24 @@
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
export const userSettingsTranslationRef: TranslationRef<{
language: string;
change_the_language: string;
theme: string;
theme_light: string;
theme_dark: string;
theme_auto: string;
change_the_theme_mode: string;
select_theme_light: string;
select_theme_dark: string;
select_theme_auto: string;
select_theme_custom: string;
lng: string;
select_lng: string;
}>;
export const userSettingsTranslationRef: TranslationRef<
'user-settings',
{
readonly language: 'Language';
readonly change_the_language: 'Change the language';
readonly theme: 'Theme';
readonly theme_light: 'Light';
readonly theme_dark: 'Dark';
readonly theme_auto: 'Auto';
readonly change_the_theme_mode: 'Change the theme mode';
readonly select_theme_light: 'Select light';
readonly select_theme_dark: 'Select dark';
readonly select_theme_auto: 'Select Auto Theme';
readonly select_theme_custom: 'Select {{custom}}';
readonly lng: '{{language}}';
readonly select_lng: 'Select language {{language}}';
}
>;
// (No @packageDocumentation comment for this package)
```
@@ -15,7 +15,6 @@
*/
import { AppTheme, appThemeApiRef } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
renderWithEffects,
TestApiRegistry,
@@ -28,7 +27,6 @@ import { fireEvent } from '@testing-library/react';
import React from 'react';
import { UserSettingsThemeToggle } from './UserSettingsThemeToggle';
import { ApiProvider, AppThemeSelector } from '@backstage/core-app-api';
import { userSettingsTranslationRef } from '../../translation';
const mockTheme: AppTheme = {
id: 'light-theme',
@@ -41,11 +39,6 @@ const mockTheme: AppTheme = {
),
};
jest.mock('@backstage/core-plugin-api/alpha', () => ({
...jest.requireActual('@backstage/core-plugin-api/alpha'),
useTranslationRef: jest.fn(),
}));
const apiRegistry = TestApiRegistry.from([
appThemeApiRef,
AppThemeSelector.createWithStorage([mockTheme]),
@@ -54,15 +47,6 @@ const apiRegistry = TestApiRegistry.from([
describe('<UserSettingsThemeToggle />', () => {
it('toggles the theme select button', async () => {
const themeApi = apiRegistry.get(appThemeApiRef);
// todo: general test provider
const messages: Record<string, string> =
userSettingsTranslationRef.getDefaultMessages();
const useTranslationRefMock = jest
.fn()
.mockReturnValue((key: string) => messages[key]);
(useTranslationRef as jest.Mock).mockImplementation(useTranslationRefMock);
const rendered = await renderWithEffects(
wrapInTestApp(
+1 -1
View File
@@ -33,5 +33,5 @@ export const userSettingsTranslationRef = createTranslationRef({
select_theme_custom: 'Select {{custom}}',
lng: '{{language}}',
select_lng: 'Select language {{language}}',
},
} as const,
});