feat: Introduced experimental support for internationalization.

Signed-off-by: rui ma <ruima@alauda.io>
This commit is contained in:
rui ma
2023-09-04 23:51:08 +08:00
parent 155901d486
commit 6e30769cc6
38 changed files with 1698 additions and 37 deletions
+82
View File
@@ -0,0 +1,82 @@
## 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?: 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?: TranslationMessages<T>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<T>;
}>
>;
}): {
ref: T;
messages?: 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?: TranslationMessages<TranslationRef>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<TranslationRef>;
}>
>;
}>;
};
// @alpha (undocumented)
export type TranslationMessages<T> = T extends TranslationRef<infer R>
? Record<string, Partial<R>>
: never;
// (No @packageDocumentation comment for this package)
```
+15
View File
@@ -64,6 +64,7 @@ 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';
// @public
export class AlertApiForwarder implements AlertApi {
@@ -220,6 +221,20 @@ export type AppOptions = {
themes: (Partial<AppTheme> & Omit<AppTheme, 'theme'>)[];
configLoader?: AppConfigLoader;
bindRoutes?(context: { bind: AppRouteBinder }): void;
__experimentalI18n?: {
supportedLanguages: string[];
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: TranslationMessages<TranslationRef>;
lazyMessages: Record<
string,
() => Promise<{
messages: TranslationMessages<TranslationRef>;
}>
>;
}>;
};
};
// @public
+19 -3
View File
@@ -3,9 +3,22 @@
"description": "Core app API used by Backstage apps",
"version": "1.10.0-next.1",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
"access": "public"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
}
},
"backstage": {
"role": "web-library"
@@ -39,7 +52,10 @@
"@types/prop-types": "^15.7.3",
"@types/react": "^16.13.1 || ^17.0.0",
"history": "^5.0.0",
"i18next": "^22.4.15",
"i18next-browser-languagedetector": "^7.0.2",
"prop-types": "^15.7.2",
"react-i18next": "^12.3.1",
"react-use": "^17.2.4",
"zen-observable": "^0.10.0",
"zod": "^3.21.4"
+17
View File
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './apis/implementations/AppTranslationApi';
export * from './app/TranslationResource';
@@ -0,0 +1,180 @@
/*
* Copyright 2020 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 { createTranslationRef } from '@backstage/core-plugin-api/alpha';
import { AppTranslationApiImpl } from './AppTranslationImpl';
import i18next from 'i18next';
jest.mock('i18next', () => ({
createInstance: jest.fn(() => ({
use: jest.fn(() => ({
init: jest.fn(),
use: jest.fn(),
addResourceBundle: jest.fn(),
reloadResources: jest.fn(),
emit: jest.fn(),
services: {
languageUtils: {
getFallbackCodes: jest.fn().mockReturnValue(['en']),
},
},
options: {
fallbackLng: 'en',
},
})),
})),
}));
describe('AppTranslationApiImpl', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should create i18n instance and init with options', () => {
const i18nMock = i18next.createInstance() as any;
const useReturnMock = i18nMock.use();
jest.spyOn(i18nMock, 'use').mockReturnValue(useReturnMock);
jest.spyOn(i18next, 'createInstance').mockReturnValue(i18nMock);
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en', 'zh'],
});
expect(i18next.createInstance).toHaveBeenCalled();
expect(i18nMock.use).toHaveBeenCalled();
expect(useReturnMock.init).toHaveBeenCalledWith({
fallbackLng: 'en',
supportedLngs: ['en', 'zh'],
interpolation: {
escapeValue: false,
},
react: {
bindI18n: 'loaded languageChanged',
},
});
expect(instance).toBeInstanceOf(AppTranslationApiImpl);
});
it('should init messages correctly', () => {
const useResourcesMock = jest.spyOn(
AppTranslationApiImpl.prototype,
'addResources',
);
const useLazyResourcesMock = jest.spyOn(
AppTranslationApiImpl.prototype,
'addLazyResources',
);
const ref = createTranslationRef({
id: 'ref-id',
messages: {
key1: '',
},
});
const options = {
supportedLanguages: ['en'],
messages: [
{
ref,
messages: {
en: { key1: 'value1' },
},
lazyMessages: {
en: () => Promise.resolve({ key2: 'value2' }),
} as any,
},
],
};
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en'],
});
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,
);
});
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,
);
});
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' } }),
},
});
const instance = AppTranslationApiImpl.create({
supportedLanguages: ['en'],
});
instance.addLazyResources(ref);
setTimeout(() => {
expect(useReturnMock.addResourceBundle).toHaveBeenCalledWith(
'en',
'ref-id',
{ key1: 'value2' },
true,
false,
);
});
});
});
@@ -0,0 +1,194 @@
/*
* 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 {
AppTranslationApi,
TranslationRef,
} 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';
/** @alpha */
export type ExperimentalI18n = {
supportedLanguages: string[];
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: TranslationMessages<TranslationRef>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>;
}>;
};
/** @alpha */
export class AppTranslationApiImpl implements AppTranslationApi {
static create(options?: ExperimentalI18n) {
const i18n = i18next.createInstance().use(initReactI18next);
i18n.use(LanguageDetector);
i18n.init({
fallbackLng: options?.fallbackLanguage || 'en',
supportedLngs: options?.supportedLanguages || ['en'],
interpolation: {
escapeValue: false,
},
react: {
bindI18n: 'loaded languageChanged',
},
});
return new AppTranslationApiImpl(i18n, options);
}
private readonly cache = new WeakSet<TranslationRef>();
private readonly lazyCache = new WeakMap<TranslationRef, 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);
}
});
}
}
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void {
this.addResources(translationRef);
this.addLazyResources(translationRef);
}
addResources<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
initResources?: TranslationMessages<TranslationRef<Messages>>,
) {
const resources = initResources || translationRef.getResources();
if (!resources || this.cache.has(translationRef)) {
return;
}
this.cache.add(translationRef);
Object.entries(resources).forEach(([language, messages]) => {
this.i18n.addResourceBundle(
language,
translationRef.getId(),
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);
if (!cache) {
cache = new Set();
this.lazyCache.set(translationRef, cache);
}
const {
language: currentLanguage,
services,
options,
addResourceBundle,
reloadResources,
} = this.i18n;
if (cache.has(currentLanguage)) {
return;
}
const namespace = translationRef.getId();
const lazyResources = initResources || translationRef.getLazyResources();
const fallbackLanguages = services.languageUtils.getFallbackCodes(
options.fallbackLng,
currentLanguage,
) as string[];
Promise.allSettled(
[...fallbackLanguages, currentLanguage].map(addLanguage),
).then(results => {
if (results.some(result => result.status === 'fulfilled')) {
this.i18n.emit('loaded');
}
});
async function addLanguage(language: string) {
if (cache!.has(language)) {
return;
}
cache!.add(language);
let loadBackend: Promise<void> | undefined;
if (services.backendConnector?.backend) {
loadBackend = reloadResources([language], [namespace]);
}
const loadLazyResources = lazyResources?.[language];
if (!loadLazyResources) {
await loadBackend;
return;
}
const [result] = await Promise.allSettled([
loadLazyResources(),
loadBackend,
]);
if (result.status === 'rejected') {
throw result.reason;
}
addResourceBundle(
language,
namespace,
result.value.messages,
true,
false,
);
}
}
private constructor(private readonly i18n: i18n, options?: ExperimentalI18n) {
this.initMessages(options);
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export * from './AppTranslationImpl';
@@ -260,6 +260,46 @@ describe('Integration Test', () => {
expect(screen.getByText('extLink4: <none>')).toBeInTheDocument();
});
it('runs success with __experimentalI18n', async () => {
const app = new AppManager({
apis: [noOpAnalyticsApi],
defaultApis: [],
themes,
icons,
plugins: [],
components,
configLoader: async () => [],
bindRoutes: ({ bind }) => {
bind(plugin1.externalRoutes, {
extRouteRef1: plugin1RouteRef,
extRouteRef2: plugin2RouteRef,
});
},
__experimentalI18n: {
supportedLanguages: ['en'],
},
});
const Provider = app.getProvider();
const Router = app.getRouter();
await renderWithEffects(
<Provider>
<Router>
<Routes>
<Route path="/" element={<ExposedComponent />} />
<Route path="/foo" element={<HiddenComponent />} />
</Routes>
</Router>
</Provider>,
);
expect(screen.getByText('extLink1: /')).toBeInTheDocument();
expect(screen.getByText('extLink2: /foo')).toBeInTheDocument();
expect(screen.getByText('extLink3: <none>')).toBeInTheDocument();
expect(screen.getByText('extLink4: <none>')).toBeInTheDocument();
});
it('should wait for the config to load before calling feature flags', async () => {
const storageFlags = new LocalStorageFeatureFlags();
jest.spyOn(storageFlags, 'registerFlag');
+34 -17
View File
@@ -42,6 +42,10 @@ import {
BackstagePlugin,
FeatureFlag,
} from '@backstage/core-plugin-api';
import {
AppTranslationApi,
appTranslationApiRef,
} from '@backstage/core-plugin-api/alpha';
import { ApiFactoryRegistry, ApiResolver } from '../apis/system';
import {
childDiscoverer,
@@ -75,6 +79,8 @@ import { resolveRouteBindings } from './resolveRouteBindings';
import { isReactRouterBeta } from './isReactRouterBeta';
import { InternalAppContext } from './InternalAppContext';
import { AppRouter, getBasePath } from './AppRouter';
import { AppTranslationProvider } from './AppTranslationProvider';
import { AppTranslationApiImpl } from '../apis/implementations/AppTranslationApi';
type CompatiblePlugin =
| BackstagePlugin
@@ -209,6 +215,7 @@ export class AppManager implements BackstageApp {
private readonly configLoader?: AppConfigLoader;
private readonly defaultApis: Iterable<AnyApiFactory>;
private readonly bindRoutes: AppOptions['bindRoutes'];
private readonly appTranslationApi: AppTranslationApi;
private readonly appIdentityProxy = new AppIdentityProxy();
private readonly apiFactoryRegistry: ApiFactoryRegistry;
@@ -224,6 +231,9 @@ export class AppManager implements BackstageApp {
this.defaultApis = options.defaultApis ?? [];
this.bindRoutes = options.bindRoutes;
this.apiFactoryRegistry = new ApiFactoryRegistry();
this.appTranslationApi = AppTranslationApiImpl.create(
options.__experimentalI18n,
);
}
getPlugins(): BackstagePlugin[] {
@@ -379,24 +389,26 @@ export class AppManager implements BackstageApp {
return (
<ApiProvider apis={this.getApiHolder()}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
<InternalAppContext.Provider
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
<AppTranslationProvider>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
{children}
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
<InternalAppContext.Provider
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
>
{children}
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
</AppTranslationProvider>
</AppContextProvider>
</ApiProvider>
);
@@ -447,6 +459,11 @@ export class AppManager implements BackstageApp {
deps: {},
factory: () => this.appIdentityProxy,
});
this.apiFactoryRegistry.register('static', {
api: appTranslationApiRef,
deps: {},
factory: () => this.appTranslationApi,
});
// It's possible to replace the feature flag API, but since we must have at least
// one implementation we add it here directly instead of through the defaultApis.
@@ -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 React, { PropsWithChildren } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { appTranslationApiRef } from '@backstage/core-plugin-api/alpha';
import { I18nextProvider } from 'react-i18next';
/** @alpha */
export function AppTranslationProvider({ children }: PropsWithChildren<{}>) {
const appTranslationAPi = useApi(appTranslationApiRef);
const i18n = appTranslationAPi.getI18n();
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
@@ -0,0 +1,34 @@
/*
* 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';
/** @alpha */
export type TranslationMessages<T> = T extends TranslationRef<infer R>
? Record<string, Partial<R>>
: never;
/** @alpha */
export function createTranslationResource<T extends TranslationRef>(options: {
ref: T;
messages?: TranslationMessages<T>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<T> }>
>;
}) {
return options;
}
+28
View File
@@ -27,6 +27,7 @@ import {
FeatureFlag,
} from '@backstage/core-plugin-api';
import { AppConfig } from '@backstage/config';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
/**
* Props for the `BootErrorPage` component of {@link AppComponents}.
@@ -177,6 +178,16 @@ 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>
? Record<string, Partial<R>>
: never;
/**
* The options accepted by {@link createSpecializedApp}.
*
@@ -278,6 +289,23 @@ 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[];
fallbackLanguage?: string | string[];
messages?: Array<{
ref: TranslationRef;
messages?: TranslationMessages<TranslationRef>;
lazyMessages: Record<
string,
() => Promise<{ messages: TranslationMessages<TranslationRef> }>
>;
}>;
};
};
/**
@@ -3,9 +3,29 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { i18n } from 'i18next';
import { ReactNode } from 'react';
// @alpha (undocumented)
export type AppTranslationApi = {
getI18n(): i18n;
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void;
};
// @alpha (undocumented)
export const appTranslationApiRef: ApiRef<AppTranslationApi>;
// @alpha (undocumented)
export const createTranslationRef: <
Messages extends Record<keyof Messages, string> = {},
>(
config: TranslationRefConfig<Messages>,
) => TranslationRef<Messages>;
// @alpha
export interface PluginOptionsProviderProps {
// (undocumented)
@@ -17,10 +37,92 @@ export interface PluginOptionsProviderProps {
// @alpha
export const PluginProvider: (props: PluginOptionsProviderProps) => JSX.Element;
// @alpha (undocumented)
export type TranslationOptions<
Messages extends Record<keyof Messages, string> = Record<string, string>,
> = Messages;
// @alpha (undocumented)
export interface TranslationRef<
Messages extends Record<keyof Messages, string> = Record<string, string>,
> {
// (undocumented)
getDefaultMessages(): Messages;
// (undocumented)
getId(): string;
// (undocumented)
getLazyResources():
| Record<
string,
() => Promise<{
messages: Messages;
}>
>
| undefined;
// (undocumented)
getResources(): Record<string, Messages> | undefined;
}
// @alpha (undocumented)
export interface TranslationRefConfig<
Messages extends Record<keyof Messages, string>,
> {
// (undocumented)
id: string;
// (undocumented)
lazyResources?: Record<
string,
() => Promise<{
messages: Messages;
}>
>;
// (undocumented)
messages: Messages;
// (undocumented)
resources?: Record<string, Messages>;
}
// @alpha (undocumented)
export class TranslationRefImpl<Messages extends Record<keyof Messages, string>>
implements TranslationRef<Messages>
{
// (undocumented)
static create<Messages extends Record<keyof Messages, string>>(
config: TranslationRefConfig<Messages>,
): TranslationRefImpl<Messages>;
// (undocumented)
getDefaultMessages(): Messages;
// (undocumented)
getId(): string;
// (undocumented)
getLazyResources():
| Record<
string,
() => Promise<{
messages: Messages;
}>
>
| undefined;
// (undocumented)
getResources(): Record<string, Messages> | undefined;
// (undocumented)
toString(): string;
}
// @alpha
export function usePluginOptions<
TPluginOptions extends {} = {},
>(): TPluginOptions;
// @alpha (undocumented)
export const useTranslationRef: <
Messages extends Record<keyof Messages, string>,
>(
translationRef: TranslationRef<Messages>,
) => <Tkey extends keyof Messages>(
key: Tkey,
options?: TranslationOptions,
) => Messages[Tkey];
// (No @packageDocumentation comment for this package)
```
+2
View File
@@ -50,7 +50,9 @@
"@backstage/version-bridge": "workspace:^",
"@types/react": "^16.13.1 || ^17.0.0",
"history": "^5.0.0",
"i18next": "^22.4.15",
"prop-types": "^15.7.2",
"react-i18next": "^12.3.1",
"zen-observable": "^0.10.0"
},
"peerDependencies": {
+2
View File
@@ -15,3 +15,5 @@
*/
export * from './plugin-options';
export * from './translation';
export * from './apis/alpha';
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './definitions/alpha';
@@ -0,0 +1,35 @@
/*
* 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 { type i18n } from 'i18next';
import { TranslationRef } from '../../translation';
import { ApiRef, createApiRef } from '@backstage/core-plugin-api';
/** @alpha */
export type AppTranslationApi = {
getI18n(): i18n;
addResourcesByRef<Messages extends Record<string, string>>(
translationRef: TranslationRef<Messages>,
): void;
};
/**
* @alpha
*/
export const appTranslationApiRef: ApiRef<AppTranslationApi> = createApiRef({
id: 'core.apptranslation',
});
@@ -0,0 +1,16 @@
/*
* 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.
*/
export * from './AppTranslationApi';
@@ -0,0 +1,60 @@
/*
* 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 { TranslationRefImpl, createTranslationRef } from './TranslationRef';
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',
messages: { key: 'value' },
};
const translationRef = createTranslationRef(config);
expect(translationRef.getId()).toBe('testId');
expect(translationRef.getDefaultMessages()).toEqual({ key: 'value' });
});
it('should get lazy resources', async () => {
const config = {
id: 'testId',
messages: { key: 'value' },
lazyResources: {
en: () => Promise.resolve({ messages: { key: 'value' } }),
},
};
const translationRef = TranslationRefImpl.create(config);
const lazyResources = translationRef.getLazyResources();
const messages = await lazyResources?.en();
expect(messages).toEqual({ messages: { key: 'value' } });
});
});
@@ -0,0 +1,61 @@
/*
* 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, TranslationRefConfig } from './types';
/** @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);
}
getId() {
return this.config.id;
}
getDefaultMessages(): Messages {
return this.config.messages;
}
getLazyResources():
| Record<string, () => Promise<{ messages: Messages }>>
| undefined {
return this.config.lazyResources;
}
getResources(): Record<string, Messages> | undefined {
return this.config.resources;
}
toString() {
return `TranslationRef(${this.getId()})`;
}
private constructor(
private readonly config: TranslationRefConfig<Messages>,
) {}
}
/** @alpha */
export const createTranslationRef = <
Messages extends Record<keyof Messages, string> = {},
>(
config: TranslationRefConfig<Messages>,
): TranslationRef<Messages> => TranslationRefImpl.create(config);
@@ -0,0 +1,19 @@
/*
* 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.
*/
export * from './TranslationRef';
export * from './types';
export * from './useTranslationRef';
@@ -0,0 +1,45 @@
/*
* 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;
@@ -0,0 +1,68 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useTranslation } from 'react-i18next';
import { useApi } from '../apis';
import { useTranslationRef } from './useTranslationRef';
import { createTranslationRef } from './TranslationRef';
jest.mock('../apis', () => ({
...jest.requireActual('../apis'),
useApi: jest.fn(),
}));
jest.mock('react-i18next', () => ({
useTranslation: jest.fn(),
}));
describe('useTranslationRef', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return correct t', () => {
const translationRef = createTranslationRef({
id: 'ref-id',
messages: {
key1: 'default1',
key2: 'default2',
},
});
const tMock = jest.fn();
tMock.mockReturnValue('translatedValue');
const i18nMock = {
language: 'en',
t: tMock,
};
(useApi as jest.Mock).mockReturnValue({
addResourcesByRef: jest.fn(),
});
(useTranslation as jest.Mock).mockReturnValue(i18nMock);
const { result } = renderHook(() => useTranslationRef(translationRef));
const t = result.current;
t('key1', { condition: 'v1' });
expect(tMock).toHaveBeenCalledWith('key1', 'default1', { condition: 'v1' });
t('key2');
expect(tMock).toHaveBeenCalledWith('key2', 'default2', undefined);
});
});
@@ -0,0 +1,41 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { TranslationOptions, TranslationRef } from './types';
import { useApi } from '../apis';
import { appTranslationApiRef } from '../apis/alpha';
/** @alpha */
export const useTranslationRef = <
Messages extends Record<keyof Messages, string>,
>(
translationRef: TranslationRef<Messages>,
) => {
const appTranslationApi = useApi(appTranslationApiRef);
appTranslationApi.addResourcesByRef(translationRef);
const { t } = useTranslation(translationRef.getId());
const defaulteMessage = translationRef.getDefaultMessages();
return <Tkey extends keyof Messages>(
key: Tkey,
options?: TranslationOptions,
): Messages[Tkey] => t(key as string, defaulteMessage[key], options);
};