diff --git a/src/plugins/piral-translate/src/create.test.ts b/src/plugins/piral-translate/src/create.test.ts index 7acd9a8c5..2ff755f71 100644 --- a/src/plugins/piral-translate/src/create.test.ts +++ b/src/plugins/piral-translate/src/create.test.ts @@ -73,6 +73,22 @@ describe('Create Localize API', () => { expect(result).toEqual('bár'); }); + it('createApi can translate from local-global translations using the current language and passed nested translations', () => { + const config = { + language: 'en' + }; + const api = (createLocaleApi(setupLocalizer(config))(context) as any)(); + api.setTranslations({ + en: { + header: { + title: 'Hello world' + } + } + }); + const result = api.translate('header.title'); + expect(result).toBe('Hello world'); + }); + it('createApi falls back to standard string if language is not found', () => { const config = { language: 'en', diff --git a/src/plugins/piral-translate/src/create.ts b/src/plugins/piral-translate/src/create.ts index 2283cb45f..0543c0def 100644 --- a/src/plugins/piral-translate/src/create.ts +++ b/src/plugins/piral-translate/src/create.ts @@ -4,7 +4,8 @@ import type { PiralPlugin } from 'piral-core'; import { createActions } from './actions'; import { Localizer } from './localize'; import { DefaultPicker } from './default'; -import { PiletLocaleApi, LocalizationMessages, Localizable, PiralSelectLanguageEvent } from './types'; +import { PiletLocaleApi, LocalizationMessages, Localizable, PiralSelectLanguageEvent, NestedLocalizationMessages } from './types'; +import { flattenTranslations } from './flatten-translations'; export interface TranslationFallback { (key: string, language: string): string; @@ -22,7 +23,7 @@ export interface LocaleConfig { * Sets the default (global) localization messages. * @default {} */ - messages?: LocalizationMessages; + messages?: LocalizationMessages | NestedLocalizationMessages; /** * Sets the default language to use. */ @@ -72,8 +73,8 @@ export function createLocaleApi(localizer: Localizable = setupLocalizer()): Pira let localTranslations: LocalizationMessages = {}; return { - addTranslations(messages: LocalizationMessages[], isOverriding: boolean = true) { - const messagesToMerge: LocalizationMessages[] = messages; + addTranslations(messages, isOverriding = true) { + const messagesToMerge = messages; if (isOverriding) { messagesToMerge.unshift(localizer.messages); @@ -100,7 +101,7 @@ export function createLocaleApi(localizer: Localizable = setupLocalizer()): Pira return selected; }, setTranslations(messages) { - localTranslations = messages; + localTranslations = flattenTranslations(messages); }, getTranslations() { return localTranslations; diff --git a/src/plugins/piral-translate/src/flatten-translations.test.ts b/src/plugins/piral-translate/src/flatten-translations.test.ts new file mode 100644 index 000000000..a6f274054 --- /dev/null +++ b/src/plugins/piral-translate/src/flatten-translations.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest'; +import { flattenTranslations } from './flatten-translations'; + +describe('Flatten translations', () => { + it('flattenTranslations can handle flat keys', () => { + const messages = { + en: { + key: 'value' + }, + fr: { + key: 'value (fr)' + } + }; + const flatMessages = flattenTranslations(messages); + expect(flatMessages.fr.key).toEqual('value (fr)'); + }); + + it('flattenTranslations can handle flat dot keys', () => { + const messages = { + en: { + 'header.title': 'Hello world' + } + }; + const flatMessages = flattenTranslations(messages); + expect(flatMessages.en['header.title']).toBe('Hello world'); + }); + + it('flattenTranslations can handle nested keys', () => { + const messages = { + en: { + header: { + title: 'Hello world' + } + } + }; + const flatMessages = flattenTranslations(messages); + expect(flatMessages.en['header.title']).toBe('Hello world'); + }); + + it('flattenTranslations can handle nested keys with multiple depth levels', () => { + const messages = { + en: { + header: { + title: { + subtitle: 'Hello world' + } + } + } + }; + const flatMessages = flattenTranslations(messages); + expect(flatMessages.en['header.title.subtitle']).toBe('Hello world'); + }); +}); diff --git a/src/plugins/piral-translate/src/flatten-translations.ts b/src/plugins/piral-translate/src/flatten-translations.ts new file mode 100644 index 000000000..8969f3318 --- /dev/null +++ b/src/plugins/piral-translate/src/flatten-translations.ts @@ -0,0 +1,46 @@ +import { LocalizationMessages, NestedLocalizationMessages } from './types'; + +export function flattenTranslations(messages: LocalizationMessages | NestedLocalizationMessages): LocalizationMessages { + return Object.fromEntries( + Object + .entries(messages) + .map(([ language, translations ]) => { + return [ + language, + flat(translations) + ] + }) + ); +} + +function flat(source: Record): Record { + const target: Record = {}; + + flatten(source, target); + + return target; +} + +function flatten(source: any, target: Record, prop = '') { + if (typeof source === 'string') { + target[prop] = source; + + return; + } + + if (typeof source === 'object' && source !== null) { + Object + .keys(source) + .forEach((key) => { + flatten( + source[key], + target, + prop + ? `${prop}.${key}` + : key + ); + }); + + return; + } +} diff --git a/src/plugins/piral-translate/src/localize.test.ts b/src/plugins/piral-translate/src/localize.test.ts index 56255e433..510f8025d 100644 --- a/src/plugins/piral-translate/src/localize.test.ts +++ b/src/plugins/piral-translate/src/localize.test.ts @@ -8,6 +8,9 @@ const messages = { en: { hi: 'hello', greeting: 'Hi {{name}}, welcome back', + header: { + title: 'Hello world' + } }, de: { hi: 'hallo', @@ -76,4 +79,11 @@ describe('Localize Module', () => { const result = localizer.localizeGlobal('greeting', { name: undefined }); expect(result).toBe('Hi , welcome back'); }); + + it('localizeGlobal translates from global translations using passed nested translations', () => { + const localizer = new Localizer(messages, 'en'); + const result = localizer.localizeGlobal('header.title'); + + expect(result).toBe('Hello world'); + }); }); diff --git a/src/plugins/piral-translate/src/localize.ts b/src/plugins/piral-translate/src/localize.ts index c2e3567ff..fbed98e3a 100644 --- a/src/plugins/piral-translate/src/localize.ts +++ b/src/plugins/piral-translate/src/localize.ts @@ -1,4 +1,5 @@ -import { LocalizationMessages, Localizable } from './types'; +import { LocalizationMessages, Localizable, NestedLocalizationMessages } from './types'; +import { flattenTranslations } from './flatten-translations'; function defaultFallback(key: string, language: string): string { if (process.env.NODE_ENV === 'production') { @@ -20,15 +21,19 @@ function formatMessage(message: string, variables: T): string } export class Localizer implements Localizable { + public messages: LocalizationMessages; + /** * Creates a new instance of a localizer. */ constructor( - public messages: LocalizationMessages, + messages: LocalizationMessages | NestedLocalizationMessages, public language: string, public languages: Array, private fallback = defaultFallback, - ) {} + ) { + this.messages = flattenTranslations(messages); + } /** * Localizes the given key via the global translations. diff --git a/src/plugins/piral-translate/src/types.ts b/src/plugins/piral-translate/src/types.ts index 6c2d5942e..66bbe0f5b 100644 --- a/src/plugins/piral-translate/src/types.ts +++ b/src/plugins/piral-translate/src/types.ts @@ -100,6 +100,13 @@ export interface Translations { [tag: string]: string; } +export interface NestedTranslations { + /** + * The available wordings (tag to translation or nested translations). + */ + [tag: string]: string | NestedTranslations; +} + export interface LanguageLoader { (language: string, current: LanguageData): Promise; } @@ -119,6 +126,13 @@ export interface LocalizationMessages { [lang: string]: Translations; } +export interface NestedLocalizationMessages { + /** + * The available languages (lang to wordings or nested wordings). + */ + [lang: string]: NestedTranslations; +} + export interface PiletLocaleApi { /** * Adds a list of translations to the existing translations. @@ -129,7 +143,7 @@ export interface PiletLocaleApi { * @param messagesList The list of messages that extend the existing translations * @param [isOverriding=true] Indicates whether the new translations overwrite the existing translations */ - addTranslations(messagesList: LocalizationMessages[], isOverriding?: boolean): void; + addTranslations(messagesList: (LocalizationMessages | NestedLocalizationMessages)[], isOverriding?: boolean): void; /** * Gets the currently selected language directly. */ @@ -151,7 +165,7 @@ export interface PiletLocaleApi { * The translations will be exclusively used for retrieving translations for the pilet. * @param messages The messages to use as translation basis. */ - setTranslations(messages: LocalizationMessages): void; + setTranslations(messages: LocalizationMessages | NestedLocalizationMessages): void; /** * Gets the currently provided translations by the pilet. */