diff --git a/.changeset/famous-flies-kick.md b/.changeset/famous-flies-kick.md new file mode 100644 index 0000000000..abbd18f9c3 --- /dev/null +++ b/.changeset/famous-flies-kick.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +The `createTranslationRef` function from the `/alpha` subpath can now also accept a nested object structure of default translation messages, which will be flatted using `.` separators. diff --git a/packages/core-plugin-api/alpha-api-report.md b/packages/core-plugin-api/alpha-api-report.md index ba3583142f..c0f0d02535 100644 --- a/packages/core-plugin-api/alpha-api-report.md +++ b/packages/core-plugin-api/alpha-api-report.md @@ -39,19 +39,17 @@ export function createTranslationMessages< // @alpha (undocumented) export function createTranslationRef< TId extends string, - const TMessages extends { - [key in string]: string; - }, + const TNestedMessages extends AnyNestedMessages, TTranslations extends { [language in string]: () => Promise<{ default: { - [key in keyof TMessages]: string | null; + [key in keyof FlattenedMessages]: string | null; }; }>; }, >( - config: TranslationRefOptions, -): TranslationRef; + config: TranslationRefOptions, +): TranslationRef>; // @alpha (undocumented) export function createTranslationResource< @@ -169,13 +167,11 @@ export interface TranslationRef< // @alpha (undocumented) export interface TranslationRefOptions< TId extends string, - TMessages extends { - [key in string]: string; - }, + TNestedMessages extends AnyNestedMessages, TTranslations extends { [language in string]: () => Promise<{ default: { - [key in keyof TMessages]: string | null; + [key in keyof FlattenedMessages]: string | null; }; }>; }, @@ -183,7 +179,7 @@ export interface TranslationRefOptions< // (undocumented) id: TId; // (undocumented) - messages: TMessages; + messages: TNestedMessages; // (undocumented) translations?: TTranslations; } diff --git a/packages/core-plugin-api/src/translation/TranslationRef.test.ts b/packages/core-plugin-api/src/translation/TranslationRef.test.ts index b73b521774..d1fde0810f 100644 --- a/packages/core-plugin-api/src/translation/TranslationRef.test.ts +++ b/packages/core-plugin-api/src/translation/TranslationRef.test.ts @@ -34,6 +34,40 @@ describe('TranslationRefImpl', () => { expect(internalRef.getDefaultMessages()).toEqual({ key: 'value' }); }); + it('should create a TranslationRef instance with nested messages', () => { + const ref = createTranslationRef({ + id: 'test', + messages: { + key: 'value', + 'nested.conflict1': 'outer conflict1', + nested: { + key: 'nested value', + key2: 'nested value2', + conflict1: 'inner conflict1', + conflict2: 'inner conflict2', + inner: { + key: 'inner value', + }, + }, + 'nested.conflict2': 'outer conflict2', + }, + }); + + const internalRef = toInternalTranslationRef(ref); + + expect(internalRef.$$type).toBe('@backstage/TranslationRef'); + expect(internalRef.version).toBe('v1'); + expect(internalRef.id).toBe('test'); + expect(internalRef.getDefaultMessages()).toEqual({ + key: 'value', + 'nested.key': 'nested value', + 'nested.key2': 'nested value2', + 'nested.conflict1': 'inner conflict1', + 'nested.inner.key': 'inner value', + 'nested.conflict2': 'outer conflict2', + }); + }); + it('should be created with lazy translations', async () => { const ref = createTranslationRef({ id: 'test', diff --git a/packages/core-plugin-api/src/translation/TranslationRef.ts b/packages/core-plugin-api/src/translation/TranslationRef.ts index 345a9c3f65..5711273915 100644 --- a/packages/core-plugin-api/src/translation/TranslationRef.ts +++ b/packages/core-plugin-api/src/translation/TranslationRef.ts @@ -34,6 +34,30 @@ export interface TranslationRef< /** @internal */ type AnyMessages = { [key in string]: string }; +/** @ignore */ +type AnyNestedMessages = { [key in string]: AnyNestedMessages | string }; + +/** + * Flattens a nested message declaration into a flat object with dot-separated keys. + * + * @ignore + */ +type FlattenedMessages = { + [K in keyof T]: ( + x: T[K] extends infer V + ? V extends AnyNestedMessages + ? FlattenedMessages extends infer FV + ? { + [P in keyof FV as `${K & string}.${P & string}`]: FV[P]; + } + : never + : Pick + : never, + ) => void; +} extends Record void> + ? { [K in keyof O]: O[K] } + : never; + /** @internal */ export interface InternalTranslationRef< TId extends string = string, @@ -49,31 +73,53 @@ export interface InternalTranslationRef< /** @alpha */ export interface TranslationRefOptions< TId extends string, - TMessages extends { [key in string]: string }, + TNestedMessages extends AnyNestedMessages, TTranslations extends { [language in string]: () => Promise<{ - default: { [key in keyof TMessages]: string | null }; + default: { + [key in keyof FlattenedMessages]: string | null; + }; }>; }, > { id: TId; - messages: TMessages; + messages: TNestedMessages; translations?: TTranslations; } +function flattenMessages(nested: AnyNestedMessages): AnyMessages { + const entries = new Array<[string, string]>(); + + function visit(obj: AnyNestedMessages, prefix: string): void { + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + entries.push([prefix + key, value]); + } else { + visit(value, `${prefix}${key}.`); + } + } + } + + visit(nested, ''); + + return Object.fromEntries(entries); +} + /** @internal */ class TranslationRefImpl< TId extends string, - TMessages extends { [key in string]: string }, -> implements InternalTranslationRef + TNestedMessages extends AnyNestedMessages, +> implements InternalTranslationRef> { #id: TId; - #messages: TMessages; + #messages: FlattenedMessages; #resources: TranslationResource | undefined; - constructor(options: TranslationRefOptions) { + constructor(options: TranslationRefOptions) { this.#id = options.id; - this.#messages = options.messages; + this.#messages = flattenMessages( + options.messages, + ) as FlattenedMessages; } $$type = '@backstage/TranslationRef' as const; @@ -108,15 +154,17 @@ class TranslationRefImpl< /** @alpha */ export function createTranslationRef< TId extends string, - const TMessages extends { [key in string]: string }, + const TNestedMessages extends AnyNestedMessages, TTranslations extends { [language in string]: () => Promise<{ - default: { [key in keyof TMessages]: string | null }; + default: { + [key in keyof FlattenedMessages]: string | null; + }; }>; }, >( - config: TranslationRefOptions, -): TranslationRef { + config: TranslationRefOptions, +): TranslationRef> { const ref = new TranslationRefImpl(config); if (config.translations) { ref.setDefaultResource( @@ -132,7 +180,7 @@ export function createTranslationRef< /** @internal */ export function toInternalTranslationRef< TId extends string, - TMessages extends { [key in string]: string }, + TMessages extends AnyMessages, >(ref: TranslationRef): InternalTranslationRef { const r = ref as InternalTranslationRef; if (r.$$type !== '@backstage/TranslationRef') {