From 937165c8d806f50538acb0c89fb252b89df31373 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 14 Jun 2024 15:33:15 +0200 Subject: [PATCH 01/39] TASK: Implement `TranslationAddress` value object --- .eslintrc.js | 5 +- .../src/registry/TranslationAddress.spec.ts | 42 +++++++++++++++ .../src/registry/TranslationAddress.ts | 51 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts create mode 100644 packages/neos-ui-i18n/src/registry/TranslationAddress.ts diff --git a/.eslintrc.js b/.eslintrc.js index cfff665808..b25c1fd719 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,9 @@ module.exports = { 'default-case': 'off', 'no-mixed-operators': 'off', 'no-negated-condition': 'off', - 'complexity': 'off' + 'complexity': 'off', + + // This rule would prevent us from implementing meaningful value objects + 'no-useless-constructor': 'off' }, } diff --git a/packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts new file mode 100644 index 0000000000..135856cf1d --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts @@ -0,0 +1,42 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {TranslationAddress, TranslationAddressIsInvalid} from './TranslationAddress'; + +describe('TranslationAddress', () => { + it('can be created from parts', () => { + const translationAddress = TranslationAddress.create({ + id: 'some.transunit.id', + sourceName: 'SomeSource', + packageKey: 'Some.Package' + }); + + expect(translationAddress.id).toBe('some.transunit.id'); + expect(translationAddress.sourceName).toBe('SomeSource'); + expect(translationAddress.packageKey).toBe('Some.Package'); + }); + + it('can be created from string', () => { + const translationAddress = TranslationAddress.fromString( + 'Some.Package:SomeSource:some.transunit.id' + ); + + expect(translationAddress.id).toBe('some.transunit.id'); + expect(translationAddress.sourceName).toBe('SomeSource'); + expect(translationAddress.packageKey).toBe('Some.Package'); + }); + + it('throws if given an invalid string', () => { + expect(() => TranslationAddress.fromString('foo bar')) + .toThrow( + TranslationAddressIsInvalid + .becauseStringDoesNotAdhereToExpectedFormat('foo bar') + ); + }); +}); diff --git a/packages/neos-ui-i18n/src/registry/TranslationAddress.ts b/packages/neos-ui-i18n/src/registry/TranslationAddress.ts new file mode 100644 index 0000000000..4d10be2753 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/TranslationAddress.ts @@ -0,0 +1,51 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +const TRANSLATION_ADDRESS_SEPARATOR = ':'; + +export class TranslationAddress { + private constructor( + public readonly id: string, + public readonly sourceName: string, + public readonly packageKey: string + ) {} + + public static create = (props: { + id: string; + sourceName: string; + packageKey: string; + }): TranslationAddress => + new TranslationAddress(props.id, props.sourceName, props.packageKey); + + public static fromString = (string: string): TranslationAddress => { + const parts = string.split(TRANSLATION_ADDRESS_SEPARATOR); + if (parts.length !== 3) { + throw TranslationAddressIsInvalid + .becauseStringDoesNotAdhereToExpectedFormat(string); + } + + const [packageKey, sourceName, id] = parts; + + return new TranslationAddress(id, sourceName, packageKey); + } +} + +export class TranslationAddressIsInvalid extends Error { + private constructor(message: string) { + super(message); + } + + public static becauseStringDoesNotAdhereToExpectedFormat( + attemptedString: string + ): TranslationAddressIsInvalid { + return new TranslationAddressIsInvalid( + `TranslationAddress must adhere to format "{packageKey}:{sourceName}:{transUnitId}". Got "${attemptedString}" instead.` + ); + } +} From b646bac7e2be29bf13f1bc865c6059b30bd8538a Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 14 Jun 2024 15:51:35 +0200 Subject: [PATCH 02/39] TASK: Extract function `getTranslationAddress` --- .../neos-ui-i18n/src/registry/I18nRegistry.js | 10 ++---- .../registry/getTranslationAddress.spec.ts | 34 +++++++++++++++++++ .../src/registry/getTranslationAddress.ts | 28 +++++++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts create mode 100644 packages/neos-ui-i18n/src/registry/getTranslationAddress.ts diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.js b/packages/neos-ui-i18n/src/registry/I18nRegistry.js index f5f45ef896..6538903c96 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.js +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.js @@ -2,15 +2,9 @@ import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/regis import logger from '@neos-project/utils-logger'; -const errorCache = {}; - -const getTranslationAddress = function (id, packageKey, sourceName) { - if (id && id.indexOf(':') !== -1) { - return id.split(':'); - } +import {getTranslationAddress} from './getTranslationAddress'; - return [packageKey, sourceName, id]; -}; +const errorCache = {}; /** * This code is taken from the Ember version with minor adjustments. Possibly refactor it later diff --git a/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts b/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts new file mode 100644 index 0000000000..5f19fb4fed --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts @@ -0,0 +1,34 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {getTranslationAddress} from './getTranslationAddress'; + +describe('getTranslationAddress', () => { + it('provides a translation address tuple if given a single string as parameter', () => { + const [packageKey, sourceName, id] = getTranslationAddress( + 'Some.Package:SomeSource:some.transunit.id' + ); + + expect(id).toBe('some.transunit.id'); + expect(sourceName).toBe('SomeSource'); + expect(packageKey).toBe('Some.Package'); + }); + + it('provides a translation address tuple if given three separate parameters', () => { + const [packageKey, sourceName, id] = getTranslationAddress( + 'some.transunit.id', + 'Some.Package', + 'SomeSource' + ); + + expect(id).toBe('some.transunit.id'); + expect(sourceName).toBe('SomeSource'); + expect(packageKey).toBe('Some.Package'); + }); +}); diff --git a/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts b/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts new file mode 100644 index 0000000000..e0663763d2 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts @@ -0,0 +1,28 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export function getTranslationAddress( + fullyQualifiedTransUnitId: string +): [string, string, string]; +export function getTranslationAddress( + transUnitId: string, + packageKey: string, + sourceName: string +): [string, string, string]; +export function getTranslationAddress( + id: string, + packageKey?: string, + sourceName?: string +) { + if (id && id.indexOf(':') !== -1) { + return id.split(':'); + } + + return [packageKey, sourceName, id]; +} From d9f91e069109456f460ceb6f1eb567fff2b245a6 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 14 Jun 2024 19:52:39 +0200 Subject: [PATCH 03/39] TASK: Extract function `substitutePlaceholders` --- .../neos-ui-i18n/src/registry/I18nRegistry.js | 49 +------ .../registry/substitutePlaceholders.spec.ts | 138 ++++++++++++++++++ .../src/registry/substitutePlaceholders.ts | 60 ++++++++ 3 files changed, 199 insertions(+), 48 deletions(-) create mode 100644 packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts create mode 100644 packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.js b/packages/neos-ui-i18n/src/registry/I18nRegistry.js index 6538903c96..2e9eba8a1e 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.js +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.js @@ -3,57 +3,10 @@ import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/regis import logger from '@neos-project/utils-logger'; import {getTranslationAddress} from './getTranslationAddress'; +import {substitutePlaceholders} from './substitutePlaceholders'; const errorCache = {}; -/** - * This code is taken from the Ember version with minor adjustments. Possibly refactor it later - * as its style is not superb. - */ -const substitutePlaceholders = function (textWithPlaceholders, parameters) { - const result = []; - let startOfPlaceholder; - let offset = 0; - while ((startOfPlaceholder = textWithPlaceholders.indexOf('{', offset)) !== -1) { - const endOfPlaceholder = textWithPlaceholders.indexOf('}', offset); - const startOfNextPlaceholder = textWithPlaceholders.indexOf('{', startOfPlaceholder + 1); - - if (endOfPlaceholder === -1 || (startOfPlaceholder + 1) >= endOfPlaceholder || (startOfNextPlaceholder !== -1 && startOfNextPlaceholder < endOfPlaceholder)) { - // There is no closing bracket, or it is placed before the opening bracket, or there is nothing between brackets - logger.error('Text provided contains incorrectly formatted placeholders. Please make sure you conform the placeholder\'s syntax.'); - break; - } - - const contentBetweenBrackets = textWithPlaceholders.substr(startOfPlaceholder + 1, endOfPlaceholder - startOfPlaceholder - 1); - const placeholderElements = contentBetweenBrackets.replace(' ', '').split(','); - - const valueIndex = placeholderElements[0]; - const value = parameters[valueIndex]; - if (typeof value === 'undefined') { - logger.error('Placeholder "' + valueIndex + '" was not provided, make sure you provide values for every placeholder.'); - break; - } - - let formattedPlaceholder; - if (typeof placeholderElements[1] === 'undefined') { - // No formatter defined, just string-cast the value - formattedPlaceholder = parameters[valueIndex]; - } else { - logger.error('Placeholder formatter not supported.'); - break; - } - - result.push(textWithPlaceholders.substr(offset, startOfPlaceholder - offset)); - result.push(formattedPlaceholder); - - offset = endOfPlaceholder + 1; - } - - result.push(textWithPlaceholders.substr(offset)); - - return result.join(''); -}; - const getPluralForm = (translation, quantity = 0) => { const translationHasPlurals = translation instanceof Object; diff --git a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts new file mode 100644 index 0000000000..5b7216dd1d --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts @@ -0,0 +1,138 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import logger from '@neos-project/utils-logger'; +import {substitutePlaceholders} from './substitutePlaceholders'; + +describe('substitutePlaceholders', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with numerically indexed placeholders', () => { + it('substitutes placeholders with no formatter set', () => { + expect(substitutePlaceholders('Hello {0}!', ['World'])) + .toBe('Hello World!'); + expect(substitutePlaceholders('Foo {0}{1} Bar', ['{', '}'])) + .toBe('Foo {} Bar'); + }); + + it('substitutes placeholders for string-cast value if no formatter is set', () => { + expect(substitutePlaceholders('The answer is: {0}', [42])) + .toBe('The answer is: 42'); + }); + + it('complains if a placeholder has a formatter set', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('formatted {0,number} output?', [12]); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('formatter not supported') + ); + }); + + it('complains when an invalid placeholder is encountered', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('damaged {0{} placeholder', [12]); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('incorrectly formatted placeholder') + ); + }); + + it('complains when an insufficient number of arguments has been provided', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('at least 1 argument: {0}', []); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('provide values for every placeholder') + ); + + substitutePlaceholders('at least 3 arguments: {0} {1} {2}', ['foo', 'bar']); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('provide values for every placeholder') + ); + }); + + it('substitutes multiple occurrences of the same placeholder', () => { + expect(substitutePlaceholders('{0} {0} {0} {1} {1} {1}', ['foo', 'bar'])) + .toBe('foo foo foo bar bar bar'); + }); + + it('substitutes placeholders regardless of order in text', () => { + expect(substitutePlaceholders('{2} {1} {3} {0}', ['foo', 'bar', 'baz', 'qux'])) + .toBe('baz bar qux foo'); + }); + }); + + describe('with named placeholders', () => { + it('substitutes placeholders with no formatter set', () => { + expect(substitutePlaceholders('Hello {name}!', {name: 'World'})) + .toBe('Hello World!'); + expect(substitutePlaceholders('Foo {a}{b} Bar', {a: '{', b: '}'})) + .toBe('Foo {} Bar'); + }); + + it('substitutes placeholders for string-cast value if no formatter is set', () => { + expect(substitutePlaceholders('The answer is: {answer}', {answer: 42})) + .toBe('The answer is: 42'); + }); + + it('complains if a placeholder has a formatter set', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('formatted {a,number} output?', {a: 12}); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('formatter not supported') + ); + }); + + it('complains when an invalid placeholder is encountered', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('damaged {broken{} placeholder', {broken: 12}); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('incorrectly formatted placeholder') + ); + }); + + it('complains when an insufficient number of arguments has been provided', () => { + const logError = jest.spyOn(logger, 'error'); + substitutePlaceholders('at least 1 argument: {a}', {}); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('provide values for every placeholder') + ); + + substitutePlaceholders('at least 3 arguments: {a} {b} {c}', {a: 'foo', c: 'bar'}); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('provide values for every placeholder') + ); + }); + + it('substitutes multiple occurrences of the same placeholder', () => { + expect( + substitutePlaceholders( + '{name} {name} {name} {value} {value} {value}', + {name: 'foo', value: 'bar'} + ) + ).toBe('foo foo foo bar bar bar'); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts new file mode 100644 index 0000000000..2322d8b9f4 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts @@ -0,0 +1,60 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import logger from '@neos-project/utils-logger'; + +/** + * This code is taken from the Ember version with minor adjustments. Possibly refactor it later + * as its style is not superb. + */ +export const substitutePlaceholders = function (textWithPlaceholders: string, parameters: (string|number)[] | Record) { + const result = []; + let startOfPlaceholder; + let offset = 0; + while ((startOfPlaceholder = textWithPlaceholders.indexOf('{', offset)) !== -1) { + const endOfPlaceholder = textWithPlaceholders.indexOf('}', offset); + const startOfNextPlaceholder = textWithPlaceholders.indexOf('{', startOfPlaceholder + 1); + + if (endOfPlaceholder === -1 || (startOfPlaceholder + 1) >= endOfPlaceholder || (startOfNextPlaceholder !== -1 && startOfNextPlaceholder < endOfPlaceholder)) { + // There is no closing bracket, or it is placed before the opening bracket, or there is nothing between brackets + logger.error('Text provided contains incorrectly formatted placeholders. Please make sure you conform the placeholder\'s syntax.'); + break; + } + + const contentBetweenBrackets = textWithPlaceholders.substr(startOfPlaceholder + 1, endOfPlaceholder - startOfPlaceholder - 1); + const placeholderElements = contentBetweenBrackets.replace(' ', '').split(','); + + const valueIndex = placeholderElements[0]; + const value = Array.isArray(parameters) + ? parameters[parseInt(valueIndex, 10)] + : parameters[valueIndex]; + if (typeof value === 'undefined') { + logger.error('Placeholder "' + valueIndex + '" was not provided, make sure you provide values for every placeholder.'); + break; + } + + let formattedPlaceholder; + if (typeof placeholderElements[1] === 'undefined') { + // No formatter defined, just string-cast the value + formattedPlaceholder = value; + } else { + logger.error('Placeholder formatter not supported.'); + break; + } + + result.push(textWithPlaceholders.substr(offset, startOfPlaceholder - offset)); + result.push(formattedPlaceholder); + + offset = endOfPlaceholder + 1; + } + + result.push(textWithPlaceholders.substr(offset)); + + return result.join(''); +}; From d237cb10286152f40593b213f3fbb59573b42d14 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 12:59:39 +0200 Subject: [PATCH 04/39] TASK: Extract function `getPluralForm` --- .../neos-ui-i18n/src/registry/I18nRegistry.js | 16 +------ .../src/registry/TranslationUnit.ts | 1 + .../src/registry/getPluralForm.spec.ts | 44 +++++++++++++++++++ .../src/registry/getPluralForm.ts | 25 +++++++++++ 4 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 packages/neos-ui-i18n/src/registry/TranslationUnit.ts create mode 100644 packages/neos-ui-i18n/src/registry/getPluralForm.spec.ts create mode 100644 packages/neos-ui-i18n/src/registry/getPluralForm.ts diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.js b/packages/neos-ui-i18n/src/registry/I18nRegistry.js index 2e9eba8a1e..9fd3f22adb 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.js +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.js @@ -4,24 +4,10 @@ import logger from '@neos-project/utils-logger'; import {getTranslationAddress} from './getTranslationAddress'; import {substitutePlaceholders} from './substitutePlaceholders'; +import {getPluralForm} from './getPluralForm'; const errorCache = {}; -const getPluralForm = (translation, quantity = 0) => { - const translationHasPlurals = translation instanceof Object; - - // no defined quantity or less than one returns singular - if (translationHasPlurals && (!quantity || quantity <= 1)) { - return translation[0]; - } - - if (translationHasPlurals && quantity > 1) { - return translation[1] ? translation[1] : translation[0]; - } - - return translation; -}; - export default class I18nRegistry extends SynchronousRegistry { _translations = {}; diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnit.ts b/packages/neos-ui-i18n/src/registry/TranslationUnit.ts new file mode 100644 index 0000000000..a62a0d78a8 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/TranslationUnit.ts @@ -0,0 +1 @@ +export type TranslationUnit = string | [string, string] | Record; diff --git a/packages/neos-ui-i18n/src/registry/getPluralForm.spec.ts b/packages/neos-ui-i18n/src/registry/getPluralForm.spec.ts new file mode 100644 index 0000000000..92b2aeab96 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/getPluralForm.spec.ts @@ -0,0 +1,44 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {getPluralForm} from './getPluralForm'; + +describe('getPluralForm', () => { + it('returns translation if translation has no plural form', () => { + expect(getPluralForm('has no plural form')) + .toBe('has no plural form'); + expect(getPluralForm('has no plural form', 0)) + .toBe('has no plural form'); + expect(getPluralForm('has no plural form', 1)) + .toBe('has no plural form'); + expect(getPluralForm('has no plural form', 2)) + .toBe('has no plural form'); + expect(getPluralForm('has no plural form', 42)) + .toBe('has no plural form'); + }); + + it('returns singular if translation has plural form and quantity is one', () => { + expect(getPluralForm(['has singular form', 'has plural form'], 1)) + .toBe('has singular form'); + }); + + it('returns singular if translation has plural form and quantity is zero', () => { + expect(getPluralForm(['has singular form', 'has plural form'], 0)) + .toBe('has singular form'); + expect(getPluralForm(['has singular form and default quantity is 0', 'has plural form and default quantity is 0'])) + .toBe('has singular form and default quantity is 0'); + }); + + it('returns plural if translation has plural form and quantity greater than one', () => { + expect(getPluralForm(['has singular form', 'has plural form'], 2)) + .toBe('has plural form'); + expect(getPluralForm(['has singular form', 'has plural form'], 42)) + .toBe('has plural form'); + }); +}); diff --git a/packages/neos-ui-i18n/src/registry/getPluralForm.ts b/packages/neos-ui-i18n/src/registry/getPluralForm.ts new file mode 100644 index 0000000000..e899e14bce --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/getPluralForm.ts @@ -0,0 +1,25 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import type {TranslationUnit} from './TranslationUnit'; + +export const getPluralForm = (translation: TranslationUnit, quantity = 0): string => { + const translationHasPlurals = translation instanceof Object; + + // no defined quantity or less than one returns singular + if (translationHasPlurals && (!quantity || quantity <= 1)) { + return translation[0]; + } + + if (translationHasPlurals && quantity > 1) { + return translation[1] ? translation[1] : translation[0]; + } + + return translation as string; +}; From 2173568a7377844e9cdb7e1a1dc3ea6c9d63458d Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 14:15:25 +0200 Subject: [PATCH 05/39] TASK: Convert I18nRegistry to typescript --- .../neos-ui-i18n/src/registry/I18nRegistry.js | 44 ---- ...nRegistry.spec.js => I18nRegistry.spec.ts} | 29 ++- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 213 ++++++++++++++++++ 3 files changed, 232 insertions(+), 54 deletions(-) delete mode 100644 packages/neos-ui-i18n/src/registry/I18nRegistry.js rename packages/neos-ui-i18n/src/registry/{I18nRegistry.spec.js => I18nRegistry.spec.ts} (89%) create mode 100644 packages/neos-ui-i18n/src/registry/I18nRegistry.ts diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.js b/packages/neos-ui-i18n/src/registry/I18nRegistry.js deleted file mode 100644 index 9fd3f22adb..0000000000 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.js +++ /dev/null @@ -1,44 +0,0 @@ -import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/registry'; - -import logger from '@neos-project/utils-logger'; - -import {getTranslationAddress} from './getTranslationAddress'; -import {substitutePlaceholders} from './substitutePlaceholders'; -import {getPluralForm} from './getPluralForm'; - -const errorCache = {}; - -export default class I18nRegistry extends SynchronousRegistry { - _translations = {}; - - setTranslations(translations) { - this._translations = translations; - } - - // eslint-disable-next-line max-params - translate(idOrig, fallbackOrig, params = {}, packageKeyOrig = 'Neos.Neos', sourceNameOrig = 'Main', quantity = 0) { - const fallback = fallbackOrig || idOrig; - const [packageKey, sourceName, id] = getTranslationAddress(idOrig, packageKeyOrig, sourceNameOrig); - let translation = [packageKey, sourceName, id] - // Replace all dots with underscores - .map(s => s ? s.replace(/\./g, '_') : '') - // Traverse through translations and find us a fitting one - .reduce((prev, cur) => (prev ? prev[cur] || '' : ''), this._translations); - - translation = getPluralForm(translation, quantity); - if (translation && translation.length) { - if (Object.keys(params).length) { - return substitutePlaceholders(translation, params); - } - return translation; - } - - if (!errorCache[`${packageKey}:${sourceName}:${id}`]) { - logger.error(`No translation found for id "${packageKey}:${sourceName}:${id}" in:`, this._translations, `Using ${fallback} instead.`); - - errorCache[`${packageKey}:${sourceName}:${id}`] = true; - } - - return fallback; - } -} diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.js b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts similarity index 89% rename from packages/neos-ui-i18n/src/registry/I18nRegistry.spec.js rename to packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts index 32f4038a29..32cf1c7ecc 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.js +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts @@ -1,9 +1,18 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ import I18nRegistry from './I18nRegistry'; test(` Host > Containers > I18n: should display configured fallback, if no translation was found.`, () => { - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); const actual = registry.translate('', 'The Fallback'); expect(actual).toBe('The Fallback'); @@ -12,7 +21,7 @@ test(` test(` Host > Containers > I18n: should display the trans unit id, if no translation was found and no fallback was configured.`, () => { - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); const actual = registry.translate('The Trans Unit ID'); expect(actual).toBe('The Trans Unit ID'); @@ -29,7 +38,7 @@ test(` } }; - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:someLabel'); @@ -47,7 +56,7 @@ test(` } }; - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main'); @@ -68,7 +77,7 @@ test(` } }; - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main'); @@ -89,7 +98,7 @@ test(` } }; - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 0); @@ -110,7 +119,7 @@ test(` } }; - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 1); @@ -131,7 +140,7 @@ test(` } }; - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); @@ -152,7 +161,7 @@ test(` } }; - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); @@ -172,7 +181,7 @@ test(` } }; - const registry = new I18nRegistry(); + const registry = new I18nRegistry(''); registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts new file mode 100644 index 0000000000..0fcdc9e524 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -0,0 +1,213 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/registry'; + +import logger from '@neos-project/utils-logger'; + +import {getTranslationAddress} from './getTranslationAddress'; +import {substitutePlaceholders} from './substitutePlaceholders'; +import {getPluralForm} from './getPluralForm'; +import type {TranslationUnit} from './TranslationUnit'; + +const errorCache: Record = {}; + +type Translations = Record>>; +type Parameters = (string | number)[] | Record; + +export default class I18nRegistry extends SynchronousRegistry { + private _translations: Translations = {}; + + /** @internal */ + setTranslations(translations: Translations) { + this._translations = translations; + } + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. If that id is a fully qualified trans-unit id (an id + * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), + * then the translation will be looked up in the respective package and + * *.xlf file. If it's just a simple trans-unit id, the translation will + * be looked up in the "Main.xlf" file of the "Neos.Neos" package. + * + * If no translation string can be found for the given id, the fully + * qualified trans-unit id will be returned. + * + * @param {string} transUnitIdOrFullyQualifiedTransUnitId A trans-unit id or a fully qualified trans-unit id + */ + translate(transUnitIdOrFullyQualifiedTransUnitId: string): string; + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. If that id is a fully qualified trans-unit id (an id + * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), + * then the translation will be looked up in the respective package and + * *.xlf file. If it's just a simple trans-unit id, the translation will + * be looked up in the "Main.xlf" file of the "Neos.Neos" package. + * + * If no translation string can be found for the given id, the given + * fallback value will be returned. + * + * @param {string} transUnitIdOrFullyQualifiedTransUnitId A trans-unit id or a fully qualified trans-unit id + * @param {string} fallback The string that shall be displayed, when no translation string could be found. + */ + translate(transUnitIdOrFullyQualifiedTransUnitId: string, fallback: string): string; + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. If that id is a fully qualified trans-unit id (an id + * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), + * then the translation will be looked up in the respective package and + * *.xlf file. If it's just a simple trans-unit id, the translation will + * be looked up in the "Main.xlf" file of the "Neos.Neos" package. + * + * If no translation string can be found for the given id, the given + * fallback value will be returned. If no fallback value has been given, + * the fully qualified trans-unit id will be returned. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * @param {string} transUnitIdOrFullyQualifiedTransUnitId The fully qualified trans-unit id, that follows the format "{Package.Key:SourceName:trans.unit.id}" + * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. + * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string + */ + translate( + transUnitIdOrFullyQualifiedTransUnitId: string, + fallback: undefined | string, + parameters: Parameters + ): string; + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. The translation file will be looked up inside the package + * identified by the given package key. The file itself will be the Main.xlf + * in that package's resource translations. + * + * If no translation string can be found for the given id, the given fallback + * value will be returned. If no fallback value has been given, the fully + * qualified trans-unit id will be returned. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * @param {string} transUnitId The trans-unit id + * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. + * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string + * @param {string} packageKey The key of the package in which to look for the translation file + */ + translate( + transUnitId: string, + fallback: undefined | string, + parameters: undefined | Parameters, + packageKey: string + ): string; + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. The translation file will be looked up inside the package + * identified by the given package key. The file itself will be the *.xlf file + * in that package's resource translations that is identified by the given + * sourceName. + * + * If no translation string can be found for the given id, the given fallback + * value will be returned. If no fallback value has been given, the fully + * qualified trans-unit id will be returned. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * @param {string} transUnitId The trans-unit id + * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. + * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string + * @param {string} packageKey The key of the package in which to look for the translation file + * @param {string} sourceName The name of the translation file in that package's resource translations + */ + translate( + transUnitId: string, + fallback: undefined | string, + parameters: undefined | Parameters, + packageKey: string, + sourceName: string + ): string; + + /** + * Retrieves a the translation string that is identified by the given + * trans-unit id. The translation file will be looked up inside the package + * identified by the given package key. The file itself will be the *.xlf file + * in that package's resource translations that is identified by the given + * sourceName. + * + * If no translation string can be found for the given id, the given fallback + * value will be returned. If no fallback value has been given, the fully + * qualified trans-unit id will be returned. + * + * If the provided quantity is greater than 1, and the found translation has a + * plural form, then the plural form will be used. If the quantity equals 1 + * or is smaller than 1, the singular form will be used. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * @param {string} transUnitId The trans-unit id + * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. + * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string + * @param {string} packageKey The key of the package in which to look for the translation file + * @param {string} sourceName The name of the translation file in that package's resource translations + */ + translate( + transUnitId: string, + fallback: undefined | string, + parameters: undefined | Parameters, + packageKey: string, + sourceName: string, + quantity: number + ): string; + + translate( + transUnitIdOrFullyQualifiedTransUnitId: string, + explicitlyProvidedFallback?: string, + parameters?: Parameters, + explicitlyProvidedPackageKey: string = 'Neos.Neos', + explicitlyProvidedSourceName: string = 'Main', + quantity: number = 0 + ) { + const fallback = explicitlyProvidedFallback || transUnitIdOrFullyQualifiedTransUnitId; + const [packageKey, sourceName, id] = getTranslationAddress(transUnitIdOrFullyQualifiedTransUnitId, explicitlyProvidedPackageKey, explicitlyProvidedSourceName); + const translationUnit = this.getTranslationUnit(packageKey, sourceName, id); + if (translationUnit === null) { + this.logTranslationUnitNotFound(packageKey, sourceName, id, fallback); + return fallback; + } + + return parameters + ? substitutePlaceholders(getPluralForm(translationUnit, quantity), parameters) + : getPluralForm(translationUnit, quantity); + } + + private logTranslationUnitNotFound(packageKey: string, sourceName: string, id: string, fallback: string) { + if (!errorCache[`${packageKey}:${sourceName}:${id}`]) { + logger.error(`No translation found for id "${packageKey}:${sourceName}:${id}" in:`, this._translations, `Using ${fallback} instead.`); + errorCache[`${packageKey}:${sourceName}:${id}`] = true; + } + } + + private getTranslationUnit(packageKey: string, sourceName: string, id: string): null | TranslationUnit { + [packageKey, sourceName, id] = [packageKey, sourceName, id] + // Replace all dots with underscores + .map(s => s ? s.replace(/\./g, '_') : '') + + return this._translations[packageKey]?.[sourceName]?.[id] ?? null; + } +} From 571c9478f313abd2e809efa3249a13a3d9e1b350 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 16:59:26 +0200 Subject: [PATCH 06/39] TASK: Return actual TranslationAddress from `getTranslationAddress` --- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 19 ++++++++++--------- .../registry/getTranslationAddress.spec.ts | 16 ++++++++-------- .../src/registry/getTranslationAddress.ts | 18 ++++++++++++++---- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 0fcdc9e524..1a0ade514a 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -15,6 +15,7 @@ import {getTranslationAddress} from './getTranslationAddress'; import {substitutePlaceholders} from './substitutePlaceholders'; import {getPluralForm} from './getPluralForm'; import type {TranslationUnit} from './TranslationUnit'; +import type {TranslationAddress} from './TranslationAddress'; const errorCache: Record = {}; @@ -184,10 +185,10 @@ export default class I18nRegistry extends SynchronousRegistry { quantity: number = 0 ) { const fallback = explicitlyProvidedFallback || transUnitIdOrFullyQualifiedTransUnitId; - const [packageKey, sourceName, id] = getTranslationAddress(transUnitIdOrFullyQualifiedTransUnitId, explicitlyProvidedPackageKey, explicitlyProvidedSourceName); - const translationUnit = this.getTranslationUnit(packageKey, sourceName, id); + const translationAddess = getTranslationAddress(transUnitIdOrFullyQualifiedTransUnitId, explicitlyProvidedPackageKey, explicitlyProvidedSourceName); + const translationUnit = this.getTranslationUnit(translationAddess); if (translationUnit === null) { - this.logTranslationUnitNotFound(packageKey, sourceName, id, fallback); + this.logTranslationUnitNotFound(translationAddess, fallback); return fallback; } @@ -196,15 +197,15 @@ export default class I18nRegistry extends SynchronousRegistry { : getPluralForm(translationUnit, quantity); } - private logTranslationUnitNotFound(packageKey: string, sourceName: string, id: string, fallback: string) { - if (!errorCache[`${packageKey}:${sourceName}:${id}`]) { - logger.error(`No translation found for id "${packageKey}:${sourceName}:${id}" in:`, this._translations, `Using ${fallback} instead.`); - errorCache[`${packageKey}:${sourceName}:${id}`] = true; + private logTranslationUnitNotFound(address: TranslationAddress, fallback: string) { + if (!errorCache[address.toString()]) { + logger.error(`No translation found for id "${address.toString()}" in:`, this._translations, `Using ${fallback} instead.`); + errorCache[address.toString()] = true; } } - private getTranslationUnit(packageKey: string, sourceName: string, id: string): null | TranslationUnit { - [packageKey, sourceName, id] = [packageKey, sourceName, id] + private getTranslationUnit(address: TranslationAddress): null | TranslationUnit { + const [packageKey, sourceName, id] = [address.packageKey, address.sourceName, address.id] // Replace all dots with underscores .map(s => s ? s.replace(/\./g, '_') : '') diff --git a/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts b/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts index 5f19fb4fed..3707e20377 100644 --- a/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts +++ b/packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts @@ -11,24 +11,24 @@ import {getTranslationAddress} from './getTranslationAddress'; describe('getTranslationAddress', () => { it('provides a translation address tuple if given a single string as parameter', () => { - const [packageKey, sourceName, id] = getTranslationAddress( + const translationAddress = getTranslationAddress( 'Some.Package:SomeSource:some.transunit.id' ); - expect(id).toBe('some.transunit.id'); - expect(sourceName).toBe('SomeSource'); - expect(packageKey).toBe('Some.Package'); + expect(translationAddress.id).toBe('some.transunit.id'); + expect(translationAddress.sourceName).toBe('SomeSource'); + expect(translationAddress.packageKey).toBe('Some.Package'); }); it('provides a translation address tuple if given three separate parameters', () => { - const [packageKey, sourceName, id] = getTranslationAddress( + const translationAddress = getTranslationAddress( 'some.transunit.id', 'Some.Package', 'SomeSource' ); - expect(id).toBe('some.transunit.id'); - expect(sourceName).toBe('SomeSource'); - expect(packageKey).toBe('Some.Package'); + expect(translationAddress.id).toBe('some.transunit.id'); + expect(translationAddress.sourceName).toBe('SomeSource'); + expect(translationAddress.packageKey).toBe('Some.Package'); }); }); diff --git a/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts b/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts index e0663763d2..89ec9c75c9 100644 --- a/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts +++ b/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts @@ -7,22 +7,32 @@ * information, please view the LICENSE file which was distributed with this * source code. */ +import {TranslationAddress} from './TranslationAddress'; + export function getTranslationAddress( fullyQualifiedTransUnitId: string -): [string, string, string]; +): TranslationAddress; export function getTranslationAddress( transUnitId: string, packageKey: string, sourceName: string -): [string, string, string]; +): TranslationAddress; export function getTranslationAddress( id: string, packageKey?: string, sourceName?: string ) { if (id && id.indexOf(':') !== -1) { - return id.split(':'); + return TranslationAddress.fromString(id); + } + + if (packageKey === undefined) { + throw new Error(`${id} is not a fully qualified trans-unit id. A package key must be provided.`); + } + + if (sourceName === undefined) { + throw new Error(`${id} is not a fully qualified trans-unit id. A source name must be provided.`); } - return [packageKey, sourceName, id]; + return TranslationAddress.create({packageKey, sourceName, id}); } From 6610d6fac94c52343589e62dec7b5e363cf52576 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 17:36:21 +0200 Subject: [PATCH 07/39] TASK: Introduce TranslationUnitRepository --- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 6 ++-- .../TranslationUnitRepository.spec.ts | 30 +++++++++++++++++++ .../src/registry/TranslationUnitRepository.ts | 28 +++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts create mode 100644 packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 1a0ade514a..19bf957820 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -16,17 +16,17 @@ import {substitutePlaceholders} from './substitutePlaceholders'; import {getPluralForm} from './getPluralForm'; import type {TranslationUnit} from './TranslationUnit'; import type {TranslationAddress} from './TranslationAddress'; +import type {TranslationsDTO} from './TranslationUnitRepository'; const errorCache: Record = {}; -type Translations = Record>>; type Parameters = (string | number)[] | Record; export default class I18nRegistry extends SynchronousRegistry { - private _translations: Translations = {}; + private _translations: TranslationsDTO = {}; /** @internal */ - setTranslations(translations: Translations) { + setTranslations(translations: TranslationsDTO) { this._translations = translations; } diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts new file mode 100644 index 0000000000..7de2ffa2ca --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts @@ -0,0 +1,30 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {TranslationAddress} from './TranslationAddress'; +import {TranslationUnitRepository} from './TranslationUnitRepository'; + +describe('TranslationUnitRepository', () => { + it('can find a translation unit by its translationAddress', () => { + const translationUnitRepository = TranslationUnitRepository.fromDTO({ + 'Neos_Neos': { // eslint-disable-line quote-props + 'Main': { // eslint-disable-line quote-props + 'someLabel': 'The Translation' // eslint-disable-line quote-props + } + } + }); + const translationAddressThatCanBeFound = TranslationAddress.fromString('Neos.Neos:Main:someLabel'); + const translationAddressThatCannotBeFound = TranslationAddress.fromString('Vendor.Site:Main:someLabel'); + + expect(translationUnitRepository.findOneByAddress(translationAddressThatCannotBeFound)) + .toBeNull(); + expect(translationUnitRepository.findOneByAddress(translationAddressThatCanBeFound)) + .toEqual('The Translation'); + }); +}); diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts new file mode 100644 index 0000000000..66f3c08890 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts @@ -0,0 +1,28 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import type {TranslationAddress} from './TranslationAddress'; +import type {TranslationUnit} from './TranslationUnit'; + +export type TranslationsDTO = Record>>; + +export class TranslationUnitRepository { + private constructor(private readonly translations: TranslationsDTO) {} + + public static fromDTO = (translations: TranslationsDTO): TranslationUnitRepository => + new TranslationUnitRepository(translations); + + public findOneByAddress = (address: TranslationAddress): null | TranslationUnit => { + const [packageKey, sourceName, id] = [address.packageKey, address.sourceName, address.id] + // Replace all dots with underscores + .map(s => s ? s.replace(/\./g, '_') : '') + + return this.translations[packageKey]?.[sourceName]?.[id] ?? null; + } +} From 65dd83e52f4378e22cb47631aa6f98a49799fc3a Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 17:44:25 +0200 Subject: [PATCH 08/39] TASK: Preserve fully qualified translation address --- packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 6 +++--- .../src/registry/TranslationAddress.spec.ts | 2 ++ .../neos-ui-i18n/src/registry/TranslationAddress.ts | 7 ++++--- .../src/registry/TranslationUnitRepository.ts | 11 ++++++++++- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 19bf957820..e44e506395 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -198,9 +198,9 @@ export default class I18nRegistry extends SynchronousRegistry { } private logTranslationUnitNotFound(address: TranslationAddress, fallback: string) { - if (!errorCache[address.toString()]) { - logger.error(`No translation found for id "${address.toString()}" in:`, this._translations, `Using ${fallback} instead.`); - errorCache[address.toString()] = true; + if (!errorCache[address.fullyQualified]) { + logger.error(`No translation found for id "${address.fullyQualified}" in:`, this._translations, `Using ${fallback} instead.`); + errorCache[address.fullyQualified] = true; } } diff --git a/packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts index 135856cf1d..f10ae30a7f 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts @@ -20,6 +20,7 @@ describe('TranslationAddress', () => { expect(translationAddress.id).toBe('some.transunit.id'); expect(translationAddress.sourceName).toBe('SomeSource'); expect(translationAddress.packageKey).toBe('Some.Package'); + expect(translationAddress.fullyQualified).toBe('Some.Package:SomeSource:some.transunit.id'); }); it('can be created from string', () => { @@ -30,6 +31,7 @@ describe('TranslationAddress', () => { expect(translationAddress.id).toBe('some.transunit.id'); expect(translationAddress.sourceName).toBe('SomeSource'); expect(translationAddress.packageKey).toBe('Some.Package'); + expect(translationAddress.fullyQualified).toBe('Some.Package:SomeSource:some.transunit.id'); }); it('throws if given an invalid string', () => { diff --git a/packages/neos-ui-i18n/src/registry/TranslationAddress.ts b/packages/neos-ui-i18n/src/registry/TranslationAddress.ts index 4d10be2753..a563b3889b 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationAddress.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationAddress.ts @@ -13,7 +13,8 @@ export class TranslationAddress { private constructor( public readonly id: string, public readonly sourceName: string, - public readonly packageKey: string + public readonly packageKey: string, + public readonly fullyQualified: string ) {} public static create = (props: { @@ -21,7 +22,7 @@ export class TranslationAddress { sourceName: string; packageKey: string; }): TranslationAddress => - new TranslationAddress(props.id, props.sourceName, props.packageKey); + new TranslationAddress(props.id, props.sourceName, props.packageKey, `${props.packageKey}:${props.sourceName}:${props.id}`); public static fromString = (string: string): TranslationAddress => { const parts = string.split(TRANSLATION_ADDRESS_SEPARATOR); @@ -32,7 +33,7 @@ export class TranslationAddress { const [packageKey, sourceName, id] = parts; - return new TranslationAddress(id, sourceName, packageKey); + return new TranslationAddress(id, sourceName, packageKey, string); } } diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts index 66f3c08890..d6e27d38dd 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts @@ -13,16 +13,25 @@ import type {TranslationUnit} from './TranslationUnit'; export type TranslationsDTO = Record>>; export class TranslationUnitRepository { + private _translationUnitsByAddress: Record = {}; + private constructor(private readonly translations: TranslationsDTO) {} public static fromDTO = (translations: TranslationsDTO): TranslationUnitRepository => new TranslationUnitRepository(translations); public findOneByAddress = (address: TranslationAddress): null | TranslationUnit => { + if (address.fullyQualified in this._translationUnitsByAddress) { + return this._translationUnitsByAddress[address.fullyQualified]; + } + const [packageKey, sourceName, id] = [address.packageKey, address.sourceName, address.id] // Replace all dots with underscores .map(s => s ? s.replace(/\./g, '_') : '') - return this.translations[packageKey]?.[sourceName]?.[id] ?? null; + const translationUnit = this.translations[packageKey]?.[sourceName]?.[id] ?? null; + this._translationUnitsByAddress[address.fullyQualified] = translationUnit; + + return translationUnit; } } From 5e85eea5264ad3a4fb189bc5cc36f2ab2c8c79e5 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 17:54:31 +0200 Subject: [PATCH 09/39] TASK: Fix misnomer f.q. trans-unit id -> f.q. translation address --- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index e44e506395..0f4fe2210b 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -32,57 +32,57 @@ export default class I18nRegistry extends SynchronousRegistry { /** * Retrieves a the translation string that is identified by the given - * trans-unit id. If that id is a fully qualified trans-unit id (an id + * identifier. If it is a fully qualified translation address (a string * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), * then the translation will be looked up in the respective package and - * *.xlf file. If it's just a simple trans-unit id, the translation will - * be looked up in the "Main.xlf" file of the "Neos.Neos" package. + * *.xlf file. If it's a trans-unit id, the translation will be looked up + * in the "Main.xlf" file of the "Neos.Neos" package. * * If no translation string can be found for the given id, the fully - * qualified trans-unit id will be returned. + * qualified translation address will be returned. * - * @param {string} transUnitIdOrFullyQualifiedTransUnitId A trans-unit id or a fully qualified trans-unit id + * @param {string} transUnitIdOrFullyQualifiedTranslationAddress A trans-unit id or a fully qualified translation address */ - translate(transUnitIdOrFullyQualifiedTransUnitId: string): string; + translate(transUnitIdOrFullyQualifiedTranslationAddress: string): string; /** * Retrieves a the translation string that is identified by the given - * trans-unit id. If that id is a fully qualified trans-unit id (an id + * identifier. If it is a fully qualified translation address (a string * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), * then the translation will be looked up in the respective package and - * *.xlf file. If it's just a simple trans-unit id, the translation will - * be looked up in the "Main.xlf" file of the "Neos.Neos" package. + * *.xlf file. If it's a trans-unit id, the translation will be looked up + * in the "Main.xlf" file of the "Neos.Neos" package. * * If no translation string can be found for the given id, the given * fallback value will be returned. * - * @param {string} transUnitIdOrFullyQualifiedTransUnitId A trans-unit id or a fully qualified trans-unit id + * @param {string} transUnitIdOrFullyQualifiedTranslationAddress A trans-unit id or a fully qualified translation address * @param {string} fallback The string that shall be displayed, when no translation string could be found. */ - translate(transUnitIdOrFullyQualifiedTransUnitId: string, fallback: string): string; + translate(transUnitIdOrFullyQualifiedTranslationAddress: string, fallback: string): string; /** * Retrieves a the translation string that is identified by the given - * trans-unit id. If that id is a fully qualified trans-unit id (an id + * identifier. If it is a fully qualified translation address (a string * following the pattern "{Package.Key:SourceName:actual.trans.unit.id}"), * then the translation will be looked up in the respective package and - * *.xlf file. If it's just a simple trans-unit id, the translation will - * be looked up in the "Main.xlf" file of the "Neos.Neos" package. + * *.xlf file. If it's just a trans-unit id, the translation will be looked + * up in the "Main.xlf" file of the "Neos.Neos" package. * * If no translation string can be found for the given id, the given * fallback value will be returned. If no fallback value has been given, - * the fully qualified trans-unit id will be returned. + * the fully qualified translation address will be returned. * * If a translation string was found and it contains substition placeholders * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced * with the corresponding values that were passed as parameters. * - * @param {string} transUnitIdOrFullyQualifiedTransUnitId The fully qualified trans-unit id, that follows the format "{Package.Key:SourceName:trans.unit.id}" + * @param {string} transUnitIdOrFullyQualifiedTranslationAddress The fully qualified translation address, that follows the format "{Package.Key:SourceName:trans.unit.id}" * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string */ translate( - transUnitIdOrFullyQualifiedTransUnitId: string, + transUnitIdOrFullyQualifiedTranslationAddress: string, fallback: undefined | string, parameters: Parameters ): string; @@ -95,7 +95,7 @@ export default class I18nRegistry extends SynchronousRegistry { * * If no translation string can be found for the given id, the given fallback * value will be returned. If no fallback value has been given, the fully - * qualified trans-unit id will be returned. + * qualified translation address will be returned. * * If a translation string was found and it contains substition placeholders * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced @@ -122,7 +122,7 @@ export default class I18nRegistry extends SynchronousRegistry { * * If no translation string can be found for the given id, the given fallback * value will be returned. If no fallback value has been given, the fully - * qualified trans-unit id will be returned. + * qualified translation address will be returned. * * If a translation string was found and it contains substition placeholders * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced @@ -151,7 +151,7 @@ export default class I18nRegistry extends SynchronousRegistry { * * If no translation string can be found for the given id, the given fallback * value will be returned. If no fallback value has been given, the fully - * qualified trans-unit id will be returned. + * qualified translation address will be returned. * * If the provided quantity is greater than 1, and the found translation has a * plural form, then the plural form will be used. If the quantity equals 1 @@ -177,15 +177,15 @@ export default class I18nRegistry extends SynchronousRegistry { ): string; translate( - transUnitIdOrFullyQualifiedTransUnitId: string, + transUnitIdOrFullyQualifiedTranslationAddress: string, explicitlyProvidedFallback?: string, parameters?: Parameters, explicitlyProvidedPackageKey: string = 'Neos.Neos', explicitlyProvidedSourceName: string = 'Main', quantity: number = 0 ) { - const fallback = explicitlyProvidedFallback || transUnitIdOrFullyQualifiedTransUnitId; - const translationAddess = getTranslationAddress(transUnitIdOrFullyQualifiedTransUnitId, explicitlyProvidedPackageKey, explicitlyProvidedSourceName); + const fallback = explicitlyProvidedFallback || transUnitIdOrFullyQualifiedTranslationAddress; + const translationAddess = getTranslationAddress(transUnitIdOrFullyQualifiedTranslationAddress, explicitlyProvidedPackageKey, explicitlyProvidedSourceName); const translationUnit = this.getTranslationUnit(translationAddess); if (translationUnit === null) { this.logTranslationUnitNotFound(translationAddess, fallback); From de0048a3809523824dadd0b4801eb99015d63f3b Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 20:36:54 +0200 Subject: [PATCH 10/39] TASK: Use TranslationUnitRepository for translation lookup in I18nRegistry --- packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 0f4fe2210b..1e51f76694 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -16,18 +16,18 @@ import {substitutePlaceholders} from './substitutePlaceholders'; import {getPluralForm} from './getPluralForm'; import type {TranslationUnit} from './TranslationUnit'; import type {TranslationAddress} from './TranslationAddress'; -import type {TranslationsDTO} from './TranslationUnitRepository'; +import {TranslationUnitRepository, TranslationsDTO} from './TranslationUnitRepository'; const errorCache: Record = {}; type Parameters = (string | number)[] | Record; export default class I18nRegistry extends SynchronousRegistry { - private _translations: TranslationsDTO = {}; + private _translations: null|TranslationUnitRepository = null; /** @internal */ setTranslations(translations: TranslationsDTO) { - this._translations = translations; + this._translations = TranslationUnitRepository.fromDTO(translations); } /** @@ -205,10 +205,6 @@ export default class I18nRegistry extends SynchronousRegistry { } private getTranslationUnit(address: TranslationAddress): null | TranslationUnit { - const [packageKey, sourceName, id] = [address.packageKey, address.sourceName, address.id] - // Replace all dots with underscores - .map(s => s ? s.replace(/\./g, '_') : '') - - return this._translations[packageKey]?.[sourceName]?.[id] ?? null; + return this._translations?.findOneByAddress(address) ?? null; } } From b893e4ad377aac7823dfe3af882d2c7d1432517a Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 20:54:41 +0200 Subject: [PATCH 11/39] TASK: Rename TranslationUnit -> TranslationUnitDTO --- packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 4 ++-- packages/neos-ui-i18n/src/registry/TranslationUnit.ts | 11 +++++++++++ .../src/registry/TranslationUnitRepository.ts | 8 ++++---- packages/neos-ui-i18n/src/registry/getPluralForm.ts | 4 ++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 1e51f76694..29025128bb 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -14,7 +14,7 @@ import logger from '@neos-project/utils-logger'; import {getTranslationAddress} from './getTranslationAddress'; import {substitutePlaceholders} from './substitutePlaceholders'; import {getPluralForm} from './getPluralForm'; -import type {TranslationUnit} from './TranslationUnit'; +import type {TranslationUnitDTO} from './TranslationUnit'; import type {TranslationAddress} from './TranslationAddress'; import {TranslationUnitRepository, TranslationsDTO} from './TranslationUnitRepository'; @@ -204,7 +204,7 @@ export default class I18nRegistry extends SynchronousRegistry { } } - private getTranslationUnit(address: TranslationAddress): null | TranslationUnit { + private getTranslationUnit(address: TranslationAddress): null | TranslationUnitDTO { return this._translations?.findOneByAddress(address) ?? null; } } diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnit.ts b/packages/neos-ui-i18n/src/registry/TranslationUnit.ts index a62a0d78a8..1f115ac04e 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnit.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationUnit.ts @@ -1 +1,12 @@ export type TranslationUnit = string | [string, string] | Record; +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export type TranslationUnitDTO = string | [string, string] | Record; + diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts index d6e27d38dd..77a6fa8fb8 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts @@ -8,19 +8,19 @@ * source code. */ import type {TranslationAddress} from './TranslationAddress'; -import type {TranslationUnit} from './TranslationUnit'; +import type {TranslationUnitDTO} from './TranslationUnit'; -export type TranslationsDTO = Record>>; +export type TranslationsDTO = Record>>; export class TranslationUnitRepository { - private _translationUnitsByAddress: Record = {}; + private _translationUnitsByAddress: Record = {}; private constructor(private readonly translations: TranslationsDTO) {} public static fromDTO = (translations: TranslationsDTO): TranslationUnitRepository => new TranslationUnitRepository(translations); - public findOneByAddress = (address: TranslationAddress): null | TranslationUnit => { + public findOneByAddress = (address: TranslationAddress): null | TranslationUnitDTO => { if (address.fullyQualified in this._translationUnitsByAddress) { return this._translationUnitsByAddress[address.fullyQualified]; } diff --git a/packages/neos-ui-i18n/src/registry/getPluralForm.ts b/packages/neos-ui-i18n/src/registry/getPluralForm.ts index e899e14bce..b179c1c1ae 100644 --- a/packages/neos-ui-i18n/src/registry/getPluralForm.ts +++ b/packages/neos-ui-i18n/src/registry/getPluralForm.ts @@ -7,9 +7,9 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import type {TranslationUnit} from './TranslationUnit'; +import type {TranslationUnitDTO} from './TranslationUnit'; -export const getPluralForm = (translation: TranslationUnit, quantity = 0): string => { +export const getPluralForm = (translation: TranslationUnitDTO, quantity = 0): string => { const translationHasPlurals = translation instanceof Object; // no defined quantity or less than one returns singular From 26eb159fbf79c2390740827d21342df360d21375 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 21:19:35 +0200 Subject: [PATCH 12/39] TASK: Introduce TranslationUnit value object --- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 3 +- .../neos-ui-i18n/src/registry/Parameters.ts | 10 ++ .../src/registry/TranslationUnit.spec.ts | 139 ++++++++++++++++++ .../src/registry/TranslationUnit.ts | 63 +++++++- 4 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 packages/neos-ui-i18n/src/registry/Parameters.ts create mode 100644 packages/neos-ui-i18n/src/registry/TranslationUnit.spec.ts diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 29025128bb..1b3a6ac118 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -17,11 +17,10 @@ import {getPluralForm} from './getPluralForm'; import type {TranslationUnitDTO} from './TranslationUnit'; import type {TranslationAddress} from './TranslationAddress'; import {TranslationUnitRepository, TranslationsDTO} from './TranslationUnitRepository'; +import type {Parameters} from './Parameters'; const errorCache: Record = {}; -type Parameters = (string | number)[] | Record; - export default class I18nRegistry extends SynchronousRegistry { private _translations: null|TranslationUnitRepository = null; diff --git a/packages/neos-ui-i18n/src/registry/Parameters.ts b/packages/neos-ui-i18n/src/registry/Parameters.ts new file mode 100644 index 0000000000..b5e0d27d82 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/Parameters.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export type Parameters = (string | number)[] | Record; diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnit.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationUnit.spec.ts new file mode 100644 index 0000000000..14e5a73972 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/TranslationUnit.spec.ts @@ -0,0 +1,139 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {TranslationUnit} from './TranslationUnit'; + +describe('TranslationUnit', () => { + it('can be created from a defective DTO', () => { + const translationUnit = TranslationUnit.fromDTO([ + 'This translation has only a singular form, despite its DTO being an array.' + ]); + + expect(translationUnit.render(undefined, 24)) + .toBe('This translation has only a singular form, despite its DTO being an array.'); + }); + + describe('having a singular form only', () => { + it('renders a translation string without placeholders and quantity = 0', () => { + const translationUnit = TranslationUnit.fromDTO( + 'This translation has only a singular form and no placeholders.' + ); + + expect(translationUnit.render(undefined, 0)) + .toBe('This translation has only a singular form and no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity = 1', () => { + const translationUnit = TranslationUnit.fromDTO( + 'This translation has only a singular form and no placeholders.' + ); + + expect(translationUnit.render(undefined, 1)) + .toBe('This translation has only a singular form and no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity > 1', () => { + const translationUnit = TranslationUnit.fromDTO( + 'This translation has only a singular form and no placeholders.' + ); + + expect(translationUnit.render(undefined, 42)) + .toBe('This translation has only a singular form and no placeholders.'); + }); + + it('renders a translation string with placeholders and quantity = 0', () => { + const translationUnit = TranslationUnit.fromDTO( + 'This translation has only a singular form and {some} placeholder.' + ); + + expect(translationUnit.render({some: 'one'}, 0)) + .toBe('This translation has only a singular form and one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity = 1', () => { + const translationUnit = TranslationUnit.fromDTO( + 'This translation has only a singular form and {some} placeholder.' + ); + + expect(translationUnit.render({some: 'one'}, 1)) + .toBe('This translation has only a singular form and one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity > 1', () => { + const translationUnit = TranslationUnit.fromDTO( + 'This translation has only a singular form and {some} placeholder.' + ); + + expect(translationUnit.render({some: 'one'}, 42)) + .toBe('This translation has only a singular form and one placeholder.'); + }); + }); + + describe('having a singular and a plural form', () => { + it('renders a translation string without placeholders and quantity = 0', () => { + const translationUnit = TranslationUnit.fromDTO([ + 'This translation has a singular form with no placeholders.', + 'This translation has a plural form with no placeholders.' + ]); + + expect(translationUnit.render(undefined, 0)) + .toBe('This translation has a singular form with no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity = 1', () => { + const translationUnit = TranslationUnit.fromDTO([ + 'This translation has a singular form with no placeholders.', + 'This translation has a plural form with no placeholders.' + ]); + + expect(translationUnit.render(undefined, 1)) + .toBe('This translation has a singular form with no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity > 1', () => { + const translationUnit = TranslationUnit.fromDTO([ + 'This translation has a singular form with no placeholders.', + 'This translation has a plural form with no placeholders.' + ]); + + expect(translationUnit.render(undefined, 42)) + .toBe('This translation has a plural form with no placeholders.'); + }); + + it('renders a translation string with placeholders and quantity = 0', () => { + const translationUnit = TranslationUnit.fromDTO([ + 'This translation has a singular form with {some} placeholder.', + 'This translation has a plural form with {some} placeholder.' + ]); + + expect(translationUnit.render({some: 'one'}, 0)) + .toBe('This translation has a singular form with one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity = 1', () => { + const translationUnit = TranslationUnit.fromDTO([ + 'This translation has a singular form with {some} placeholder.', + 'This translation has a plural form with {some} placeholder.' + ]); + + expect(translationUnit.render({some: 'one'}, 1)) + .toBe('This translation has a singular form with one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity > 1', () => { + const translationUnit = TranslationUnit.fromDTO([ + 'This translation has a singular form with {some} placeholder.', + 'This translation has a plural form with {some} placeholder.' + ]); + + expect(translationUnit.render({some: 'one'}, 42)) + .toBe('This translation has a plural form with one placeholder.'); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnit.ts b/packages/neos-ui-i18n/src/registry/TranslationUnit.ts index 1f115ac04e..eba8d45bb3 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnit.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationUnit.ts @@ -1,4 +1,3 @@ -export type TranslationUnit = string | [string, string] | Record; /* * This file is part of the Neos.Neos.Ui package. * @@ -8,5 +7,65 @@ export type TranslationUnit = string | [string, string] | Record * information, please view the LICENSE file which was distributed with this * source code. */ -export type TranslationUnitDTO = string | [string, string] | Record; +import type {Parameters} from './Parameters'; +import {substitutePlaceholders} from './substitutePlaceholders'; +export type TranslationUnitDTO = string | TranslationUnitDTOTuple; +type TranslationUnitDTOTuple = [string, string] | Record; + +export class TranslationUnit { + private constructor( + private readonly implementation: + | TranslationUnitWithSingularFormOnly + | TranslationUnitWithSingularAndPluralForm + ) { + } + + public static fromDTO = (dto: TranslationUnitDTO): TranslationUnit => + dto instanceof Object + ? TranslationUnit.fromTuple(dto) + : TranslationUnit.fromString(dto); + + private static fromTuple = (tuple: TranslationUnitDTOTuple): TranslationUnit => + new TranslationUnit( + tuple[1] === undefined + ? new TranslationUnitWithSingularFormOnly(tuple[0]) + : new TranslationUnitWithSingularAndPluralForm(tuple[0], tuple[1]) + ); + + private static fromString = (string: string): TranslationUnit => + new TranslationUnit( + new TranslationUnitWithSingularFormOnly(string) + ); + + public render(parameters: undefined | Parameters, quantity: number): string { + return this.implementation.render(parameters, quantity); + } +} + +class TranslationUnitWithSingularFormOnly { + public constructor(private readonly value: string) {} + + public render(parameters: undefined | Parameters): string { + return parameters + ? substitutePlaceholders(this.value, parameters) + : this.value; + } +} + +class TranslationUnitWithSingularAndPluralForm { + public constructor( + private readonly singular: string, + private readonly plural: string + ) {} + + public render(parameters: undefined | Parameters, quantity: number): string { + return parameters + ? substitutePlaceholders(this.byQuantity(quantity), parameters) + : this.byQuantity(quantity); + } + + private byQuantity(quantity: number): string { + return quantity <= 1 ? this.singular : this.plural; + } +} From 4a56a076e4e7820a892b9c41a6da54cb730d3937 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 21:44:42 +0200 Subject: [PATCH 13/39] TASK: Integrate TranslationUnit with I18nRegistry --- packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 10 +++------- .../src/registry/TranslationUnitRepository.spec.ts | 3 ++- .../src/registry/TranslationUnitRepository.ts | 11 +++++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 1b3a6ac118..538e6d2410 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -12,9 +12,7 @@ import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/regis import logger from '@neos-project/utils-logger'; import {getTranslationAddress} from './getTranslationAddress'; -import {substitutePlaceholders} from './substitutePlaceholders'; -import {getPluralForm} from './getPluralForm'; -import type {TranslationUnitDTO} from './TranslationUnit'; +import type {TranslationUnit} from './TranslationUnit'; import type {TranslationAddress} from './TranslationAddress'; import {TranslationUnitRepository, TranslationsDTO} from './TranslationUnitRepository'; import type {Parameters} from './Parameters'; @@ -191,9 +189,7 @@ export default class I18nRegistry extends SynchronousRegistry { return fallback; } - return parameters - ? substitutePlaceholders(getPluralForm(translationUnit, quantity), parameters) - : getPluralForm(translationUnit, quantity); + return translationUnit.render(parameters, quantity); } private logTranslationUnitNotFound(address: TranslationAddress, fallback: string) { @@ -203,7 +199,7 @@ export default class I18nRegistry extends SynchronousRegistry { } } - private getTranslationUnit(address: TranslationAddress): null | TranslationUnitDTO { + private getTranslationUnit(address: TranslationAddress): null | TranslationUnit { return this._translations?.findOneByAddress(address) ?? null; } } diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts index 7de2ffa2ca..ce49be3e4a 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts @@ -8,6 +8,7 @@ * source code. */ import {TranslationAddress} from './TranslationAddress'; +import {TranslationUnit} from './TranslationUnit'; import {TranslationUnitRepository} from './TranslationUnitRepository'; describe('TranslationUnitRepository', () => { @@ -25,6 +26,6 @@ describe('TranslationUnitRepository', () => { expect(translationUnitRepository.findOneByAddress(translationAddressThatCannotBeFound)) .toBeNull(); expect(translationUnitRepository.findOneByAddress(translationAddressThatCanBeFound)) - .toEqual('The Translation'); + .toStrictEqual(TranslationUnit.fromDTO('The Translation')); }); }); diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts index 77a6fa8fb8..1ef213b145 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts @@ -8,19 +8,19 @@ * source code. */ import type {TranslationAddress} from './TranslationAddress'; -import type {TranslationUnitDTO} from './TranslationUnit'; +import {TranslationUnit, TranslationUnitDTO} from './TranslationUnit'; export type TranslationsDTO = Record>>; export class TranslationUnitRepository { - private _translationUnitsByAddress: Record = {}; + private _translationUnitsByAddress: Record = {}; private constructor(private readonly translations: TranslationsDTO) {} public static fromDTO = (translations: TranslationsDTO): TranslationUnitRepository => new TranslationUnitRepository(translations); - public findOneByAddress = (address: TranslationAddress): null | TranslationUnitDTO => { + public findOneByAddress = (address: TranslationAddress): null | TranslationUnit => { if (address.fullyQualified in this._translationUnitsByAddress) { return this._translationUnitsByAddress[address.fullyQualified]; } @@ -29,7 +29,10 @@ export class TranslationUnitRepository { // Replace all dots with underscores .map(s => s ? s.replace(/\./g, '_') : '') - const translationUnit = this.translations[packageKey]?.[sourceName]?.[id] ?? null; + const translationUnitDTO = this.translations[packageKey]?.[sourceName]?.[id] ?? null; + const translationUnit = translationUnitDTO + ? TranslationUnit.fromDTO(translationUnitDTO) + : null; this._translationUnitsByAddress[address.fullyQualified] = translationUnit; return translationUnit; From 52a1acb8ca60cd66a9d86a464f3a3931747745ec Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 21:47:13 +0200 Subject: [PATCH 14/39] TASK: Remove obsolete function `getPluralForm` --- .../src/registry/getPluralForm.spec.ts | 44 ------------------- .../src/registry/getPluralForm.ts | 25 ----------- 2 files changed, 69 deletions(-) delete mode 100644 packages/neos-ui-i18n/src/registry/getPluralForm.spec.ts delete mode 100644 packages/neos-ui-i18n/src/registry/getPluralForm.ts diff --git a/packages/neos-ui-i18n/src/registry/getPluralForm.spec.ts b/packages/neos-ui-i18n/src/registry/getPluralForm.spec.ts deleted file mode 100644 index 92b2aeab96..0000000000 --- a/packages/neos-ui-i18n/src/registry/getPluralForm.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * This file is part of the Neos.Neos.Ui package. - * - * (c) Contributors of the Neos Project - www.neos.io - * - * This package is Open Source Software. For the full copyright and license - * information, please view the LICENSE file which was distributed with this - * source code. - */ -import {getPluralForm} from './getPluralForm'; - -describe('getPluralForm', () => { - it('returns translation if translation has no plural form', () => { - expect(getPluralForm('has no plural form')) - .toBe('has no plural form'); - expect(getPluralForm('has no plural form', 0)) - .toBe('has no plural form'); - expect(getPluralForm('has no plural form', 1)) - .toBe('has no plural form'); - expect(getPluralForm('has no plural form', 2)) - .toBe('has no plural form'); - expect(getPluralForm('has no plural form', 42)) - .toBe('has no plural form'); - }); - - it('returns singular if translation has plural form and quantity is one', () => { - expect(getPluralForm(['has singular form', 'has plural form'], 1)) - .toBe('has singular form'); - }); - - it('returns singular if translation has plural form and quantity is zero', () => { - expect(getPluralForm(['has singular form', 'has plural form'], 0)) - .toBe('has singular form'); - expect(getPluralForm(['has singular form and default quantity is 0', 'has plural form and default quantity is 0'])) - .toBe('has singular form and default quantity is 0'); - }); - - it('returns plural if translation has plural form and quantity greater than one', () => { - expect(getPluralForm(['has singular form', 'has plural form'], 2)) - .toBe('has plural form'); - expect(getPluralForm(['has singular form', 'has plural form'], 42)) - .toBe('has plural form'); - }); -}); diff --git a/packages/neos-ui-i18n/src/registry/getPluralForm.ts b/packages/neos-ui-i18n/src/registry/getPluralForm.ts deleted file mode 100644 index b179c1c1ae..0000000000 --- a/packages/neos-ui-i18n/src/registry/getPluralForm.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * This file is part of the Neos.Neos.Ui package. - * - * (c) Contributors of the Neos Project - www.neos.io - * - * This package is Open Source Software. For the full copyright and license - * information, please view the LICENSE file which was distributed with this - * source code. - */ -import type {TranslationUnitDTO} from './TranslationUnit'; - -export const getPluralForm = (translation: TranslationUnitDTO, quantity = 0): string => { - const translationHasPlurals = translation instanceof Object; - - // no defined quantity or less than one returns singular - if (translationHasPlurals && (!quantity || quantity <= 1)) { - return translation[0]; - } - - if (translationHasPlurals && quantity > 1) { - return translation[1] ? translation[1] : translation[0]; - } - - return translation as string; -}; From 67ff54394b1435e3ec29fe0740db74c4c8c2343f Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 22:07:15 +0200 Subject: [PATCH 15/39] TASK: Convert neos-ui-i18n/src/registry/I18nRegistry.spec.js to TypeScript --- .../neos-ui-i18n/src/registry/I18nRegistry.spec.ts | 2 +- packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 2 +- packages/neos-ui-i18n/src/registry/index.js | 5 ----- packages/neos-ui-i18n/src/registry/index.ts | 10 ++++++++++ 4 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 packages/neos-ui-i18n/src/registry/index.js create mode 100644 packages/neos-ui-i18n/src/registry/index.ts diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts index 32cf1c7ecc..6b1761e626 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts @@ -7,7 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import I18nRegistry from './I18nRegistry'; +import {I18nRegistry} from './I18nRegistry'; test(` Host > Containers > I18n: should display configured fallback, if no translation diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 538e6d2410..902a4b6084 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -19,7 +19,7 @@ import type {Parameters} from './Parameters'; const errorCache: Record = {}; -export default class I18nRegistry extends SynchronousRegistry { +export class I18nRegistry extends SynchronousRegistry { private _translations: null|TranslationUnitRepository = null; /** @internal */ diff --git a/packages/neos-ui-i18n/src/registry/index.js b/packages/neos-ui-i18n/src/registry/index.js deleted file mode 100644 index c0eb2ae997..0000000000 --- a/packages/neos-ui-i18n/src/registry/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import I18nRegistry from './I18nRegistry'; - -export { - I18nRegistry -}; diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts new file mode 100644 index 0000000000..358938131e --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {I18nRegistry} from './I18nRegistry'; From 4d343759ef4f1018f5fe911964a38a079d8ac695 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 22:23:42 +0200 Subject: [PATCH 16/39] TASK: Turn `I18nRegistry` into a singleton --- packages/neos-ui-i18n/src/manifest.js | 11 ++--------- packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 2 ++ packages/neos-ui-i18n/src/registry/index.ts | 3 ++- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/neos-ui-i18n/src/manifest.js b/packages/neos-ui-i18n/src/manifest.js index c7e0d91b44..920ed97519 100644 --- a/packages/neos-ui-i18n/src/manifest.js +++ b/packages/neos-ui-i18n/src/manifest.js @@ -1,14 +1,7 @@ import manifest from '@neos-project/neos-ui-extensibility'; -import {I18nRegistry} from './registry/index'; +import {i18nRegistry} from './registry'; manifest('@neos-project/neos-ui-i18n', {}, globalRegistry => { - globalRegistry.set( - 'i18n', - new I18nRegistry(` - # Registry for Internationalization / Localization - - Has one public method "translate()" which can be used to translate strings. - `) - ); + globalRegistry.set('i18n', i18nRegistry); }); diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 902a4b6084..b87f3ec019 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -203,3 +203,5 @@ export class I18nRegistry extends SynchronousRegistry { return this._translations?.findOneByAddress(address) ?? null; } } + +export const i18nRegistry = new I18nRegistry('The i18n registry'); diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts index 358938131e..5d12e26bc9 100644 --- a/packages/neos-ui-i18n/src/registry/index.ts +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -7,4 +7,5 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -export {I18nRegistry} from './I18nRegistry'; +export type {I18nRegistry} from './I18nRegistry'; +export {i18nRegistry} from './I18nRegistry'; From bba306ab9366d8732c4fbec600175ab51c17539b Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 23:06:23 +0200 Subject: [PATCH 17/39] TASK: Make component independent from global registry --- packages/neos-ui-i18n/package.json | 2 - packages/neos-ui-i18n/src/index.spec.js | 17 ++++--- packages/neos-ui-i18n/src/index.tsx | 19 +++----- .../neos-ui-i18n/src/registry/Parameters.ts | 2 +- packages/neos-ui-i18n/src/registry/index.ts | 2 + .../registry/substitutePlaceholders.spec.ts | 44 +++++++++++++++++++ .../src/registry/substitutePlaceholders.ts | 8 +++- yarn.lock | 2 - 8 files changed, 70 insertions(+), 26 deletions(-) diff --git a/packages/neos-ui-i18n/package.json b/packages/neos-ui-i18n/package.json index 76232a5cb2..6e924f7a70 100644 --- a/packages/neos-ui-i18n/package.json +++ b/packages/neos-ui-i18n/package.json @@ -10,8 +10,6 @@ "typescript": "^4.6.4" }, "dependencies": { - "@neos-project/neos-ts-interfaces": "workspace:*", - "@neos-project/neos-ui-decorators": "workspace:*", "@neos-project/neos-ui-extensibility": "workspace:*", "@neos-project/utils-logger": "workspace:*" }, diff --git a/packages/neos-ui-i18n/src/index.spec.js b/packages/neos-ui-i18n/src/index.spec.js index a749a39785..8d28365463 100644 --- a/packages/neos-ui-i18n/src/index.spec.js +++ b/packages/neos-ui-i18n/src/index.spec.js @@ -2,21 +2,26 @@ import React from 'react'; import {mount} from 'enzyme'; import I18n from './index'; +import {i18nRegistry} from './registry'; -const FakeRegistry = { - translate(key) { +beforeEach(() => { + jest.spyOn(i18nRegistry, 'translate'); + jest.mocked(i18nRegistry.translate).mockImplementation((key) => { return key; - } -}; + }); +}); +afterEach(() => { + jest.restoreAllMocks(); +}); test(` should render a node.`, () => { - const original = mount(); + const original = mount(); expect(original.html()).toBe(''); }); test(` should call translation service with key.`, () => { - const original = mount(); + const original = mount(); expect(original.html()).toBe('My key'); }); diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx index e0ca2540af..ac89d34e72 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/index.tsx @@ -1,12 +1,5 @@ import React from 'react'; -import {neos} from '@neos-project/neos-ui-decorators'; -import {GlobalRegistry} from '@neos-project/neos-ts-interfaces'; -import {NeosInjectedProps} from '@neos-project/neos-ui-decorators/src/neos'; - -const regsToProps = (globalRegistry: GlobalRegistry) => ({ - i18nRegistry: globalRegistry.get('i18n') -}); -type InjectedProps = NeosInjectedProps; +import {Parameters, i18nRegistry} from './registry'; interface I18nProps { // Fallback key which gets rendered once the i18n service doesn't return a translation. @@ -20,20 +13,18 @@ interface I18nProps { sourceName?: string; // Additional parameters which are passed to the i18n service. - params?: object; + params?: Parameters; // Optional className which gets added to the translation span. className?: string; } -class I18n extends React.PureComponent { +export default class I18n extends React.PureComponent { public render(): JSX.Element { - const {i18nRegistry, packageKey, sourceName, params, id, fallback} = this.props; + const {packageKey, sourceName, params, id, fallback} = this.props; return ( - {i18nRegistry.translate(id, fallback, params, packageKey, sourceName)} + {i18nRegistry.translate(id ?? '', fallback, params, packageKey ?? 'Neos.Neos', sourceName ?? 'Main')} ); } } - -export default neos(regsToProps)(I18n); diff --git a/packages/neos-ui-i18n/src/registry/Parameters.ts b/packages/neos-ui-i18n/src/registry/Parameters.ts index b5e0d27d82..7c4ef56823 100644 --- a/packages/neos-ui-i18n/src/registry/Parameters.ts +++ b/packages/neos-ui-i18n/src/registry/Parameters.ts @@ -7,4 +7,4 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -export type Parameters = (string | number)[] | Record; +export type Parameters = unknown[] | Record; diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts index 5d12e26bc9..f67897ea10 100644 --- a/packages/neos-ui-i18n/src/registry/index.ts +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -9,3 +9,5 @@ */ export type {I18nRegistry} from './I18nRegistry'; export {i18nRegistry} from './I18nRegistry'; + +export type {Parameters} from './Parameters'; diff --git a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts index 5b7216dd1d..26fa7bef50 100644 --- a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts @@ -65,6 +65,26 @@ describe('substitutePlaceholders', () => { ); }); + it('complains when arguments of a strange type have been provided', () => { + const logError = jest.spyOn(logger, 'error'); + + substitutePlaceholders('One argument: {0}', [() => {}]); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('is not of type string or number') + ); + + substitutePlaceholders('One argument: {0}', [Boolean]); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('is not of type string or number') + ); + + logError.mockClear(); + }); + it('substitutes multiple occurrences of the same placeholder', () => { expect(substitutePlaceholders('{0} {0} {0} {1} {1} {1}', ['foo', 'bar'])) .toBe('foo foo foo bar bar bar'); @@ -126,6 +146,30 @@ describe('substitutePlaceholders', () => { ); }); + it('complains when arguments of a strange type have been provided', () => { + const logError = jest.spyOn(logger, 'error'); + + substitutePlaceholders('One argument: {a}', { + a: () => {} + }); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('is not of type string or number') + ); + + substitutePlaceholders('One argument: {a}', { + a: Boolean + }); + expect(logError).toHaveBeenCalledTimes(2); + expect(logError).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('is not of type string or number') + ); + + logError.mockClear(); + }); + it('substitutes multiple occurrences of the same placeholder', () => { expect( substitutePlaceholders( diff --git a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts index 2322d8b9f4..1403e5060a 100644 --- a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts @@ -9,11 +9,13 @@ */ import logger from '@neos-project/utils-logger'; +import {Parameters} from './Parameters'; + /** * This code is taken from the Ember version with minor adjustments. Possibly refactor it later * as its style is not superb. */ -export const substitutePlaceholders = function (textWithPlaceholders: string, parameters: (string|number)[] | Record) { +export const substitutePlaceholders = function (textWithPlaceholders: string, parameters: Parameters) { const result = []; let startOfPlaceholder; let offset = 0; @@ -38,6 +40,10 @@ export const substitutePlaceholders = function (textWithPlaceholders: string, pa logger.error('Placeholder "' + valueIndex + '" was not provided, make sure you provide values for every placeholder.'); break; } + if (typeof value !== 'string' && typeof value !== 'number') { + logger.error('Placeholder "' + valueIndex + '" is not of type string or number.'); + break; + } let formattedPlaceholder; if (typeof placeholderElements[1] === 'undefined') { diff --git a/yarn.lock b/yarn.lock index b540920254..73327a418c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3654,8 +3654,6 @@ __metadata: resolution: "@neos-project/neos-ui-i18n@workspace:packages/neos-ui-i18n" dependencies: "@neos-project/jest-preset-neos-ui": "workspace:*" - "@neos-project/neos-ts-interfaces": "workspace:*" - "@neos-project/neos-ui-decorators": "workspace:*" "@neos-project/neos-ui-extensibility": "workspace:*" "@neos-project/utils-logger": "workspace:*" enzyme: ^3.8.0 From c2ed26fb8ff3402fa18be1701793afe9efb4e7a0 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 23:26:18 +0200 Subject: [PATCH 18/39] TASK: Expose I18nRegistry through i18n package rather than neos-ts-interfaces --- packages/neos-ts-interfaces/package.json | 3 +++ packages/neos-ts-interfaces/src/index.ts | 5 ++--- .../src/container/ErrorBoundary/ErrorBoundary.tsx | 2 +- packages/neos-ui-i18n/src/index.tsx | 2 ++ .../Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx | 3 +-- .../ResolutionStrategyConfirmationDialog.tsx | 4 ++-- .../ResolutionStrategySelectionDialog.tsx | 4 ++-- .../Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx | 3 ++- .../PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx | 3 ++- yarn.lock | 1 + 10 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/neos-ts-interfaces/package.json b/packages/neos-ts-interfaces/package.json index d6e993fee1..4d24fcb673 100644 --- a/packages/neos-ts-interfaces/package.json +++ b/packages/neos-ts-interfaces/package.json @@ -4,6 +4,9 @@ "description": "Neos domain-related TypeScript interfaces", "private": true, "main": "src/index.ts", + "dependencies": { + "@neos-project/neos-ui-i18n": "workspace:*" + }, "devDependencies": { "@neos-project/jest-preset-neos-ui": "workspace:*", "typescript": "^4.6.4" diff --git a/packages/neos-ts-interfaces/src/index.ts b/packages/neos-ts-interfaces/src/index.ts index 7689692b18..aaaeac447d 100644 --- a/packages/neos-ts-interfaces/src/index.ts +++ b/packages/neos-ts-interfaces/src/index.ts @@ -1,3 +1,5 @@ +import type {I18nRegistry} from '@neos-project/neos-ui-i18n'; + export type NodeContextPath = string; export type FusionPath = string; export type NodeTypeName = string; @@ -268,9 +270,6 @@ export interface ValidatorRegistry { get: (validatorName: string) => Validator | null; set: (validatorName: string, validator: Validator) => void; } -export interface I18nRegistry { - translate: (id?: string, fallback?: string, params?: {}, packageKey?: string, sourceName?: string) => string; -} export interface GlobalRegistry { get: (key: K) => K extends 'i18n' ? I18nRegistry : K extends 'validators' ? ValidatorRegistry : null; diff --git a/packages/neos-ui-error/src/container/ErrorBoundary/ErrorBoundary.tsx b/packages/neos-ui-error/src/container/ErrorBoundary/ErrorBoundary.tsx index c659169820..aaf20ca94b 100644 --- a/packages/neos-ui-error/src/container/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/neos-ui-error/src/container/ErrorBoundary/ErrorBoundary.tsx @@ -13,7 +13,7 @@ import React from 'react'; import Logo from '@neos-project/react-ui-components/src/Logo'; import Button from '@neos-project/react-ui-components/src/Button'; import Icon from '@neos-project/react-ui-components/src/Icon'; -import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; +import type {I18nRegistry} from '@neos-project/neos-ui-i18n'; import styles from './style.module.css'; diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx index ac89d34e72..e6a6c0036f 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/index.tsx @@ -1,6 +1,8 @@ import React from 'react'; import {Parameters, i18nRegistry} from './registry'; +export type {I18nRegistry} from './registry'; + interface I18nProps { // Fallback key which gets rendered once the i18n service doesn't return a translation. fallback?: string; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx index 97a9189aa5..e473d7c685 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx @@ -9,8 +9,7 @@ */ import React from 'react'; -import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; -import I18n from '@neos-project/neos-ui-i18n'; +import I18n, {I18nRegistry} from '@neos-project/neos-ui-i18n'; import {Icon} from '@neos-project/react-ui-components'; import {Conflict, ReasonForConflict} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import {TypeOfChange} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx index c0e67a104a..9aa24adfd7 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx @@ -9,8 +9,8 @@ */ import React from 'react'; -import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; -import I18n from '@neos-project/neos-ui-i18n'; +import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import I18n, {I18nRegistry} from '@neos-project/neos-ui-i18n'; import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; import {PublishingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Publishing'; import {Conflict, ResolutionStrategy} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx index c4feb6f686..6c46c83095 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx @@ -9,8 +9,8 @@ */ import React from 'react'; -import I18n from '@neos-project/neos-ui-i18n'; -import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import I18n, {I18nRegistry} from '@neos-project/neos-ui-i18n'; +import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; import {Button, Dialog, Icon, SelectBox, SelectBox_Option_MultiLineWithThumbnail} from '@neos-project/react-ui-components'; import {Conflict, ResolutionStrategy, SyncingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index 2da8d0a0fd..bdb242ee24 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -14,7 +14,8 @@ import {connect} from 'react-redux'; import {neos} from '@neos-project/neos-ui-decorators'; import {selectors, actions} from '@neos-project/neos-ui-redux-store'; import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; -import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import type {WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import type {I18nRegistry} from '@neos-project/neos-ui-i18n'; import {ResolutionStrategy, SyncingPhase, State as SyncingState} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import {ConfirmationDialog} from './ConfirmationDialog'; diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx index 51a2d2b434..a618709358 100644 --- a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx @@ -14,7 +14,8 @@ import {connect} from 'react-redux'; import {actions, selectors} from '@neos-project/neos-ui-redux-store'; import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; import {neos} from '@neos-project/neos-ui-decorators'; -import {I18nRegistry, WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; +import {WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; +import type {I18nRegistry} from '@neos-project/neos-ui-i18n'; import {Button} from '@neos-project/react-ui-components'; import {WorkspaceSyncIcon} from './WorkspaceSyncIcon'; diff --git a/yarn.lock b/yarn.lock index 73327a418c..c7aebfae23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3448,6 +3448,7 @@ __metadata: resolution: "@neos-project/neos-ts-interfaces@workspace:packages/neos-ts-interfaces" dependencies: "@neos-project/jest-preset-neos-ui": "workspace:*" + "@neos-project/neos-ui-i18n": "workspace:*" typescript: ^4.6.4 languageName: unknown linkType: soft From fb372f3d5ba764a823ba2bbefc9db8fb45c19929 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 17 Jun 2024 23:29:51 +0200 Subject: [PATCH 19/39] TASK: Convert neos-ui-i18n/index.spec.js to TypeScript --- .../src/{index.spec.js => index.spec.tsx} | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) rename packages/neos-ui-i18n/src/{index.spec.js => index.spec.tsx} (61%) diff --git a/packages/neos-ui-i18n/src/index.spec.js b/packages/neos-ui-i18n/src/index.spec.tsx similarity index 61% rename from packages/neos-ui-i18n/src/index.spec.js rename to packages/neos-ui-i18n/src/index.spec.tsx index 8d28365463..dc50becbaa 100644 --- a/packages/neos-ui-i18n/src/index.spec.js +++ b/packages/neos-ui-i18n/src/index.spec.tsx @@ -1,3 +1,12 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ import React from 'react'; import {mount} from 'enzyme'; @@ -6,7 +15,7 @@ import {i18nRegistry} from './registry'; beforeEach(() => { jest.spyOn(i18nRegistry, 'translate'); - jest.mocked(i18nRegistry.translate).mockImplementation((key) => { + (jest as any).mocked(i18nRegistry.translate).mockImplementation((key: string) => { return key; }); }); From 29c7cfc943679ef82d88a59e9adf333f23bc5831 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Tue, 18 Jun 2024 15:50:42 +0200 Subject: [PATCH 20/39] TASK: Rename `TranslationUnit` -> `Translation` --- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 20 +++---- ...lationUnit.spec.ts => Translation.spec.ts} | 56 +++++++++---------- .../{TranslationUnit.ts => Translation.ts} | 34 +++++------ ....spec.ts => TranslationRepository.spec.ts} | 16 +++--- ...Repository.ts => TranslationRepository.ts} | 28 +++++----- 5 files changed, 77 insertions(+), 77 deletions(-) rename packages/neos-ui-i18n/src/registry/{TranslationUnit.spec.ts => Translation.spec.ts} (73%) rename packages/neos-ui-i18n/src/registry/{TranslationUnit.ts => Translation.ts} (60%) rename packages/neos-ui-i18n/src/registry/{TranslationUnitRepository.spec.ts => TranslationRepository.spec.ts} (59%) rename packages/neos-ui-i18n/src/registry/{TranslationUnitRepository.ts => TranslationRepository.ts} (51%) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index b87f3ec019..3819a7a495 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -12,19 +12,19 @@ import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/regis import logger from '@neos-project/utils-logger'; import {getTranslationAddress} from './getTranslationAddress'; -import type {TranslationUnit} from './TranslationUnit'; +import type {Translation} from './Translation'; import type {TranslationAddress} from './TranslationAddress'; -import {TranslationUnitRepository, TranslationsDTO} from './TranslationUnitRepository'; +import {TranslationRepository, TranslationsDTO} from './TranslationRepository'; import type {Parameters} from './Parameters'; const errorCache: Record = {}; export class I18nRegistry extends SynchronousRegistry { - private _translations: null|TranslationUnitRepository = null; + private _translations: null|TranslationRepository = null; /** @internal */ setTranslations(translations: TranslationsDTO) { - this._translations = TranslationUnitRepository.fromDTO(translations); + this._translations = TranslationRepository.fromDTO(translations); } /** @@ -183,23 +183,23 @@ export class I18nRegistry extends SynchronousRegistry { ) { const fallback = explicitlyProvidedFallback || transUnitIdOrFullyQualifiedTranslationAddress; const translationAddess = getTranslationAddress(transUnitIdOrFullyQualifiedTranslationAddress, explicitlyProvidedPackageKey, explicitlyProvidedSourceName); - const translationUnit = this.getTranslationUnit(translationAddess); - if (translationUnit === null) { - this.logTranslationUnitNotFound(translationAddess, fallback); + const translation = this.getTranslation(translationAddess); + if (translation === null) { + this.logTranslationNotFound(translationAddess, fallback); return fallback; } - return translationUnit.render(parameters, quantity); + return translation.render(parameters, quantity); } - private logTranslationUnitNotFound(address: TranslationAddress, fallback: string) { + private logTranslationNotFound(address: TranslationAddress, fallback: string) { if (!errorCache[address.fullyQualified]) { logger.error(`No translation found for id "${address.fullyQualified}" in:`, this._translations, `Using ${fallback} instead.`); errorCache[address.fullyQualified] = true; } } - private getTranslationUnit(address: TranslationAddress): null | TranslationUnit { + private getTranslation(address: TranslationAddress): null | Translation { return this._translations?.findOneByAddress(address) ?? null; } } diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnit.spec.ts b/packages/neos-ui-i18n/src/registry/Translation.spec.ts similarity index 73% rename from packages/neos-ui-i18n/src/registry/TranslationUnit.spec.ts rename to packages/neos-ui-i18n/src/registry/Translation.spec.ts index 14e5a73972..3a46a68e04 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnit.spec.ts +++ b/packages/neos-ui-i18n/src/registry/Translation.spec.ts @@ -7,132 +7,132 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {TranslationUnit} from './TranslationUnit'; +import {Translation} from './Translation'; -describe('TranslationUnit', () => { +describe('Translation', () => { it('can be created from a defective DTO', () => { - const translationUnit = TranslationUnit.fromDTO([ + const translation = Translation.fromDTO([ 'This translation has only a singular form, despite its DTO being an array.' ]); - expect(translationUnit.render(undefined, 24)) + expect(translation.render(undefined, 24)) .toBe('This translation has only a singular form, despite its DTO being an array.'); }); describe('having a singular form only', () => { it('renders a translation string without placeholders and quantity = 0', () => { - const translationUnit = TranslationUnit.fromDTO( + const translation = Translation.fromDTO( 'This translation has only a singular form and no placeholders.' ); - expect(translationUnit.render(undefined, 0)) + expect(translation.render(undefined, 0)) .toBe('This translation has only a singular form and no placeholders.'); }); it('renders a translation string without placeholders and with quantity = 1', () => { - const translationUnit = TranslationUnit.fromDTO( + const translation = Translation.fromDTO( 'This translation has only a singular form and no placeholders.' ); - expect(translationUnit.render(undefined, 1)) + expect(translation.render(undefined, 1)) .toBe('This translation has only a singular form and no placeholders.'); }); it('renders a translation string without placeholders and with quantity > 1', () => { - const translationUnit = TranslationUnit.fromDTO( + const translation = Translation.fromDTO( 'This translation has only a singular form and no placeholders.' ); - expect(translationUnit.render(undefined, 42)) + expect(translation.render(undefined, 42)) .toBe('This translation has only a singular form and no placeholders.'); }); it('renders a translation string with placeholders and quantity = 0', () => { - const translationUnit = TranslationUnit.fromDTO( + const translation = Translation.fromDTO( 'This translation has only a singular form and {some} placeholder.' ); - expect(translationUnit.render({some: 'one'}, 0)) + expect(translation.render({some: 'one'}, 0)) .toBe('This translation has only a singular form and one placeholder.'); }); it('renders a translation string with placeholders and with quantity = 1', () => { - const translationUnit = TranslationUnit.fromDTO( + const translation = Translation.fromDTO( 'This translation has only a singular form and {some} placeholder.' ); - expect(translationUnit.render({some: 'one'}, 1)) + expect(translation.render({some: 'one'}, 1)) .toBe('This translation has only a singular form and one placeholder.'); }); it('renders a translation string with placeholders and with quantity > 1', () => { - const translationUnit = TranslationUnit.fromDTO( + const translation = Translation.fromDTO( 'This translation has only a singular form and {some} placeholder.' ); - expect(translationUnit.render({some: 'one'}, 42)) + expect(translation.render({some: 'one'}, 42)) .toBe('This translation has only a singular form and one placeholder.'); }); }); describe('having a singular and a plural form', () => { it('renders a translation string without placeholders and quantity = 0', () => { - const translationUnit = TranslationUnit.fromDTO([ + const translation = Translation.fromDTO([ 'This translation has a singular form with no placeholders.', 'This translation has a plural form with no placeholders.' ]); - expect(translationUnit.render(undefined, 0)) + expect(translation.render(undefined, 0)) .toBe('This translation has a singular form with no placeholders.'); }); it('renders a translation string without placeholders and with quantity = 1', () => { - const translationUnit = TranslationUnit.fromDTO([ + const translation = Translation.fromDTO([ 'This translation has a singular form with no placeholders.', 'This translation has a plural form with no placeholders.' ]); - expect(translationUnit.render(undefined, 1)) + expect(translation.render(undefined, 1)) .toBe('This translation has a singular form with no placeholders.'); }); it('renders a translation string without placeholders and with quantity > 1', () => { - const translationUnit = TranslationUnit.fromDTO([ + const translation = Translation.fromDTO([ 'This translation has a singular form with no placeholders.', 'This translation has a plural form with no placeholders.' ]); - expect(translationUnit.render(undefined, 42)) + expect(translation.render(undefined, 42)) .toBe('This translation has a plural form with no placeholders.'); }); it('renders a translation string with placeholders and quantity = 0', () => { - const translationUnit = TranslationUnit.fromDTO([ + const translation = Translation.fromDTO([ 'This translation has a singular form with {some} placeholder.', 'This translation has a plural form with {some} placeholder.' ]); - expect(translationUnit.render({some: 'one'}, 0)) + expect(translation.render({some: 'one'}, 0)) .toBe('This translation has a singular form with one placeholder.'); }); it('renders a translation string with placeholders and with quantity = 1', () => { - const translationUnit = TranslationUnit.fromDTO([ + const translation = Translation.fromDTO([ 'This translation has a singular form with {some} placeholder.', 'This translation has a plural form with {some} placeholder.' ]); - expect(translationUnit.render({some: 'one'}, 1)) + expect(translation.render({some: 'one'}, 1)) .toBe('This translation has a singular form with one placeholder.'); }); it('renders a translation string with placeholders and with quantity > 1', () => { - const translationUnit = TranslationUnit.fromDTO([ + const translation = Translation.fromDTO([ 'This translation has a singular form with {some} placeholder.', 'This translation has a plural form with {some} placeholder.' ]); - expect(translationUnit.render({some: 'one'}, 42)) + expect(translation.render({some: 'one'}, 42)) .toBe('This translation has a plural form with one placeholder.'); }); }); diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnit.ts b/packages/neos-ui-i18n/src/registry/Translation.ts similarity index 60% rename from packages/neos-ui-i18n/src/registry/TranslationUnit.ts rename to packages/neos-ui-i18n/src/registry/Translation.ts index eba8d45bb3..50fd8bb688 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnit.ts +++ b/packages/neos-ui-i18n/src/registry/Translation.ts @@ -10,32 +10,32 @@ import type {Parameters} from './Parameters'; import {substitutePlaceholders} from './substitutePlaceholders'; -export type TranslationUnitDTO = string | TranslationUnitDTOTuple; -type TranslationUnitDTOTuple = [string, string] | Record; +export type TranslationDTO = string | TranslationDTOTuple; +type TranslationDTOTuple = [string, string] | Record; -export class TranslationUnit { +export class Translation { private constructor( private readonly implementation: - | TranslationUnitWithSingularFormOnly - | TranslationUnitWithSingularAndPluralForm + | TranslationWithSingularFormOnly + | TranslationWithSingularAndPluralForm ) { } - public static fromDTO = (dto: TranslationUnitDTO): TranslationUnit => + public static fromDTO = (dto: TranslationDTO): Translation => dto instanceof Object - ? TranslationUnit.fromTuple(dto) - : TranslationUnit.fromString(dto); + ? Translation.fromTuple(dto) + : Translation.fromString(dto); - private static fromTuple = (tuple: TranslationUnitDTOTuple): TranslationUnit => - new TranslationUnit( + private static fromTuple = (tuple: TranslationDTOTuple): Translation => + new Translation( tuple[1] === undefined - ? new TranslationUnitWithSingularFormOnly(tuple[0]) - : new TranslationUnitWithSingularAndPluralForm(tuple[0], tuple[1]) + ? new TranslationWithSingularFormOnly(tuple[0]) + : new TranslationWithSingularAndPluralForm(tuple[0], tuple[1]) ); - private static fromString = (string: string): TranslationUnit => - new TranslationUnit( - new TranslationUnitWithSingularFormOnly(string) + private static fromString = (string: string): Translation => + new Translation( + new TranslationWithSingularFormOnly(string) ); public render(parameters: undefined | Parameters, quantity: number): string { @@ -43,7 +43,7 @@ export class TranslationUnit { } } -class TranslationUnitWithSingularFormOnly { +class TranslationWithSingularFormOnly { public constructor(private readonly value: string) {} public render(parameters: undefined | Parameters): string { @@ -53,7 +53,7 @@ class TranslationUnitWithSingularFormOnly { } } -class TranslationUnitWithSingularAndPluralForm { +class TranslationWithSingularAndPluralForm { public constructor( private readonly singular: string, private readonly plural: string diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts similarity index 59% rename from packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts rename to packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts index ce49be3e4a..2318feefcc 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts @@ -8,12 +8,12 @@ * source code. */ import {TranslationAddress} from './TranslationAddress'; -import {TranslationUnit} from './TranslationUnit'; -import {TranslationUnitRepository} from './TranslationUnitRepository'; +import {Translation} from './Translation'; +import {TranslationRepository} from './TranslationRepository'; -describe('TranslationUnitRepository', () => { - it('can find a translation unit by its translationAddress', () => { - const translationUnitRepository = TranslationUnitRepository.fromDTO({ +describe('TranslationRepository', () => { + it('can find a translation by its translationAddress', () => { + const translationRepository = TranslationRepository.fromDTO({ 'Neos_Neos': { // eslint-disable-line quote-props 'Main': { // eslint-disable-line quote-props 'someLabel': 'The Translation' // eslint-disable-line quote-props @@ -23,9 +23,9 @@ describe('TranslationUnitRepository', () => { const translationAddressThatCanBeFound = TranslationAddress.fromString('Neos.Neos:Main:someLabel'); const translationAddressThatCannotBeFound = TranslationAddress.fromString('Vendor.Site:Main:someLabel'); - expect(translationUnitRepository.findOneByAddress(translationAddressThatCannotBeFound)) + expect(translationRepository.findOneByAddress(translationAddressThatCannotBeFound)) .toBeNull(); - expect(translationUnitRepository.findOneByAddress(translationAddressThatCanBeFound)) - .toStrictEqual(TranslationUnit.fromDTO('The Translation')); + expect(translationRepository.findOneByAddress(translationAddressThatCanBeFound)) + .toStrictEqual(Translation.fromDTO('The Translation')); }); }); diff --git a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts similarity index 51% rename from packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts rename to packages/neos-ui-i18n/src/registry/TranslationRepository.ts index 1ef213b145..ad6dbcdc49 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationUnitRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts @@ -8,33 +8,33 @@ * source code. */ import type {TranslationAddress} from './TranslationAddress'; -import {TranslationUnit, TranslationUnitDTO} from './TranslationUnit'; +import {Translation, TranslationDTO} from './Translation'; -export type TranslationsDTO = Record>>; +export type TranslationsDTO = Record>>; -export class TranslationUnitRepository { - private _translationUnitsByAddress: Record = {}; +export class TranslationRepository { + private _translationsByAddress: Record = {}; private constructor(private readonly translations: TranslationsDTO) {} - public static fromDTO = (translations: TranslationsDTO): TranslationUnitRepository => - new TranslationUnitRepository(translations); + public static fromDTO = (translations: TranslationsDTO): TranslationRepository => + new TranslationRepository(translations); - public findOneByAddress = (address: TranslationAddress): null | TranslationUnit => { - if (address.fullyQualified in this._translationUnitsByAddress) { - return this._translationUnitsByAddress[address.fullyQualified]; + public findOneByAddress = (address: TranslationAddress): null | Translation => { + if (address.fullyQualified in this._translationsByAddress) { + return this._translationsByAddress[address.fullyQualified]; } const [packageKey, sourceName, id] = [address.packageKey, address.sourceName, address.id] // Replace all dots with underscores .map(s => s ? s.replace(/\./g, '_') : '') - const translationUnitDTO = this.translations[packageKey]?.[sourceName]?.[id] ?? null; - const translationUnit = translationUnitDTO - ? TranslationUnit.fromDTO(translationUnitDTO) + const translationDTO = this.translations[packageKey]?.[sourceName]?.[id] ?? null; + const translation = translationDTO + ? Translation.fromDTO(translationDTO) : null; - this._translationUnitsByAddress[address.fullyQualified] = translationUnit; + this._translationsByAddress[address.fullyQualified] = translation; - return translationUnit; + return translation; } } From a01b3ccc6a959722bce7f138a163cf545b5f73e4 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Tue, 18 Jun 2024 20:49:18 +0200 Subject: [PATCH 21/39] TASK: Expose function `registerTranslations` from @neos-project/neos-ui-i18n --- packages/neos-ui-i18n/src/index.tsx | 1 + .../src/registry/I18nRegistry.spec.ts | 115 +++--------------- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 13 +- .../registry/TranslationRepository.spec.ts | 55 ++++++++- .../src/registry/TranslationRepository.ts | 51 +++++++- packages/neos-ui-i18n/src/registry/index.ts | 2 + .../neos-ui-sagas/src/UI/Impersonate/index.js | 15 +-- .../Inspector/PropertyGroup/index.spec.js | 11 ++ packages/neos-ui/src/index.js | 4 +- 9 files changed, 150 insertions(+), 117 deletions(-) diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx index e6a6c0036f..5c287670a9 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {Parameters, i18nRegistry} from './registry'; export type {I18nRegistry} from './registry'; +export {registerTranslations} from './registry'; interface I18nProps { // Fallback key which gets rendered once the i18n service doesn't return a translation. diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts index 6b1761e626..a03467a0b4 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts @@ -8,6 +8,24 @@ * source code. */ import {I18nRegistry} from './I18nRegistry'; +import {registerTranslations} from './TranslationRepository'; + +beforeAll(() => { + registerTranslations({ + 'Neos_Neos': { // eslint-disable-line quote-props + 'Main': { // eslint-disable-line quote-props + 'someLabel': 'The Translation', // eslint-disable-line quote-props + 'singularLabelOnly': { + 0: 'Singular Translation' // eslint-disable-line quote-props + }, + 'pluralLabel': { + 0: 'Singular Translation', // eslint-disable-line quote-props + 1: 'Plural Translation' // eslint-disable-line quote-props + } + } + } + }); +}) test(` Host > Containers > I18n: should display configured fallback, if no translation @@ -30,16 +48,7 @@ test(` test(` Host > Containers > I18n: should display the translated string, if a translation was found via short-string.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation' // eslint-disable-line quote-props - } - } - }; - const registry = new I18nRegistry(''); - registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:someLabel'); expect(actual).toBe('The Translation'); @@ -48,16 +57,7 @@ test(` test(` Host > Containers > I18n: should display the translated string, if a translation was found via full-length prop description.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation' // eslint-disable-line quote-props - } - } - }; - const registry = new I18nRegistry(''); - registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main'); expect(actual).toBe('The Translation'); @@ -65,20 +65,7 @@ test(` test(` Host > Containers > I18n: Should display singular when no quantity is defined.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - const registry = new I18nRegistry(''); - registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main'); expect(actual).toBe('Singular Translation'); @@ -86,20 +73,7 @@ test(` test(` Host > Containers > I18n: Should display singular when quantity is zero.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - const registry = new I18nRegistry(''); - registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 0); expect(actual).toBe('Singular Translation'); @@ -107,20 +81,7 @@ test(` test(` Host > Containers > I18n: Should display singular when quantity is one.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - const registry = new I18nRegistry(''); - registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 1); expect(actual).toBe('Singular Translation'); @@ -128,20 +89,7 @@ test(` test(` Host > Containers > I18n: Should display plural when quantity is two.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - const registry = new I18nRegistry(''); - registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); expect(actual).toBe('Plural Translation'); @@ -149,20 +97,7 @@ test(` test(` Host > Containers > I18n: Should display regular language label even when no plural exists and a quantity is defined.`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation', // eslint-disable-line quote-props - 1: 'Plural Translation' // eslint-disable-line quote-props - } - } - } - }; - const registry = new I18nRegistry(''); - registry.setTranslations(translations); const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); expect(actual).toBe('The Translation'); @@ -170,20 +105,8 @@ test(` test(` Host > Containers > I18n: Should display singular when quantity is higher but plural label is not defined`, () => { - const translations = { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation', // eslint-disable-line quote-props - 'pluralLabel': { - 0: 'Singular Translation' // eslint-disable-line quote-props - } - } - } - }; - const registry = new I18nRegistry(''); - registry.setTranslations(translations); - const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); + const actual = registry.translate('Neos.Neos:Main:singularLabelOnly', undefined, undefined, 'Neos.Neos', 'Main', 2); expect(actual).toBe('Singular Translation'); }); diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 3819a7a495..ab0ed2ecd0 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -14,19 +14,12 @@ import logger from '@neos-project/utils-logger'; import {getTranslationAddress} from './getTranslationAddress'; import type {Translation} from './Translation'; import type {TranslationAddress} from './TranslationAddress'; -import {TranslationRepository, TranslationsDTO} from './TranslationRepository'; import type {Parameters} from './Parameters'; +import {getTranslationRepository} from './TranslationRepository'; const errorCache: Record = {}; export class I18nRegistry extends SynchronousRegistry { - private _translations: null|TranslationRepository = null; - - /** @internal */ - setTranslations(translations: TranslationsDTO) { - this._translations = TranslationRepository.fromDTO(translations); - } - /** * Retrieves a the translation string that is identified by the given * identifier. If it is a fully qualified translation address (a string @@ -194,13 +187,13 @@ export class I18nRegistry extends SynchronousRegistry { private logTranslationNotFound(address: TranslationAddress, fallback: string) { if (!errorCache[address.fullyQualified]) { - logger.error(`No translation found for id "${address.fullyQualified}" in:`, this._translations, `Using ${fallback} instead.`); + logger.error(`No translation found for id "${address.fullyQualified}" in:`, getTranslationRepository(), `Using ${fallback} instead.`); errorCache[address.fullyQualified] = true; } } private getTranslation(address: TranslationAddress): null | Translation { - return this._translations?.findOneByAddress(address) ?? null; + return getTranslationRepository().findOneByAddress(address) ?? null; } } diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts index 2318feefcc..83e82f15e3 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts @@ -9,7 +9,13 @@ */ import {TranslationAddress} from './TranslationAddress'; import {Translation} from './Translation'; -import {TranslationRepository} from './TranslationRepository'; +import { + TranslationRepository, + TranslationRepositoryIsNotAvailable, + TranslationsCannotBeRegistered, + getTranslationRepository, + registerTranslations +} from './TranslationRepository'; describe('TranslationRepository', () => { it('can find a translation by its translationAddress', () => { @@ -28,4 +34,51 @@ describe('TranslationRepository', () => { expect(translationRepository.findOneByAddress(translationAddressThatCanBeFound)) .toStrictEqual(Translation.fromDTO('The Translation')); }); + + describe('singleton', () => { + test('getTranslationRepository throws if called before translations have been registered', () => { + expect(() => getTranslationRepository()) + .toThrow( + TranslationRepositoryIsNotAvailable + .becauseTranslationsHaveNotBeenRegisteredYet() + ); + }); + + test('getTranslationRepository returns the singleton TranslationRepository instance after translations have been registered', () => { + registerTranslations({ + 'Neos_Neos': { // eslint-disable-line quote-props + 'Main': { // eslint-disable-line quote-props + 'someLabel': 'The Translation' // eslint-disable-line quote-props + } + } + }); + + expect(getTranslationRepository()) + .toStrictEqual( + TranslationRepository.fromDTO({ + 'Neos_Neos': { // eslint-disable-line quote-props + 'Main': { // eslint-disable-line quote-props + 'someLabel': 'The Translation' // eslint-disable-line quote-props + } + } + }) + ); + + expect(getTranslationRepository()) + .toBe(getTranslationRepository()); + }); + + test('registerTranslations throws if called more than once', () => { + expect(() => registerTranslations({ + 'Neos_Neos': { // eslint-disable-line quote-props + 'Main': { // eslint-disable-line quote-props + 'someLabel': 'The Translation' // eslint-disable-line quote-props + } + } + })).toThrow( + TranslationsCannotBeRegistered + .becauseTranslationsHaveAlreadyBeenRegistered() + ); + }); + }); }); diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts index ad6dbcdc49..78ab000373 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts @@ -20,7 +20,7 @@ export class TranslationRepository { public static fromDTO = (translations: TranslationsDTO): TranslationRepository => new TranslationRepository(translations); - public findOneByAddress = (address: TranslationAddress): null | Translation => { + public findOneByAddress(address: TranslationAddress): null | Translation { if (address.fullyQualified in this._translationsByAddress) { return this._translationsByAddress[address.fullyQualified]; } @@ -38,3 +38,52 @@ export class TranslationRepository { return translation; } } + +let translationRepository: null | TranslationRepository = null; + +/** + * Registers the given translations globally for use throughout the application + * + * @internal For use in the Neos UI application bootstrapping process only! + * @param {TranslationsDTO} translations + */ +export function registerTranslations(translations: TranslationsDTO): void { + if (translationRepository !== null) { + throw TranslationsCannotBeRegistered + .becauseTranslationsHaveAlreadyBeenRegistered(); + } + + translationRepository = TranslationRepository.fromDTO(translations); +} + +export class TranslationsCannotBeRegistered extends Error { + private constructor(message: string) { + super(`[Translations cannot be registered]: ${message}`); + } + + public static becauseTranslationsHaveAlreadyBeenRegistered = () => + new TranslationsCannotBeRegistered( + 'Translations can only be registered once, and have already been registered.' + ); +} + +export function getTranslationRepository(): TranslationRepository { + if (translationRepository === null) { + throw TranslationRepositoryIsNotAvailable + .becauseTranslationsHaveNotBeenRegisteredYet(); + } + + return translationRepository; +} + +export class TranslationRepositoryIsNotAvailable extends Error { + private constructor(message: string) { + super(`[TranslationRepository is not available]: ${message}`); + } + + public static becauseTranslationsHaveNotBeenRegisteredYet = () => + new TranslationRepositoryIsNotAvailable( + 'Translations have not been registered yet. Make sure to call' + + ' `registerTranslations` during the application bootstrapping process.' + ); +} diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts index f67897ea10..3ade324882 100644 --- a/packages/neos-ui-i18n/src/registry/index.ts +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -11,3 +11,5 @@ export type {I18nRegistry} from './I18nRegistry'; export {i18nRegistry} from './I18nRegistry'; export type {Parameters} from './Parameters'; + +export {registerTranslations} from './TranslationRepository'; diff --git a/packages/neos-ui-sagas/src/UI/Impersonate/index.js b/packages/neos-ui-sagas/src/UI/Impersonate/index.js index 354e85e9b2..fd819bc6e6 100644 --- a/packages/neos-ui-sagas/src/UI/Impersonate/index.js +++ b/packages/neos-ui-sagas/src/UI/Impersonate/index.js @@ -7,15 +7,16 @@ import {showFlashMessage} from '@neos-project/neos-ui-error'; export function * impersonateRestore({globalRegistry, routes}) { const {impersonateRestore} = backend.get().endpoints; const i18nRegistry = globalRegistry.get('i18n'); - const errorMessage = i18nRegistry.translate( - 'impersonate.error.restoreUser', - 'Could not switch back to the original user.', - {}, - 'Neos.Neos', - 'Main' - ); yield takeEvery(actionTypes.User.Impersonate.RESTORE, function * restore(action) { + const errorMessage = i18nRegistry.translate( + 'impersonate.error.restoreUser', + 'Could not switch back to the original user.', + {}, + 'Neos.Neos', + 'Main' + ); + try { const feedback = yield call(impersonateRestore, action.payload); const originUser = feedback?.origin?.accountIdentifier; diff --git a/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js b/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js index 4a9798c932..715a18c5bc 100644 --- a/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js +++ b/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js @@ -4,9 +4,20 @@ import {createStore} from 'redux'; import {mount} from 'enzyme'; import PropertyGroup from './index'; import {WrapWithMockGlobalRegistry} from '@neos-project/neos-ui-editors/src/_lib/testUtils'; +import {registerTranslations} from '@neos-project/neos-ui-i18n'; const store = createStore(state => state, {}); +beforeAll(() => { + registerTranslations({ + 'Neos_Neos': { + 'Main': { + 'Foo group': 'Foo group' + } + } + }); +}); + test(`PropertyGroup > is rendered`, () => { const items = [ { diff --git a/packages/neos-ui/src/index.js b/packages/neos-ui/src/index.js index b545d10636..eeeccb4686 100644 --- a/packages/neos-ui/src/index.js +++ b/packages/neos-ui/src/index.js @@ -11,6 +11,7 @@ import {SynchronousMetaRegistry} from '@neos-project/neos-ui-extensibility/src/r import backend from '@neos-project/neos-ui-backend-connector'; import {handleActions} from '@neos-project/utils-redux'; import {showFlashMessage} from '@neos-project/neos-ui-error'; +import {registerTranslations} from '@neos-project/neos-ui-i18n'; import { appContainer, @@ -172,10 +173,9 @@ async function loadNodeTypesSchema() { async function loadTranslations() { const {getJsonResource} = backend.get().endpoints; - const i18nRegistry = globalRegistry.get('i18n'); const translations = await getJsonResource(configuration.endpoints.translations); - i18nRegistry.setTranslations(translations); + registerTranslations(translations); } async function loadImpersonateStatus() { From 5e18aa9e2a9abcc50f20035c4857997f126e4902 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 19 Jun 2024 12:30:41 +0200 Subject: [PATCH 22/39] TASK: Discover translations endpoint via -tag --- Classes/Presentation/ApplicationView.php | 6 ++++++ packages/neos-ui/src/index.js | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Classes/Presentation/ApplicationView.php b/Classes/Presentation/ApplicationView.php index c54f1d7c5c..4f3f6228dd 100644 --- a/Classes/Presentation/ApplicationView.php +++ b/Classes/Presentation/ApplicationView.php @@ -113,6 +113,12 @@ private function renderHead(): string ) ); + // @TODO: All endpoints should be treated this way and be isolated from + // initial data. + $result .= sprintf( + '', + $this->variables['initialData']['configuration']['endpoints']['translations'], + ); $result .= sprintf( '', json_encode($this->variables['initialData']), diff --git a/packages/neos-ui/src/index.js b/packages/neos-ui/src/index.js index eeeccb4686..b804bdac68 100644 --- a/packages/neos-ui/src/index.js +++ b/packages/neos-ui/src/index.js @@ -173,7 +173,8 @@ async function loadNodeTypesSchema() { async function loadTranslations() { const {getJsonResource} = backend.get().endpoints; - const translations = await getJsonResource(configuration.endpoints.translations); + const endpoint = document.getElementById('neos-ui-uri:/neos/xliff.json').getAttribute('href'); + const translations = await getJsonResource(endpoint); registerTranslations(translations); } From ce1b6183ef8dad6eedb00be3392a34489ad5e0e4 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Tue, 25 Jun 2024 14:32:30 +0200 Subject: [PATCH 23/39] TASK: Introduce Locale & PluralRule models --- .../neos-ui-i18n/src/model/Locale.spec.ts | 81 ++++++++++++ packages/neos-ui-i18n/src/model/Locale.ts | 116 ++++++++++++++++++ .../neos-ui-i18n/src/model/PluralRule.spec.ts | 39 ++++++ packages/neos-ui-i18n/src/model/PluralRule.ts | 66 ++++++++++ .../src/model/PluralRules.spec.ts | 50 ++++++++ .../neos-ui-i18n/src/model/PluralRules.ts | 52 ++++++++ 6 files changed, 404 insertions(+) create mode 100644 packages/neos-ui-i18n/src/model/Locale.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/Locale.ts create mode 100644 packages/neos-ui-i18n/src/model/PluralRule.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/PluralRule.ts create mode 100644 packages/neos-ui-i18n/src/model/PluralRules.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/PluralRules.ts diff --git a/packages/neos-ui-i18n/src/model/Locale.spec.ts b/packages/neos-ui-i18n/src/model/Locale.spec.ts new file mode 100644 index 0000000000..5dbac9fa84 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Locale.spec.ts @@ -0,0 +1,81 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import { + getLocale, + InvalidLocale, + Locale, + LocaleIsNotAvailable, + LocaleCannotBeRegistered, + registerLocale +} from './Locale'; +import {InvalidPluralRules} from './PluralRules'; + +describe('Locale', () => { + it('throws when attempted to be created with invalid locale identifier', () => { + expect(() => Locale.create('an invalid identifier', 'one,other')) + .toThrow(InvalidLocale.becauseOfInvalidIdentifier('an invalid identifier')); + }); + + it('throws when attempted to be created with invalid plural forms', () => { + expect(() => Locale.create('en-US', '')) + .toThrow(InvalidLocale.becauseOfInvalidPluralRules('en-US', InvalidPluralRules.becauseTheyAreEmpty())); + }); + + describe('#getPluralFormIndexForQuantity', () => { + it('provides the index for lookup of the correct plural form given a quantity', () => { + const locale_en_US = Locale.create('en-US', 'one,other'); + const locale_ar_EG = Locale.create('ar-EG', 'zero,one,two,few,many'); + + expect(locale_en_US.getPluralFormIndexForQuantity(0)) + .toBe(1); + expect(locale_en_US.getPluralFormIndexForQuantity(1)) + .toBe(0); + expect(locale_en_US.getPluralFormIndexForQuantity(2)) + .toBe(1); + expect(locale_en_US.getPluralFormIndexForQuantity(3)) + .toBe(1); + + expect(locale_ar_EG.getPluralFormIndexForQuantity(0)) + .toBe(0); + expect(locale_ar_EG.getPluralFormIndexForQuantity(1)) + .toBe(1); + expect(locale_ar_EG.getPluralFormIndexForQuantity(2)) + .toBe(2); + expect(locale_ar_EG.getPluralFormIndexForQuantity(6)) + .toBe(3); + expect(locale_ar_EG.getPluralFormIndexForQuantity(18)) + .toBe(4); + }); + }); + + describe('singleton', () => { + test('getLocale throws if called before locale has been registered', () => { + expect(() => getLocale()).toThrow( + LocaleIsNotAvailable.becauseLocaleHasNotBeenRegisteredYet() + ); + }); + + test('getLocale returns the singleton Locale instance after locale has been registered', () => { + registerLocale('en-US', 'one,other'); + + expect(getLocale()).toStrictEqual( + Locale.create('en-US', 'one,other') + ); + + expect(getLocale()).toBe(getLocale()); + }); + + test('registerLocale throws if called more than once', () => { + expect(() => registerLocale('en-US', 'one,other')).toThrow( + LocaleCannotBeRegistered.becauseLocaleHasAlreadyBeenRegistered() + ); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/Locale.ts b/packages/neos-ui-i18n/src/model/Locale.ts new file mode 100644 index 0000000000..e2591ac1a2 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Locale.ts @@ -0,0 +1,116 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {PluralRule} from './PluralRule'; +import {InvalidPluralRules, PluralRules} from './PluralRules'; + +export class Locale { + private readonly intlPluralRules: Intl.PluralRules; + + private constructor( + private readonly intlLocale: Intl.Locale, + private readonly pluralRules: PluralRules + ) { + this.intlPluralRules = new Intl.PluralRules(this.intlLocale.toString()); + } + + public static create = (identifier: string, pluralRulesAsString: string): Locale => { + let intlLocale: Intl.Locale; + try { + intlLocale = new Intl.Locale(identifier) + } catch { + throw InvalidLocale.becauseOfInvalidIdentifier(identifier); + } + + let pluralRules: PluralRules; + try { + pluralRules = PluralRules.fromString(pluralRulesAsString); + } catch (error) { + throw InvalidLocale.becauseOfInvalidPluralRules( + identifier, + error as InvalidPluralRules + ); + } + + return new Locale(intlLocale, pluralRules); + } + + public getPluralFormIndexForQuantity(quantity: number): number { + return this.pluralRules.getIndexOf( + PluralRule.fromString( + this.intlPluralRules.select(quantity) + ) + ); + } +} + +export class InvalidLocale extends Error { + private constructor( + message: string, + public readonly cause?: InvalidPluralRules + ) { + super(message); + } + + public static becauseOfInvalidIdentifier = (attemptedIdentifier: string): InvalidLocale => + new InvalidLocale(`"${attemptedIdentifier}" is not a valid locale identifier. It must pass as a sole argument to new Intl.Locale(...). Please consult https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale for further information.`); + + public static becauseOfInvalidPluralRules = (identifier: string, cause: InvalidPluralRules): InvalidLocale => + new InvalidLocale(`Locale "${identifier}" could not be initialized because of invalid plural forms: ${cause.message}`, cause); +} + +let locale: null | Locale = null; + +/** + * Registers the given locale globally for use throughout the application + * + * @internal For use in the Neos UI application bootstrapping process only! + * @param {string} identifier + * @param {string} pluralRulesAsString + */ +export function registerLocale(identifier: string, pluralRulesAsString: string): void { + if (locale !== null) { + throw LocaleCannotBeRegistered + .becauseLocaleHasAlreadyBeenRegistered(); + } + + locale = Locale.create(identifier, pluralRulesAsString); +} + +export class LocaleCannotBeRegistered extends Error { + private constructor(message: string) { + super(`[Locale cannot be registered]: ${message}`); + } + + public static becauseLocaleHasAlreadyBeenRegistered = () => + new LocaleCannotBeRegistered( + 'Locale can only be registered once, and has already been registered.' + ); +} + +export function getLocale(): Locale { + if (locale === null) { + throw LocaleIsNotAvailable + .becauseLocaleHasNotBeenRegisteredYet(); + } + + return locale; +} + +export class LocaleIsNotAvailable extends Error { + private constructor(message: string) { + super(`[Locale is not available]: ${message}`); + } + + public static becauseLocaleHasNotBeenRegisteredYet = () => + new LocaleIsNotAvailable( + 'Locale has not been registered yet. Make sure to call' + + ' `registerLocale` during the application bootstrapping process.' + ); +} diff --git a/packages/neos-ui-i18n/src/model/PluralRule.spec.ts b/packages/neos-ui-i18n/src/model/PluralRule.spec.ts new file mode 100644 index 0000000000..76cca063f2 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/PluralRule.spec.ts @@ -0,0 +1,39 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {PluralRule, InvalidPluralRule} from './PluralRule'; + +describe('PluralRule', () => { + it('can be created from string', () => { + expect(PluralRule.fromString('zero')) + .toBe(PluralRule.ZERO); + expect(PluralRule.fromString('one')) + .toBe(PluralRule.ONE); + expect(PluralRule.fromString('two')) + .toBe(PluralRule.TWO); + expect(PluralRule.fromString('few')) + .toBe(PluralRule.FEW); + expect(PluralRule.fromString('many')) + .toBe(PluralRule.MANY); + expect(PluralRule.fromString('other')) + .toBe(PluralRule.OTHER); + }); + + it('throws when attempted to be created from an empty string', () => { + expect(() => PluralRule.fromString('')) + .toThrow(InvalidPluralRule.becauseItIsEmpty()); + }); + + it('throws when attempted to be created from an invalid string', () => { + expect(() => PluralRule.fromString('does-not-exist')) + .toThrow(InvalidPluralRule.becauseItIsUnknown('does-not-exist')); + expect(() => PluralRule.fromString('ZeRo')) + .toThrow(InvalidPluralRule.becauseItIsUnknown('ZeRo')); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/PluralRule.ts b/packages/neos-ui-i18n/src/model/PluralRule.ts new file mode 100644 index 0000000000..20bdab60c8 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/PluralRule.ts @@ -0,0 +1,66 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +/** + * Plural case as per Unicode CLDR: + * https://cldr.unicode.org/index/cldr-spec/plural-rules + */ +export class PluralRule { + private constructor(public readonly value: string) {} + + public static readonly ZERO = new PluralRule('zero'); + + public static readonly ONE = new PluralRule('one'); + + public static readonly TWO = new PluralRule('two'); + + public static readonly FEW = new PluralRule('few'); + + public static readonly MANY = new PluralRule('many'); + + public static readonly OTHER = new PluralRule('other'); + + public static fromString = (string: string): PluralRule => { + if (string === '') { + throw InvalidPluralRule.becauseItIsEmpty(); + } + + switch (string) { + case 'zero': + return PluralRule.ZERO; + case 'one': + return PluralRule.ONE; + case 'two': + return PluralRule.TWO; + case 'few': + return PluralRule.FEW; + case 'many': + return PluralRule.MANY; + case 'other': + return PluralRule.OTHER; + default: + throw InvalidPluralRule.becauseItIsUnknown(string); + } + } +} + +export class InvalidPluralRule extends Error { + private constructor(message: string) { + super(message); + } + + public static becauseItIsEmpty = (): InvalidPluralRule => + new InvalidPluralRule(`PluralRule must be one of "zero", "one", "two", "few", "many" +or "other", but was empty.`); + + public static becauseItIsUnknown = (attemptedString: string): InvalidPluralRule => + new InvalidPluralRule(`PluralRule must be one of "zero", "one", "two", "few", "many" +or "other". Got "${attemptedString}" instead.`); +} diff --git a/packages/neos-ui-i18n/src/model/PluralRules.spec.ts b/packages/neos-ui-i18n/src/model/PluralRules.spec.ts new file mode 100644 index 0000000000..bcf0c0e6b0 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/PluralRules.spec.ts @@ -0,0 +1,50 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {InvalidPluralRule, PluralRule} from './PluralRule'; +import {InvalidPluralRules, PluralRules} from './PluralRules'; + +describe('PluralRules', () => { + it('can be created from string', () => { + expect(PluralRules.fromString('one,other')) + .toStrictEqual(PluralRules.of(PluralRule.ONE, PluralRule.OTHER)); + + expect(PluralRules.fromString('one,two,few,many,other')) + .toStrictEqual(PluralRules.of(PluralRule.ONE, PluralRule.TWO, PluralRule.FEW, PluralRule.MANY, PluralRule.OTHER)); + }); + + it('throws when attempted to be created from an empty string', () => { + expect(() => PluralRules.fromString('')) + .toThrow(InvalidPluralRules.becauseTheyAreEmpty()); + }); + + it('throws when attempted to be created from an invalid string', () => { + expect(() => PluralRules.fromString(',,,')) + .toThrow(InvalidPluralRules.becauseOfInvalidPluralRule(0, InvalidPluralRule.becauseItIsEmpty())); + expect(() => PluralRules.fromString('one,two,twenty,other')) + .toThrow(InvalidPluralRules.becauseOfInvalidPluralRule(2, InvalidPluralRule.becauseItIsUnknown('twenty'))); + }); + + describe('#getIndexOf', () => { + it('returns the index of the given plural case', () => { + const pluralRules = PluralRules.fromString('one,two,few,many,other'); + + expect(pluralRules.getIndexOf(PluralRule.ONE)) + .toBe(0); + expect(pluralRules.getIndexOf(PluralRule.TWO)) + .toBe(1); + expect(pluralRules.getIndexOf(PluralRule.FEW)) + .toBe(2); + expect(pluralRules.getIndexOf(PluralRule.MANY)) + .toBe(3); + expect(pluralRules.getIndexOf(PluralRule.OTHER)) + .toBe(4); + }); + }) +}); diff --git a/packages/neos-ui-i18n/src/model/PluralRules.ts b/packages/neos-ui-i18n/src/model/PluralRules.ts new file mode 100644 index 0000000000..b53eb3d13e --- /dev/null +++ b/packages/neos-ui-i18n/src/model/PluralRules.ts @@ -0,0 +1,52 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ + +import {InvalidPluralRule, PluralRule} from './PluralRule'; + +/** + * A list of plural cases + * @internal + */ +export class PluralRules { + private constructor(public readonly value: PluralRule[]) {} + + public static of = (...cases: PluralRule[]) => + new PluralRules(cases); + + public static fromString = (string: string): PluralRules => { + if (string === '') { + throw InvalidPluralRules.becauseTheyAreEmpty(); + } + + return new PluralRules(string.split(',').map((string, index) => { + try { + return PluralRule.fromString(string) + } catch (error) { + throw InvalidPluralRules.becauseOfInvalidPluralRule(index, error as InvalidPluralRule); + } + })); + } + + public getIndexOf(pluralRule: PluralRule): number { + return this.value.indexOf(pluralRule); + } +} + +export class InvalidPluralRules extends Error { + private constructor(message: string, public readonly cause?: InvalidPluralRule) { + super(message); + } + + public static becauseTheyAreEmpty = (): InvalidPluralRules => + new InvalidPluralRules(`PluralRules must not be empty, but were.`); + + public static becauseOfInvalidPluralRule = (index: number, cause: InvalidPluralRule): InvalidPluralRules => + new InvalidPluralRules(`PluralRules contain invalid value at index ${index}: ${cause.message}`, cause); +} From 0a0c4ad7d54456352e70c4f69411e4713feffa9a Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Tue, 25 Jun 2024 15:43:31 +0200 Subject: [PATCH 24/39] TASK: Expose `registerLocale` and use it in bootstrap process --- Classes/Presentation/ApplicationView.php | 10 +++++++++- packages/neos-ui-i18n/src/index.tsx | 2 ++ packages/neos-ui-i18n/src/model/index.ts | 10 ++++++++++ packages/neos-ui/src/index.js | 6 ++++-- 4 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 packages/neos-ui-i18n/src/model/index.ts diff --git a/Classes/Presentation/ApplicationView.php b/Classes/Presentation/ApplicationView.php index 4f3f6228dd..21c64fa715 100644 --- a/Classes/Presentation/ApplicationView.php +++ b/Classes/Presentation/ApplicationView.php @@ -16,6 +16,8 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Core\Bootstrap; +use Neos\Flow\I18n\Cldr\Reader\PluralsReader; +use Neos\Flow\I18n\Locale; use Neos\Flow\Mvc\View\AbstractView; use Neos\Flow\ResourceManagement\ResourceManager; use Neos\Flow\Security\Context as SecurityContext; @@ -49,6 +51,9 @@ final class ApplicationView extends AbstractView #[Flow\Inject] protected Bootstrap $bootstrap; + #[Flow\Inject] + protected PluralsReader $pluralsReader; + /** * This contains the supported options, their default values, descriptions and types. * @@ -113,11 +118,14 @@ private function renderHead(): string ) ); + $locale = new Locale($this->userService->getInterfaceLanguage()); // @TODO: All endpoints should be treated this way and be isolated from // initial data. $result .= sprintf( - '', + '', $this->variables['initialData']['configuration']['endpoints']['translations'], + (string) $locale, + implode(',', $this->pluralsReader->getPluralForms($locale)), ); $result .= sprintf( '', diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx index 5c287670a9..a8b6b0481a 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/index.tsx @@ -1,6 +1,8 @@ import React from 'react'; import {Parameters, i18nRegistry} from './registry'; +export {registerLocale} from './model'; + export type {I18nRegistry} from './registry'; export {registerTranslations} from './registry'; diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts new file mode 100644 index 0000000000..54a721ce30 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {registerLocale} from './Locale'; diff --git a/packages/neos-ui/src/index.js b/packages/neos-ui/src/index.js index b804bdac68..1c7f0b3662 100644 --- a/packages/neos-ui/src/index.js +++ b/packages/neos-ui/src/index.js @@ -11,7 +11,7 @@ import {SynchronousMetaRegistry} from '@neos-project/neos-ui-extensibility/src/r import backend from '@neos-project/neos-ui-backend-connector'; import {handleActions} from '@neos-project/utils-redux'; import {showFlashMessage} from '@neos-project/neos-ui-error'; -import {registerTranslations} from '@neos-project/neos-ui-i18n'; +import {registerLocale, registerTranslations} from '@neos-project/neos-ui-i18n'; import { appContainer, @@ -173,9 +173,11 @@ async function loadNodeTypesSchema() { async function loadTranslations() { const {getJsonResource} = backend.get().endpoints; - const endpoint = document.getElementById('neos-ui-uri:/neos/xliff.json').getAttribute('href'); + const link = document.getElementById('neos-ui-uri:/neos/xliff.json'); + const endpoint = link.getAttribute('href'); const translations = await getJsonResource(endpoint); + registerLocale(link.dataset.locale, link.dataset.localePluralRules); registerTranslations(translations); } From cf61d3abe9bc9e208f4bb43de29423ce577bf72c Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Tue, 25 Jun 2024 16:41:16 +0200 Subject: [PATCH 25/39] !!!BUGFIX: Use Intl plurals for proper plural form determination --- packages/neos-ui-i18n/src/model/index.ts | 2 +- .../src/registry/I18nRegistry.spec.ts | 11 ++-- .../src/registry/Translation.spec.ts | 34 ++++++------ .../neos-ui-i18n/src/registry/Translation.ts | 54 ++++++------------- .../registry/TranslationRepository.spec.ts | 13 +++-- .../src/registry/TranslationRepository.ts | 18 +++++-- .../Inspector/PropertyGroup/index.spec.js | 3 +- 7 files changed, 66 insertions(+), 69 deletions(-) diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts index 54a721ce30..4e3627b66a 100644 --- a/packages/neos-ui-i18n/src/model/index.ts +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -7,4 +7,4 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -export {registerLocale} from './Locale'; +export {getLocale, Locale, registerLocale} from './Locale'; diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts index a03467a0b4..c32cc650a8 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts @@ -7,10 +7,13 @@ * information, please view the LICENSE file which was distributed with this * source code. */ +import {registerLocale} from '../model'; + import {I18nRegistry} from './I18nRegistry'; import {registerTranslations} from './TranslationRepository'; beforeAll(() => { + registerLocale('en-US', 'one,other'); registerTranslations({ 'Neos_Neos': { // eslint-disable-line quote-props 'Main': { // eslint-disable-line quote-props @@ -64,19 +67,19 @@ test(` }); test(` - Host > Containers > I18n: Should display singular when no quantity is defined.`, () => { + Host > Containers > I18n: Should display plural when no quantity is defined.`, () => { const registry = new I18nRegistry(''); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main'); - expect(actual).toBe('Singular Translation'); + expect(actual).toBe('Plural Translation'); }); test(` - Host > Containers > I18n: Should display singular when quantity is zero.`, () => { + Host > Containers > I18n: Should display plural when quantity is zero.`, () => { const registry = new I18nRegistry(''); const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 0); - expect(actual).toBe('Singular Translation'); + expect(actual).toBe('Plural Translation'); }); test(` diff --git a/packages/neos-ui-i18n/src/registry/Translation.spec.ts b/packages/neos-ui-i18n/src/registry/Translation.spec.ts index 3a46a68e04..45182fca4c 100644 --- a/packages/neos-ui-i18n/src/registry/Translation.spec.ts +++ b/packages/neos-ui-i18n/src/registry/Translation.spec.ts @@ -7,11 +7,15 @@ * information, please view the LICENSE file which was distributed with this * source code. */ +import {Locale} from '../model'; + import {Translation} from './Translation'; describe('Translation', () => { + const locale_en_US = Locale.create('en-US', 'one,other'); + it('can be created from a defective DTO', () => { - const translation = Translation.fromDTO([ + const translation = Translation.fromDTO(locale_en_US, [ 'This translation has only a singular form, despite its DTO being an array.' ]); @@ -21,7 +25,7 @@ describe('Translation', () => { describe('having a singular form only', () => { it('renders a translation string without placeholders and quantity = 0', () => { - const translation = Translation.fromDTO( + const translation = Translation.fromDTO(locale_en_US, 'This translation has only a singular form and no placeholders.' ); @@ -30,7 +34,7 @@ describe('Translation', () => { }); it('renders a translation string without placeholders and with quantity = 1', () => { - const translation = Translation.fromDTO( + const translation = Translation.fromDTO(locale_en_US, 'This translation has only a singular form and no placeholders.' ); @@ -39,7 +43,7 @@ describe('Translation', () => { }); it('renders a translation string without placeholders and with quantity > 1', () => { - const translation = Translation.fromDTO( + const translation = Translation.fromDTO(locale_en_US, 'This translation has only a singular form and no placeholders.' ); @@ -48,7 +52,7 @@ describe('Translation', () => { }); it('renders a translation string with placeholders and quantity = 0', () => { - const translation = Translation.fromDTO( + const translation = Translation.fromDTO(locale_en_US, 'This translation has only a singular form and {some} placeholder.' ); @@ -57,7 +61,7 @@ describe('Translation', () => { }); it('renders a translation string with placeholders and with quantity = 1', () => { - const translation = Translation.fromDTO( + const translation = Translation.fromDTO(locale_en_US, 'This translation has only a singular form and {some} placeholder.' ); @@ -66,7 +70,7 @@ describe('Translation', () => { }); it('renders a translation string with placeholders and with quantity > 1', () => { - const translation = Translation.fromDTO( + const translation = Translation.fromDTO(locale_en_US, 'This translation has only a singular form and {some} placeholder.' ); @@ -77,17 +81,17 @@ describe('Translation', () => { describe('having a singular and a plural form', () => { it('renders a translation string without placeholders and quantity = 0', () => { - const translation = Translation.fromDTO([ + const translation = Translation.fromDTO(locale_en_US, [ 'This translation has a singular form with no placeholders.', 'This translation has a plural form with no placeholders.' ]); expect(translation.render(undefined, 0)) - .toBe('This translation has a singular form with no placeholders.'); + .toBe('This translation has a plural form with no placeholders.'); }); it('renders a translation string without placeholders and with quantity = 1', () => { - const translation = Translation.fromDTO([ + const translation = Translation.fromDTO(locale_en_US, [ 'This translation has a singular form with no placeholders.', 'This translation has a plural form with no placeholders.' ]); @@ -97,7 +101,7 @@ describe('Translation', () => { }); it('renders a translation string without placeholders and with quantity > 1', () => { - const translation = Translation.fromDTO([ + const translation = Translation.fromDTO(locale_en_US, [ 'This translation has a singular form with no placeholders.', 'This translation has a plural form with no placeholders.' ]); @@ -107,17 +111,17 @@ describe('Translation', () => { }); it('renders a translation string with placeholders and quantity = 0', () => { - const translation = Translation.fromDTO([ + const translation = Translation.fromDTO(locale_en_US, [ 'This translation has a singular form with {some} placeholder.', 'This translation has a plural form with {some} placeholder.' ]); expect(translation.render({some: 'one'}, 0)) - .toBe('This translation has a singular form with one placeholder.'); + .toBe('This translation has a plural form with one placeholder.'); }); it('renders a translation string with placeholders and with quantity = 1', () => { - const translation = Translation.fromDTO([ + const translation = Translation.fromDTO(locale_en_US, [ 'This translation has a singular form with {some} placeholder.', 'This translation has a plural form with {some} placeholder.' ]); @@ -127,7 +131,7 @@ describe('Translation', () => { }); it('renders a translation string with placeholders and with quantity > 1', () => { - const translation = Translation.fromDTO([ + const translation = Translation.fromDTO(locale_en_US, [ 'This translation has a singular form with {some} placeholder.', 'This translation has a plural form with {some} placeholder.' ]); diff --git a/packages/neos-ui-i18n/src/registry/Translation.ts b/packages/neos-ui-i18n/src/registry/Translation.ts index 50fd8bb688..d113904937 100644 --- a/packages/neos-ui-i18n/src/registry/Translation.ts +++ b/packages/neos-ui-i18n/src/registry/Translation.ts @@ -7,57 +7,31 @@ * information, please view the LICENSE file which was distributed with this * source code. */ +import {Locale} from '../model'; + import type {Parameters} from './Parameters'; import {substitutePlaceholders} from './substitutePlaceholders'; export type TranslationDTO = string | TranslationDTOTuple; -type TranslationDTOTuple = [string, string] | Record; +type TranslationDTOTuple = string[] | Record; export class Translation { private constructor( - private readonly implementation: - | TranslationWithSingularFormOnly - | TranslationWithSingularAndPluralForm + private readonly locale: Locale, + private readonly value: string[] ) { } - public static fromDTO = (dto: TranslationDTO): Translation => + public static fromDTO = (locale: Locale, dto: TranslationDTO): Translation => dto instanceof Object - ? Translation.fromTuple(dto) - : Translation.fromString(dto); - - private static fromTuple = (tuple: TranslationDTOTuple): Translation => - new Translation( - tuple[1] === undefined - ? new TranslationWithSingularFormOnly(tuple[0]) - : new TranslationWithSingularAndPluralForm(tuple[0], tuple[1]) - ); - - private static fromString = (string: string): Translation => - new Translation( - new TranslationWithSingularFormOnly(string) - ); - - public render(parameters: undefined | Parameters, quantity: number): string { - return this.implementation.render(parameters, quantity); - } -} - -class TranslationWithSingularFormOnly { - public constructor(private readonly value: string) {} + ? Translation.fromTuple(locale, dto) + : Translation.fromString(locale, dto); - public render(parameters: undefined | Parameters): string { - return parameters - ? substitutePlaceholders(this.value, parameters) - : this.value; - } -} + private static fromTuple = (locale: Locale, tuple: TranslationDTOTuple): Translation => + new Translation(locale, Object.values(tuple)); -class TranslationWithSingularAndPluralForm { - public constructor( - private readonly singular: string, - private readonly plural: string - ) {} + private static fromString = (locale: Locale, string: string): Translation => + new Translation(locale, [string]); public render(parameters: undefined | Parameters, quantity: number): string { return parameters @@ -66,6 +40,8 @@ class TranslationWithSingularAndPluralForm { } private byQuantity(quantity: number): string { - return quantity <= 1 ? this.singular : this.plural; + const index = this.locale.getPluralFormIndexForQuantity(quantity); + + return this.value[index] ?? this.value[0] ?? ''; } } diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts index 83e82f15e3..e410d2aec3 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts @@ -7,6 +7,8 @@ * information, please view the LICENSE file which was distributed with this * source code. */ +import {Locale, registerLocale} from '../model'; + import {TranslationAddress} from './TranslationAddress'; import {Translation} from './Translation'; import { @@ -18,8 +20,10 @@ import { } from './TranslationRepository'; describe('TranslationRepository', () => { - it('can find a translation by its translationAddress', () => { - const translationRepository = TranslationRepository.fromDTO({ + const locale_en_US = Locale.create('en-US', 'one,other'); + + it('can find a translation by its translation address', () => { + const translationRepository = TranslationRepository.fromDTO(locale_en_US, { 'Neos_Neos': { // eslint-disable-line quote-props 'Main': { // eslint-disable-line quote-props 'someLabel': 'The Translation' // eslint-disable-line quote-props @@ -32,7 +36,7 @@ describe('TranslationRepository', () => { expect(translationRepository.findOneByAddress(translationAddressThatCannotBeFound)) .toBeNull(); expect(translationRepository.findOneByAddress(translationAddressThatCanBeFound)) - .toStrictEqual(Translation.fromDTO('The Translation')); + .toStrictEqual(Translation.fromDTO(locale_en_US, 'The Translation')); }); describe('singleton', () => { @@ -45,6 +49,7 @@ describe('TranslationRepository', () => { }); test('getTranslationRepository returns the singleton TranslationRepository instance after translations have been registered', () => { + registerLocale('en-US', 'one,other'); registerTranslations({ 'Neos_Neos': { // eslint-disable-line quote-props 'Main': { // eslint-disable-line quote-props @@ -55,7 +60,7 @@ describe('TranslationRepository', () => { expect(getTranslationRepository()) .toStrictEqual( - TranslationRepository.fromDTO({ + TranslationRepository.fromDTO(locale_en_US, { 'Neos_Neos': { // eslint-disable-line quote-props 'Main': { // eslint-disable-line quote-props 'someLabel': 'The Translation' // eslint-disable-line quote-props diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts index 78ab000373..3734c08d1d 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts @@ -7,6 +7,8 @@ * information, please view the LICENSE file which was distributed with this * source code. */ +import {Locale, getLocale} from '../model'; + import type {TranslationAddress} from './TranslationAddress'; import {Translation, TranslationDTO} from './Translation'; @@ -15,10 +17,13 @@ export type TranslationsDTO = Record = {}; - private constructor(private readonly translations: TranslationsDTO) {} + private constructor( + private readonly locale: Locale, + private readonly translations: TranslationsDTO + ) {} - public static fromDTO = (translations: TranslationsDTO): TranslationRepository => - new TranslationRepository(translations); + public static fromDTO = (locale: Locale, translations: TranslationsDTO): TranslationRepository => + new TranslationRepository(locale, translations); public findOneByAddress(address: TranslationAddress): null | Translation { if (address.fullyQualified in this._translationsByAddress) { @@ -31,7 +36,7 @@ export class TranslationRepository { const translationDTO = this.translations[packageKey]?.[sourceName]?.[id] ?? null; const translation = translationDTO - ? Translation.fromDTO(translationDTO) + ? Translation.fromDTO(this.locale, translationDTO) : null; this._translationsByAddress[address.fullyQualified] = translation; @@ -53,7 +58,10 @@ export function registerTranslations(translations: TranslationsDTO): void { .becauseTranslationsHaveAlreadyBeenRegistered(); } - translationRepository = TranslationRepository.fromDTO(translations); + translationRepository = TranslationRepository.fromDTO( + getLocale(), + translations + ); } export class TranslationsCannotBeRegistered extends Error { diff --git a/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js b/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js index 715a18c5bc..6077bfaeb7 100644 --- a/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js +++ b/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js @@ -4,11 +4,12 @@ import {createStore} from 'redux'; import {mount} from 'enzyme'; import PropertyGroup from './index'; import {WrapWithMockGlobalRegistry} from '@neos-project/neos-ui-editors/src/_lib/testUtils'; -import {registerTranslations} from '@neos-project/neos-ui-i18n'; +import {registerLocale, registerTranslations} from '@neos-project/neos-ui-i18n'; const store = createStore(state => state, {}); beforeAll(() => { + registerLocale('en-US', 'one,other'); registerTranslations({ 'Neos_Neos': { 'Main': { From 2a1a0e9af1491a60fb0a3ab9a85d0b9777957b97 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 26 Jun 2024 12:20:32 +0200 Subject: [PATCH 26/39] FEATURE: Implement and expose API `translate` function --- packages/neos-ui-i18n/src/index.tsx | 2 + .../neos-ui-i18n/src/model/Locale.spec.ts | 8 +- packages/neos-ui-i18n/src/model/Locale.ts | 9 + packages/neos-ui-i18n/src/model/index.ts | 2 +- .../registry/TranslationRepository.spec.ts | 14 +- .../src/registry/TranslationRepository.ts | 9 + packages/neos-ui-i18n/src/registry/index.ts | 8 +- packages/neos-ui-i18n/src/translate.spec.ts | 314 ++++++++++++++++++ packages/neos-ui-i18n/src/translate.ts | 67 ++++ 9 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 packages/neos-ui-i18n/src/translate.spec.ts create mode 100644 packages/neos-ui-i18n/src/translate.ts diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx index a8b6b0481a..7ccc5d0a9a 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/index.tsx @@ -6,6 +6,8 @@ export {registerLocale} from './model'; export type {I18nRegistry} from './registry'; export {registerTranslations} from './registry'; +export {translate} from './translate'; + interface I18nProps { // Fallback key which gets rendered once the i18n service doesn't return a translation. fallback?: string; diff --git a/packages/neos-ui-i18n/src/model/Locale.spec.ts b/packages/neos-ui-i18n/src/model/Locale.spec.ts index 5dbac9fa84..d9bab78d6f 100644 --- a/packages/neos-ui-i18n/src/model/Locale.spec.ts +++ b/packages/neos-ui-i18n/src/model/Locale.spec.ts @@ -13,7 +13,8 @@ import { Locale, LocaleIsNotAvailable, LocaleCannotBeRegistered, - registerLocale + registerLocale, + unregisterLocale } from './Locale'; import {InvalidPluralRules} from './PluralRules'; @@ -77,5 +78,10 @@ describe('Locale', () => { LocaleCannotBeRegistered.becauseLocaleHasAlreadyBeenRegistered() ); }); + + test('unregisterLocale allows to run registerLocale again for testing purposes', () => { + unregisterLocale(); + expect(() => registerLocale('en-US', 'one,other')).not.toThrow(); + }); }); }); diff --git a/packages/neos-ui-i18n/src/model/Locale.ts b/packages/neos-ui-i18n/src/model/Locale.ts index e2591ac1a2..debc3b8c7c 100644 --- a/packages/neos-ui-i18n/src/model/Locale.ts +++ b/packages/neos-ui-i18n/src/model/Locale.ts @@ -83,6 +83,15 @@ export function registerLocale(identifier: string, pluralRulesAsString: string): locale = Locale.create(identifier, pluralRulesAsString); } +/** + * Unregisters the currently globally registered locale (if there is any) + * + * @internal For testing purposes only! + */ +export function unregisterLocale(): void { + locale = null; +} + export class LocaleCannotBeRegistered extends Error { private constructor(message: string) { super(`[Locale cannot be registered]: ${message}`); diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts index 4e3627b66a..b86799ae36 100644 --- a/packages/neos-ui-i18n/src/model/index.ts +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -7,4 +7,4 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -export {getLocale, Locale, registerLocale} from './Locale'; +export {getLocale, Locale, registerLocale, unregisterLocale} from './Locale'; diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts index e410d2aec3..32fb94f7a1 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts @@ -16,7 +16,8 @@ import { TranslationRepositoryIsNotAvailable, TranslationsCannotBeRegistered, getTranslationRepository, - registerTranslations + registerTranslations, + unregisterTranslations } from './TranslationRepository'; describe('TranslationRepository', () => { @@ -85,5 +86,16 @@ describe('TranslationRepository', () => { .becauseTranslationsHaveAlreadyBeenRegistered() ); }); + + test('unregisterTranslations allows to run registerTranslations again for testing purposes', () => { + unregisterTranslations(); + expect(() => registerTranslations({ + 'Neos_Neos': { // eslint-disable-line quote-props + 'Main': { // eslint-disable-line quote-props + 'someLabel': 'The Translation' // eslint-disable-line quote-props + } + } + })).not.toThrow(); + }); }); }); diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts index 3734c08d1d..91c56ac61f 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts @@ -64,6 +64,15 @@ export function registerTranslations(translations: TranslationsDTO): void { ); } +/** + * Unregisters the currently globally registered translations (if there are any) + * + * @internal For testing purposes only! + */ +export function unregisterTranslations(): void { + translationRepository = null; +} + export class TranslationsCannotBeRegistered extends Error { private constructor(message: string) { super(`[Translations cannot be registered]: ${message}`); diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts index 3ade324882..8429ee0f9c 100644 --- a/packages/neos-ui-i18n/src/registry/index.ts +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -12,4 +12,10 @@ export {i18nRegistry} from './I18nRegistry'; export type {Parameters} from './Parameters'; -export {registerTranslations} from './TranslationRepository'; +export {substitutePlaceholders} from './substitutePlaceholders'; + +export { + getTranslationRepository, + registerTranslations, + unregisterTranslations +} from './TranslationRepository'; diff --git a/packages/neos-ui-i18n/src/translate.spec.ts b/packages/neos-ui-i18n/src/translate.spec.ts new file mode 100644 index 0000000000..95c70db56e --- /dev/null +++ b/packages/neos-ui-i18n/src/translate.spec.ts @@ -0,0 +1,314 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {registerLocale, unregisterLocale} from './model'; +import {registerTranslations, unregisterTranslations} from './registry'; +import {translate} from './translate'; + +describe('translate', () => { + describe('when no translation was found', () => { + beforeAll(() => { + registerLocale('en-US', 'one,other'); + registerTranslations({}); + }); + afterAll(() => { + unregisterLocale(); + unregisterTranslations(); + }); + + it('returns given fallback', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', 'This is the fallback')) + .toBe('This is the fallback'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', 'This is another fallback')) + .toBe('This is another fallback'); + }); + + it('returns given "other" form of fallback when quantity = 0', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 0)) + .toBe('Plural Fallback'); + }); + + it('returns given "one" form of fallback when quantity = 1', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 1)) + .toBe('Singular Fallback'); + }); + + it('returns given "other" form of fallback when quantity > 1', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 2)) + .toBe('Plural Fallback'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 42)) + .toBe('Plural Fallback'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['Singular Fallback', 'Plural Fallback'], [], 24227)) + .toBe('Plural Fallback'); + }); + + it('substitutes numerical parameters in fallback string', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', 'This is {0} fallback with {1} parameters.', ['a', 'a few'])) + .toBe('This is a fallback with a few parameters.'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['This is a fallback with {0} parameter.', 'This is a fallback with {0} parameters.'], ['just one'], 1)) + .toBe('This is a fallback with just one parameter.'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['This is a fallback with {0} parameter.', 'This is a fallback with {0} parameters.'], ['one or more'], 2)) + .toBe('This is a fallback with one or more parameters.'); + }); + + it('substitutes named parameters in fallback string', () => { + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', 'This is {foo} fallback with {bar} parameters.', {foo: 'one', bar: 'a couple of'})) + .toBe('This is one fallback with a couple of parameters.'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['This is a fallback with {foo} parameter.', 'This is a fallback with {foo} parameters.'], {foo: 'just one'}, 1)) + .toBe('This is a fallback with just one parameter.'); + expect(translate('Unknown.Package:UnknownSource:unknown.trans-unit.id', ['This is a fallback with {foo} parameter.', 'This is a fallback with {foo} parameters.'], {foo: 'one or more'}, 2)) + .toBe('This is a fallback with one or more parameters.'); + }); + }); + + describe('when a translation was found', () => { + describe('in locale "en-US"', () => { + beforeAll(() => { + registerLocale('en-US', 'one,other'); + registerTranslations({ + 'Neos_Neos_Ui': { + 'Main': { + 'translation_without_plural_forms': + 'This is a translation without plural forms.', + 'translation_with_numerical_parameters': + 'This translation contains {0} {1} {2}.', + 'translation_with_named_parameters': + 'This translation contains {foo} {bar} {baz}.', + 'translation_with_plural_forms': [ + 'This is the "one" form of the translated string.', + 'This is the "other" form of the translated string.' + ], + 'translation_with_plural_forms_and_numerical_parameters': [ + 'This is the "one" form of a translation that contains {0} {1} {2}.', + 'This is the "other" form of a translation that contains {0} {1} {2}.' + ], + 'translation_with_plural_forms_and_named_parameters': [ + 'This is the "one" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "other" form of a translation that contains {foo} {bar} {baz}.' + ] + } + } + }); + }); + afterAll(() => { + unregisterLocale(); + unregisterTranslations(); + }); + + it('returns translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.without.plural.forms', 'This is the fallback')) + .toBe('This is a translation without plural forms.'); + }); + + it('substitutes numerical parameters in translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'])) + .toBe('This translation contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'})) + .toBe('This translation contains 3 named parameters.'); + }); + + describe('when quantity = 0', () => { + it('returns "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 0)) + .toBe('This is the "other" form of the translated string.'); + }); + + it('substitutes numerical parameters in "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 0)) + .toBe('This is the "other" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 0)) + .toBe('This is the "other" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity = 1', () => { + it('returns "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 1)) + .toBe('This is the "one" form of the translated string.'); + }); + + it('substitutes numerical parameters in "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 1)) + .toBe('This is the "one" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 1)) + .toBe('This is the "one" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity > 1', () => { + it('returns "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 23)) + .toBe('This is the "other" form of the translated string.'); + }); + + it('substitutes numerical parameters in "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 42)) + .toBe('This is the "other" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "other" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 274711)) + .toBe('This is the "other" form of a translation that contains 3 named parameters.'); + }); + }); + }); + + describe('in locale "ar-EG"', () => { + beforeAll(() => { + registerLocale('ar-EG', 'zero,one,two,few,many'); + registerTranslations({ + 'Neos_Neos_Ui': { + 'Main': { + 'translation_without_plural_forms': + 'This is a translation without plural forms.', + 'translation_with_numerical_parameters': + 'This translation contains {0} {1} {2}.', + 'translation_with_named_parameters': + 'This translation contains {foo} {bar} {baz}.', + 'translation_with_plural_forms': [ + 'This is the "zero" form of the translated string.', + 'This is the "one" form of the translated string.', + 'This is the "two" form of the translated string.', + 'This is the "few" form of the translated string.', + 'This is the "many" form of the translated string.' + ], + 'translation_with_plural_forms_and_numerical_parameters': [ + 'This is the "zero" form of a translation that contains {0} {1} {2}.', + 'This is the "one" form of a translation that contains {0} {1} {2}.', + 'This is the "two" form of a translation that contains {0} {1} {2}.', + 'This is the "few" form of a translation that contains {0} {1} {2}.', + 'This is the "many" form of a translation that contains {0} {1} {2}.' + ], + 'translation_with_plural_forms_and_named_parameters': [ + 'This is the "zero" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "one" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "two" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "few" form of a translation that contains {foo} {bar} {baz}.', + 'This is the "many" form of a translation that contains {foo} {bar} {baz}.' + ] + } + } + }); + }); + afterAll(() => { + unregisterLocale(); + unregisterTranslations(); + }); + + it('returns translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.without.plural.forms', 'This is the fallback')) + .toBe('This is a translation without plural forms.'); + }); + + it('substitutes numerical parameters in translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'])) + .toBe('This translation contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'})) + .toBe('This translation contains 3 named parameters.'); + }); + + describe('when quantity = 0', () => { + it('returns "zero" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 0)) + .toBe('This is the "zero" form of the translated string.'); + }); + + it('substitutes numerical parameters in "zero" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 0)) + .toBe('This is the "zero" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "zero" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 0)) + .toBe('This is the "zero" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity = 1', () => { + it('returns "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 1)) + .toBe('This is the "one" form of the translated string.'); + }); + + it('substitutes numerical parameters in "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 1)) + .toBe('This is the "one" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "one" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 1)) + .toBe('This is the "one" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity = 2', () => { + it('returns "two" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 2)) + .toBe('This is the "two" form of the translated string.'); + }); + + it('substitutes numerical parameters in "two" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 2)) + .toBe('This is the "two" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "two" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 2)) + .toBe('This is the "two" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity % 100 is between 3 and 10', () => { + it('returns "few" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 7)) + .toBe('This is the "few" form of the translated string.'); + }); + + it('substitutes numerical parameters in "few" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 108)) + .toBe('This is the "few" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "few" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 2005)) + .toBe('This is the "few" form of a translation that contains 3 named parameters.'); + }); + }); + + describe('when quantity % 100 is between 11 and 99', () => { + it('returns "many" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms', 'This is the fallback', [], 11)) + .toBe('This is the "many" form of the translated string.'); + }); + + it('substitutes numerical parameters in "many" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.numerical.parameters', 'This is the fallback', [3, 'numerical', 'parameters'], 112)) + .toBe('This is the "many" form of a translation that contains 3 numerical parameters.'); + }); + + it('substitutes named parameters in "many" form of translated string', () => { + expect(translate('Neos.Neos.Ui:Main:translation.with.plural.forms.and.named.parameters', 'This is the fallback', {foo: 3, bar: 'named', baz: 'parameters'}, 10099)) + .toBe('This is the "many" form of a translation that contains 3 named parameters.'); + }); + }); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/translate.ts b/packages/neos-ui-i18n/src/translate.ts new file mode 100644 index 0000000000..0dedc8b026 --- /dev/null +++ b/packages/neos-ui-i18n/src/translate.ts @@ -0,0 +1,67 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {getTranslationRepository, substitutePlaceholders} from './registry'; +import {TranslationAddress} from './registry/TranslationAddress'; + +/** + * Retrieves a the translation string that is identified by the given fully + * qualified translation address (a string following the pattern + * "{Package.Key:SourceName:actual.trans.unit.id}"), then the translation will + * be looked up in the respective package and *.xlf file. + * + * If no translation string can be found for the given address, the given + * fallback value will be returned. + * + * If a translation string was found and it contains substition placeholders + * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced + * with the corresponding values that were passed as parameters. + * + * Optionally, a quantity can be provided, which will then be used to determine + * a plural version of the translation string, within the plural rules set + * within the currently registered locale. + * + * @api + * @param {string} fullyQualifiedTranslationAddressAsString The translation address + * @param {string | [string, string]} fallback The string that shall be displayed, when no translation string could be found. If a tuple of two values is given, the first value will be treated as the singular, the second value as the plural form. + * @param {(string | number)[] | Record} [parameters] The values to replace substitution placeholders with in the translation string + * @param {quantity} [quantity] The key of the package in which to look for the translation file + */ +export function translate( + fullyQualifiedTranslationAddressAsString: string, + fallback: string | [string, string], + parameters: (string | number)[] | Record = [], + quantity: number = 0 +): string { + const translationRepository = getTranslationRepository(); + const translationAddress = TranslationAddress.fromString(fullyQualifiedTranslationAddressAsString); + const translation = translationRepository.findOneByAddress(translationAddress); + + if (translation === null) { + return renderFallback(fallback, quantity, parameters); + } + + return translation.render(parameters, quantity); +} + +function renderFallback( + fallback: string | [string, string], + quantity: number, + parameters: (string | number)[] | Record +) { + const fallbackHasPluralForms = Array.isArray(fallback); + let result: string; + if (fallbackHasPluralForms) { + result = quantity === 1 ? fallback[0] : fallback[1]; + } else { + result = fallback; + } + + return substitutePlaceholders(result, parameters); +} From ec05cfe0f73679ffb1d3b6a1b35c6f28d3a90c25 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 26 Jun 2024 14:28:35 +0200 Subject: [PATCH 27/39] TASK: Centralize setup of globals for @neos-project/neos-ui-i18n --- .../neos-ui-i18n/src/global/globals.spec.ts | 45 ++++++++++++ packages/neos-ui-i18n/src/global/globals.ts | 65 +++++++++++++++++ packages/neos-ui-i18n/src/global/index.ts | 12 ++++ .../neos-ui-i18n/src/global/setupI18n.spec.ts | 46 ++++++++++++ packages/neos-ui-i18n/src/global/setupI18n.ts | 38 ++++++++++ .../src/global/teardownI18n.spec.ts | 34 +++++++++ .../neos-ui-i18n/src/global/teardownI18n.ts | 20 ++++++ packages/neos-ui-i18n/src/index.tsx | 3 +- .../neos-ui-i18n/src/model/Locale.spec.ts | 39 +---------- packages/neos-ui-i18n/src/model/Locale.ts | 59 ---------------- packages/neos-ui-i18n/src/model/index.ts | 2 +- .../src/registry/I18nRegistry.spec.ts | 6 +- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 9 ++- .../registry/TranslationRepository.spec.ts | 70 +------------------ .../src/registry/TranslationRepository.ts | 63 +---------------- packages/neos-ui-i18n/src/registry/index.ts | 7 +- packages/neos-ui-i18n/src/translate.spec.ts | 22 +++--- packages/neos-ui-i18n/src/translate.ts | 6 +- .../Inspector/PropertyGroup/index.spec.js | 5 +- packages/neos-ui/src/index.js | 5 +- 20 files changed, 291 insertions(+), 265 deletions(-) create mode 100644 packages/neos-ui-i18n/src/global/globals.spec.ts create mode 100644 packages/neos-ui-i18n/src/global/globals.ts create mode 100644 packages/neos-ui-i18n/src/global/index.ts create mode 100644 packages/neos-ui-i18n/src/global/setupI18n.spec.ts create mode 100644 packages/neos-ui-i18n/src/global/setupI18n.ts create mode 100644 packages/neos-ui-i18n/src/global/teardownI18n.spec.ts create mode 100644 packages/neos-ui-i18n/src/global/teardownI18n.ts diff --git a/packages/neos-ui-i18n/src/global/globals.spec.ts b/packages/neos-ui-i18n/src/global/globals.spec.ts new file mode 100644 index 0000000000..8623ed1bb0 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/globals.spec.ts @@ -0,0 +1,45 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {GlobalsRuntimeContraintViolation, requireGlobals, setGlobals, unsetGlobals} from './globals'; + +describe('globals', () => { + afterEach(() => { + unsetGlobals(); + }); + + test('requireGlobals throws when globals are not initialized yet', () => { + expect(() => requireGlobals()) + .toThrow( + GlobalsRuntimeContraintViolation + .becauseGlobalsWereRequiredButHaveNotBeenSetYet() + ); + }); + + test('setGlobals sets the current globals ', () => { + setGlobals('foo' as any); + expect(requireGlobals()).toBe('foo'); + }); + + test('setGlobals throws if run multiple times', () => { + setGlobals('foo' as any); + expect(() => setGlobals('bar' as any)) + .toThrow( + GlobalsRuntimeContraintViolation + .becauseGlobalsWereAttemptedToBeSetMoreThanOnce() + ); + }); + + test('unsetGlobals allows to run setGlobals again', () => { + setGlobals('foo' as any); + unsetGlobals(); + setGlobals('bar' as any); + expect(requireGlobals()).toBe('bar'); + }); +}); diff --git a/packages/neos-ui-i18n/src/global/globals.ts b/packages/neos-ui-i18n/src/global/globals.ts new file mode 100644 index 0000000000..663c6d9e91 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/globals.ts @@ -0,0 +1,65 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale} from '../model'; +import {TranslationRepository} from '../registry'; + +export const globals = { + current: null as null | { + locale: Locale; + translationRepository: TranslationRepository; + } +}; + +export function requireGlobals(): NonNullable<(typeof globals)['current']> { + if (globals.current === null) { + throw GlobalsRuntimeContraintViolation + .becauseGlobalsWereRequiredButHaveNotBeenSetYet(); + } + + return globals.current; +} + +export function setGlobals(value: NonNullable<(typeof globals)['current']>) { + if (globals.current === null) { + globals.current = value; + return; + } + + throw GlobalsRuntimeContraintViolation + .becauseGlobalsWereAttemptedToBeSetMoreThanOnce(); +} + +export function unsetGlobals() { + globals.current = null; +} + +export class GlobalsRuntimeContraintViolation extends Error { + private constructor(message: string) { + super(message); + } + + public static becauseGlobalsWereRequiredButHaveNotBeenSetYet = () => + new GlobalsRuntimeContraintViolation( + 'Globals for "@neos-project/neos-ui-i18n" are not available,' + + ' because they have not been initialized yet. Make sure to run' + + ' `loadI18n` or `setupI18n` (for testing).' + ); + + public static becauseGlobalsWereAttemptedToBeSetMoreThanOnce = () => + new GlobalsRuntimeContraintViolation( + 'Globals for "@neos-project/neos-ui-i18n" have already been set. ' + + ' Make sure to only run one of `loadI18n` or `setupI18n` (for' + + ' testing). Neither function must ever be called more than' + + ' once, unless you are in a testing scenario. Then you are' + + ' allowed to run `teardownI18n` to reset the globals, after' + + ' which you can run `setupI18n` to test for a different set of' + + ' translations.' + ); +} diff --git a/packages/neos-ui-i18n/src/global/index.ts b/packages/neos-ui-i18n/src/global/index.ts new file mode 100644 index 0000000000..d4e42c6df6 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/index.ts @@ -0,0 +1,12 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {requireGlobals} from './globals'; +export {setupI18n} from './setupI18n'; +export {teardownI18n} from './teardownI18n'; diff --git a/packages/neos-ui-i18n/src/global/setupI18n.spec.ts b/packages/neos-ui-i18n/src/global/setupI18n.spec.ts new file mode 100644 index 0000000000..88afcd9ca3 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/setupI18n.spec.ts @@ -0,0 +1,46 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale} from '../model'; +import {TranslationRepository} from '../registry/TranslationRepository'; +import {requireGlobals, unsetGlobals} from './globals'; +import {setupI18n} from './setupI18n'; + +describe('setupI18n', () => { + afterEach(() => { + unsetGlobals(); + }); + + it('registers a global locale and sets up a global translation repository', () => { + setupI18n('en-US', 'one,other', { + 'Neos_Neos_Ui': { + 'Main': { + 'trans-unit_id': 'Some Translation' + } + } + }); + + const {locale, translationRepository} = requireGlobals(); + + expect(locale).toStrictEqual(Locale.create('en-US', 'one,other')); + expect(translationRepository) + .toStrictEqual( + TranslationRepository.fromDTO( + Locale.create('en-US', 'one,other'), + { + 'Neos_Neos_Ui': { + 'Main': { + 'trans-unit_id': 'Some Translation' + } + } + } + ) + ); + }); +}); diff --git a/packages/neos-ui-i18n/src/global/setupI18n.ts b/packages/neos-ui-i18n/src/global/setupI18n.ts new file mode 100644 index 0000000000..4150a91d0d --- /dev/null +++ b/packages/neos-ui-i18n/src/global/setupI18n.ts @@ -0,0 +1,38 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {Locale} from '../model'; +import {TranslationRepository, TranslationsDTO} from '../registry/TranslationRepository'; + +import {setGlobals} from './globals'; + +/** + * Sets up the application-wide globals for translation. + * + * You may use this function for setting up translations in a testing scenario. + * Make sure to run teardownI18n to clean up the globals after your testing + * scenario is finished. + * + * @param {string} localeIdentifier The locale identifier (e.g. "en-US") + * @param {string} pluralRulesAsString Comma-separated list of plural rules (each one of: "zero", "one", "two", "few", "many" or "other") + * @param {TranslationsDTO} translations The translations as provided by the /neos/xliff.json endpoint + */ +export function setupI18n( + localeIdentifier: string, + pluralRulesAsString: string, + translations: TranslationsDTO +): void { + const locale = Locale.create(localeIdentifier, pluralRulesAsString); + const translationRepository = TranslationRepository.fromDTO( + locale, + translations + ); + + setGlobals({locale, translationRepository}); +} diff --git a/packages/neos-ui-i18n/src/global/teardownI18n.spec.ts b/packages/neos-ui-i18n/src/global/teardownI18n.spec.ts new file mode 100644 index 0000000000..a0cc21b88d --- /dev/null +++ b/packages/neos-ui-i18n/src/global/teardownI18n.spec.ts @@ -0,0 +1,34 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {globals, unsetGlobals} from './globals'; +import {setupI18n} from './setupI18n'; +import {teardownI18n} from './teardownI18n'; + +describe('teardownI18n', () => { + afterEach(() => { + unsetGlobals(); + }); + + it('unsets the previously registered locale and translation repository', () => { + setupI18n('en-US', 'one,other', { + 'Neos_Neos_Ui': { + 'Main': { + 'trans-unit_id': 'Some Translation' + } + } + }); + + expect(globals.current).not.toBeNull(); + + teardownI18n(); + + expect(globals.current).toBeNull(); + }); +}); diff --git a/packages/neos-ui-i18n/src/global/teardownI18n.ts b/packages/neos-ui-i18n/src/global/teardownI18n.ts new file mode 100644 index 0000000000..cc3c557a96 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/teardownI18n.ts @@ -0,0 +1,20 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {unsetGlobals} from './globals'; + +/** + * Unsets the previously registered locale and translations + * + * You may use this function for cleaning up after running setupI18n in a + * testing scenario. + */ +export function teardownI18n(): void { + unsetGlobals(); +} diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx index 7ccc5d0a9a..0c850c06fc 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/index.tsx @@ -1,10 +1,9 @@ import React from 'react'; import {Parameters, i18nRegistry} from './registry'; -export {registerLocale} from './model'; +export {setupI18n, teardownI18n} from './global'; export type {I18nRegistry} from './registry'; -export {registerTranslations} from './registry'; export {translate} from './translate'; diff --git a/packages/neos-ui-i18n/src/model/Locale.spec.ts b/packages/neos-ui-i18n/src/model/Locale.spec.ts index d9bab78d6f..be42536117 100644 --- a/packages/neos-ui-i18n/src/model/Locale.spec.ts +++ b/packages/neos-ui-i18n/src/model/Locale.spec.ts @@ -7,15 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import { - getLocale, - InvalidLocale, - Locale, - LocaleIsNotAvailable, - LocaleCannotBeRegistered, - registerLocale, - unregisterLocale -} from './Locale'; +import {InvalidLocale, Locale} from './Locale'; import {InvalidPluralRules} from './PluralRules'; describe('Locale', () => { @@ -55,33 +47,4 @@ describe('Locale', () => { .toBe(4); }); }); - - describe('singleton', () => { - test('getLocale throws if called before locale has been registered', () => { - expect(() => getLocale()).toThrow( - LocaleIsNotAvailable.becauseLocaleHasNotBeenRegisteredYet() - ); - }); - - test('getLocale returns the singleton Locale instance after locale has been registered', () => { - registerLocale('en-US', 'one,other'); - - expect(getLocale()).toStrictEqual( - Locale.create('en-US', 'one,other') - ); - - expect(getLocale()).toBe(getLocale()); - }); - - test('registerLocale throws if called more than once', () => { - expect(() => registerLocale('en-US', 'one,other')).toThrow( - LocaleCannotBeRegistered.becauseLocaleHasAlreadyBeenRegistered() - ); - }); - - test('unregisterLocale allows to run registerLocale again for testing purposes', () => { - unregisterLocale(); - expect(() => registerLocale('en-US', 'one,other')).not.toThrow(); - }); - }); }); diff --git a/packages/neos-ui-i18n/src/model/Locale.ts b/packages/neos-ui-i18n/src/model/Locale.ts index debc3b8c7c..72a7a910ed 100644 --- a/packages/neos-ui-i18n/src/model/Locale.ts +++ b/packages/neos-ui-i18n/src/model/Locale.ts @@ -64,62 +64,3 @@ export class InvalidLocale extends Error { public static becauseOfInvalidPluralRules = (identifier: string, cause: InvalidPluralRules): InvalidLocale => new InvalidLocale(`Locale "${identifier}" could not be initialized because of invalid plural forms: ${cause.message}`, cause); } - -let locale: null | Locale = null; - -/** - * Registers the given locale globally for use throughout the application - * - * @internal For use in the Neos UI application bootstrapping process only! - * @param {string} identifier - * @param {string} pluralRulesAsString - */ -export function registerLocale(identifier: string, pluralRulesAsString: string): void { - if (locale !== null) { - throw LocaleCannotBeRegistered - .becauseLocaleHasAlreadyBeenRegistered(); - } - - locale = Locale.create(identifier, pluralRulesAsString); -} - -/** - * Unregisters the currently globally registered locale (if there is any) - * - * @internal For testing purposes only! - */ -export function unregisterLocale(): void { - locale = null; -} - -export class LocaleCannotBeRegistered extends Error { - private constructor(message: string) { - super(`[Locale cannot be registered]: ${message}`); - } - - public static becauseLocaleHasAlreadyBeenRegistered = () => - new LocaleCannotBeRegistered( - 'Locale can only be registered once, and has already been registered.' - ); -} - -export function getLocale(): Locale { - if (locale === null) { - throw LocaleIsNotAvailable - .becauseLocaleHasNotBeenRegisteredYet(); - } - - return locale; -} - -export class LocaleIsNotAvailable extends Error { - private constructor(message: string) { - super(`[Locale is not available]: ${message}`); - } - - public static becauseLocaleHasNotBeenRegisteredYet = () => - new LocaleIsNotAvailable( - 'Locale has not been registered yet. Make sure to call' - + ' `registerLocale` during the application bootstrapping process.' - ); -} diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts index b86799ae36..2df64b4aed 100644 --- a/packages/neos-ui-i18n/src/model/index.ts +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -7,4 +7,4 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -export {getLocale, Locale, registerLocale, unregisterLocale} from './Locale'; +export {Locale} from './Locale'; diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts index c32cc650a8..0c926dd8bb 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts @@ -7,14 +7,12 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {registerLocale} from '../model'; +import {setupI18n} from '../global'; import {I18nRegistry} from './I18nRegistry'; -import {registerTranslations} from './TranslationRepository'; beforeAll(() => { - registerLocale('en-US', 'one,other'); - registerTranslations({ + setupI18n('en-US', 'one,other', { 'Neos_Neos': { // eslint-disable-line quote-props 'Main': { // eslint-disable-line quote-props 'someLabel': 'The Translation', // eslint-disable-line quote-props diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index ab0ed2ecd0..9883458f19 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -11,11 +11,12 @@ import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/regis import logger from '@neos-project/utils-logger'; +import {requireGlobals} from '../global'; + import {getTranslationAddress} from './getTranslationAddress'; import type {Translation} from './Translation'; import type {TranslationAddress} from './TranslationAddress'; import type {Parameters} from './Parameters'; -import {getTranslationRepository} from './TranslationRepository'; const errorCache: Record = {}; @@ -187,13 +188,15 @@ export class I18nRegistry extends SynchronousRegistry { private logTranslationNotFound(address: TranslationAddress, fallback: string) { if (!errorCache[address.fullyQualified]) { - logger.error(`No translation found for id "${address.fullyQualified}" in:`, getTranslationRepository(), `Using ${fallback} instead.`); + const {translationRepository} = requireGlobals(); + logger.error(`No translation found for id "${address.fullyQualified}" in:`, translationRepository, `Using ${fallback} instead.`); errorCache[address.fullyQualified] = true; } } private getTranslation(address: TranslationAddress): null | Translation { - return getTranslationRepository().findOneByAddress(address) ?? null; + const {translationRepository} = requireGlobals(); + return translationRepository.findOneByAddress(address) ?? null; } } diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts index 32fb94f7a1..26cdd32584 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts @@ -7,18 +7,11 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale, registerLocale} from '../model'; +import {Locale} from '../model'; import {TranslationAddress} from './TranslationAddress'; import {Translation} from './Translation'; -import { - TranslationRepository, - TranslationRepositoryIsNotAvailable, - TranslationsCannotBeRegistered, - getTranslationRepository, - registerTranslations, - unregisterTranslations -} from './TranslationRepository'; +import {TranslationRepository} from './TranslationRepository'; describe('TranslationRepository', () => { const locale_en_US = Locale.create('en-US', 'one,other'); @@ -39,63 +32,4 @@ describe('TranslationRepository', () => { expect(translationRepository.findOneByAddress(translationAddressThatCanBeFound)) .toStrictEqual(Translation.fromDTO(locale_en_US, 'The Translation')); }); - - describe('singleton', () => { - test('getTranslationRepository throws if called before translations have been registered', () => { - expect(() => getTranslationRepository()) - .toThrow( - TranslationRepositoryIsNotAvailable - .becauseTranslationsHaveNotBeenRegisteredYet() - ); - }); - - test('getTranslationRepository returns the singleton TranslationRepository instance after translations have been registered', () => { - registerLocale('en-US', 'one,other'); - registerTranslations({ - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation' // eslint-disable-line quote-props - } - } - }); - - expect(getTranslationRepository()) - .toStrictEqual( - TranslationRepository.fromDTO(locale_en_US, { - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation' // eslint-disable-line quote-props - } - } - }) - ); - - expect(getTranslationRepository()) - .toBe(getTranslationRepository()); - }); - - test('registerTranslations throws if called more than once', () => { - expect(() => registerTranslations({ - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation' // eslint-disable-line quote-props - } - } - })).toThrow( - TranslationsCannotBeRegistered - .becauseTranslationsHaveAlreadyBeenRegistered() - ); - }); - - test('unregisterTranslations allows to run registerTranslations again for testing purposes', () => { - unregisterTranslations(); - expect(() => registerTranslations({ - 'Neos_Neos': { // eslint-disable-line quote-props - 'Main': { // eslint-disable-line quote-props - 'someLabel': 'The Translation' // eslint-disable-line quote-props - } - } - })).not.toThrow(); - }); - }); }); diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts index 91c56ac61f..200c02bfe4 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts @@ -7,7 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale, getLocale} from '../model'; +import {Locale} from '../model'; import type {TranslationAddress} from './TranslationAddress'; import {Translation, TranslationDTO} from './Translation'; @@ -43,64 +43,3 @@ export class TranslationRepository { return translation; } } - -let translationRepository: null | TranslationRepository = null; - -/** - * Registers the given translations globally for use throughout the application - * - * @internal For use in the Neos UI application bootstrapping process only! - * @param {TranslationsDTO} translations - */ -export function registerTranslations(translations: TranslationsDTO): void { - if (translationRepository !== null) { - throw TranslationsCannotBeRegistered - .becauseTranslationsHaveAlreadyBeenRegistered(); - } - - translationRepository = TranslationRepository.fromDTO( - getLocale(), - translations - ); -} - -/** - * Unregisters the currently globally registered translations (if there are any) - * - * @internal For testing purposes only! - */ -export function unregisterTranslations(): void { - translationRepository = null; -} - -export class TranslationsCannotBeRegistered extends Error { - private constructor(message: string) { - super(`[Translations cannot be registered]: ${message}`); - } - - public static becauseTranslationsHaveAlreadyBeenRegistered = () => - new TranslationsCannotBeRegistered( - 'Translations can only be registered once, and have already been registered.' - ); -} - -export function getTranslationRepository(): TranslationRepository { - if (translationRepository === null) { - throw TranslationRepositoryIsNotAvailable - .becauseTranslationsHaveNotBeenRegisteredYet(); - } - - return translationRepository; -} - -export class TranslationRepositoryIsNotAvailable extends Error { - private constructor(message: string) { - super(`[TranslationRepository is not available]: ${message}`); - } - - public static becauseTranslationsHaveNotBeenRegisteredYet = () => - new TranslationRepositoryIsNotAvailable( - 'Translations have not been registered yet. Make sure to call' - + ' `registerTranslations` during the application bootstrapping process.' - ); -} diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts index 8429ee0f9c..92bc20d699 100644 --- a/packages/neos-ui-i18n/src/registry/index.ts +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -14,8 +14,5 @@ export type {Parameters} from './Parameters'; export {substitutePlaceholders} from './substitutePlaceholders'; -export { - getTranslationRepository, - registerTranslations, - unregisterTranslations -} from './TranslationRepository'; +export {TranslationAddress} from './TranslationAddress'; +export {TranslationRepository} from './TranslationRepository'; diff --git a/packages/neos-ui-i18n/src/translate.spec.ts b/packages/neos-ui-i18n/src/translate.spec.ts index 95c70db56e..a25e62472c 100644 --- a/packages/neos-ui-i18n/src/translate.spec.ts +++ b/packages/neos-ui-i18n/src/translate.spec.ts @@ -7,19 +7,17 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {registerLocale, unregisterLocale} from './model'; -import {registerTranslations, unregisterTranslations} from './registry'; +import {setupI18n, teardownI18n} from './global'; import {translate} from './translate'; +/* eslint-disable max-nested-callbacks */ describe('translate', () => { describe('when no translation was found', () => { beforeAll(() => { - registerLocale('en-US', 'one,other'); - registerTranslations({}); + setupI18n('en-US', 'one,other', {}); }); afterAll(() => { - unregisterLocale(); - unregisterTranslations(); + teardownI18n(); }); it('returns given fallback', () => { @@ -70,8 +68,7 @@ describe('translate', () => { describe('when a translation was found', () => { describe('in locale "en-US"', () => { beforeAll(() => { - registerLocale('en-US', 'one,other'); - registerTranslations({ + setupI18n('en-US', 'one,other', { 'Neos_Neos_Ui': { 'Main': { 'translation_without_plural_forms': @@ -97,8 +94,7 @@ describe('translate', () => { }); }); afterAll(() => { - unregisterLocale(); - unregisterTranslations(); + teardownI18n(); }); it('returns translated string', () => { @@ -170,8 +166,7 @@ describe('translate', () => { describe('in locale "ar-EG"', () => { beforeAll(() => { - registerLocale('ar-EG', 'zero,one,two,few,many'); - registerTranslations({ + setupI18n('ar-EG', 'zero,one,two,few,many', { 'Neos_Neos_Ui': { 'Main': { 'translation_without_plural_forms': @@ -206,8 +201,7 @@ describe('translate', () => { }); }); afterAll(() => { - unregisterLocale(); - unregisterTranslations(); + teardownI18n(); }); it('returns translated string', () => { diff --git a/packages/neos-ui-i18n/src/translate.ts b/packages/neos-ui-i18n/src/translate.ts index 0dedc8b026..aa63c325b2 100644 --- a/packages/neos-ui-i18n/src/translate.ts +++ b/packages/neos-ui-i18n/src/translate.ts @@ -7,8 +7,8 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {getTranslationRepository, substitutePlaceholders} from './registry'; -import {TranslationAddress} from './registry/TranslationAddress'; +import {requireGlobals} from './global'; +import {substitutePlaceholders, TranslationAddress} from './registry'; /** * Retrieves a the translation string that is identified by the given fully @@ -39,7 +39,7 @@ export function translate( parameters: (string | number)[] | Record = [], quantity: number = 0 ): string { - const translationRepository = getTranslationRepository(); + const {translationRepository} = requireGlobals(); const translationAddress = TranslationAddress.fromString(fullyQualifiedTranslationAddressAsString); const translation = translationRepository.findOneByAddress(translationAddress); diff --git a/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js b/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js index 6077bfaeb7..2b05c5058e 100644 --- a/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js +++ b/packages/neos-ui/src/Containers/RightSideBar/Inspector/PropertyGroup/index.spec.js @@ -4,13 +4,12 @@ import {createStore} from 'redux'; import {mount} from 'enzyme'; import PropertyGroup from './index'; import {WrapWithMockGlobalRegistry} from '@neos-project/neos-ui-editors/src/_lib/testUtils'; -import {registerLocale, registerTranslations} from '@neos-project/neos-ui-i18n'; +import {setupI18n} from '@neos-project/neos-ui-i18n'; const store = createStore(state => state, {}); beforeAll(() => { - registerLocale('en-US', 'one,other'); - registerTranslations({ + setupI18n('en-US', 'one,other', { 'Neos_Neos': { 'Main': { 'Foo group': 'Foo group' diff --git a/packages/neos-ui/src/index.js b/packages/neos-ui/src/index.js index 1c7f0b3662..553399f791 100644 --- a/packages/neos-ui/src/index.js +++ b/packages/neos-ui/src/index.js @@ -10,8 +10,8 @@ import fetchWithErrorHandling from '@neos-project/neos-ui-backend-connector/src/ import {SynchronousMetaRegistry} from '@neos-project/neos-ui-extensibility/src/registry'; import backend from '@neos-project/neos-ui-backend-connector'; import {handleActions} from '@neos-project/utils-redux'; +import {setupI18n} from '@neos-project/neos-ui-i18n'; import {showFlashMessage} from '@neos-project/neos-ui-error'; -import {registerLocale, registerTranslations} from '@neos-project/neos-ui-i18n'; import { appContainer, @@ -177,8 +177,7 @@ async function loadTranslations() { const endpoint = link.getAttribute('href'); const translations = await getJsonResource(endpoint); - registerLocale(link.dataset.locale, link.dataset.localePluralRules); - registerTranslations(translations); + setupI18n(link.dataset.locale, link.dataset.localePluralRules, translations); } async function loadImpersonateStatus() { From e4410944b41b0c5a84253b0db9988b09c817a69e Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 27 Jun 2024 13:59:50 +0200 Subject: [PATCH 28/39] TASK: Implement, expose and use `initializeI18n` --- ...-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip | Bin 0 -> 30760 bytes ...-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip | Bin 0 -> 47935 bytes package.json | 1 + .../src/setupBrowserEnv.js | 3 +- packages/neos-ui-i18n/src/global/index.ts | 1 + .../src/global/initializeI18n.spec.ts | 196 ++++++++++++++++++ .../neos-ui-i18n/src/global/initializeI18n.ts | 113 ++++++++++ packages/neos-ui-i18n/src/index.tsx | 2 +- packages/neos-ui/src/index.js | 13 +- yarn.lock | 24 +++ 10 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 .yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip create mode 100644 .yarn/cache/node-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip create mode 100644 packages/neos-ui-i18n/src/global/initializeI18n.spec.ts create mode 100644 packages/neos-ui-i18n/src/global/initializeI18n.ts diff --git a/.yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip b/.yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip new file mode 100644 index 0000000000000000000000000000000000000000..bddbef91a20ac444aa633999625641f72f7d327b GIT binary patch literal 30760 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≤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 Wl>i0%sT;D1Y@nE?C literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 162b50448e..639c971b78 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@neos-project/eslint-config-neos": "^2.6.1", "@typescript-eslint/eslint-plugin": "^5.44.0", "@typescript-eslint/parser": "^5.44.0", + "cross-fetch": "^4.0.0", "editorconfig-checker": "^4.0.2", "esbuild": "~0.17.0", "eslint": "^8.27.0", diff --git a/packages/jest-preset-neos-ui/src/setupBrowserEnv.js b/packages/jest-preset-neos-ui/src/setupBrowserEnv.js index 792b4ae476..c1d08efc5c 100644 --- a/packages/jest-preset-neos-ui/src/setupBrowserEnv.js +++ b/packages/jest-preset-neos-ui/src/setupBrowserEnv.js @@ -1,6 +1,5 @@ import 'regenerator-runtime/runtime'; import browserEnv from 'browser-env'; +import 'cross-fetch/polyfill'; browserEnv(); - -window.fetch = () => Promise.resolve(null); diff --git a/packages/neos-ui-i18n/src/global/index.ts b/packages/neos-ui-i18n/src/global/index.ts index d4e42c6df6..6250e8b255 100644 --- a/packages/neos-ui-i18n/src/global/index.ts +++ b/packages/neos-ui-i18n/src/global/index.ts @@ -7,6 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ +export {initializeI18n} from './initializeI18n'; export {requireGlobals} from './globals'; export {setupI18n} from './setupI18n'; export {teardownI18n} from './teardownI18n'; diff --git a/packages/neos-ui-i18n/src/global/initializeI18n.spec.ts b/packages/neos-ui-i18n/src/global/initializeI18n.spec.ts new file mode 100644 index 0000000000..5c1a634de4 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/initializeI18n.spec.ts @@ -0,0 +1,196 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {translate} from '../translate'; + +import {I18nCouldNotBeInitialized, initializeI18n} from './initializeI18n'; +import {teardownI18n} from './teardownI18n'; + +describe('initializeI18n', () => { + beforeEach(() => { + const server: typeof fetch = (input, init) => { + expect(init?.credentials).toBe('include'); + + const request = new Request(input, init); + const url = new URL(request.url); + + switch (url.pathname) { + case '/neos/xliff.json': + return Promise.resolve( + new Response(JSON.stringify({ + Neos_Neos_Ui: { + Main: { + 'some_trans-unit_id': + 'This is the translation' + } + } + }), {headers: {'Content-Type': 'application/json'}}) + ); + default: + return Promise.resolve(Response.error()); + } + }; + jest.spyOn(global, 'fetch' as any).mockImplementation(server as any); + }); + + afterEach(() => { + jest.resetAllMocks(); + teardownI18n(); + }); + + it('loads the translation from the location specified in the current HTML document', async () => { + document.head.innerHTML = ` + + `; + + await initializeI18n(); + + expect(translate('Neos.Neos.Ui:Main:some.trans-unit.id', 'This is the fallback')) + .toBe('This is the translation'); + }); + + it('rejects when i18n route link cannot be found', () => { + // no tag at all + document.head.innerHTML = ''; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkCouldNotBeFound() + ); + + // link tag, but id is missing + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkCouldNotBeFound() + ); + + // metag tag instead of link tag + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkCouldNotBeFound() + ); + }); + + it('rejects when i18n route link has no "href" attribute', () => { + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkHasNoHref() + ); + }); + + it('rejects when i18n route link does not provide a valid URL has "href"', () => { + // empty + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized + .becauseRouteLinkHrefIsNotAValidURL('') + ); + + // not a URL at all + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized + .becauseRouteLinkHrefIsNotAValidURL('something something') + ); + + // relative URL instead of absolute + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized + .becauseRouteLinkHrefIsNotAValidURL('/neos/xliff.json?locale=en-US') + ); + }); + + it('rejects when i18n route link has no "data-locale" attribute', () => { + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkHasNoLocale() + ); + }); + + it('rejects when i18n route link has no "data-locale-plural-rules" attribute', () => { + document.head.innerHTML = ` + + `; + + expect(() => initializeI18n()) + .rejects.toThrow( + I18nCouldNotBeInitialized.becauseRouteLinkHasNoPluralRules() + ); + }); +}); diff --git a/packages/neos-ui-i18n/src/global/initializeI18n.ts b/packages/neos-ui-i18n/src/global/initializeI18n.ts new file mode 100644 index 0000000000..eb428c68ba --- /dev/null +++ b/packages/neos-ui-i18n/src/global/initializeI18n.ts @@ -0,0 +1,113 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {setupI18n} from './setupI18n'; + +const LINK_ID_FOR_I18N_ROUTE = 'neos-ui-uri:/neos/xliff.json'; + +/** + * @summary Initializes the Neos UI i18n mechanism globally + * @description + * Given a prepared HTML document that contains a -tag with the id + * "neos-ui-uri:/neos/xliff.json", this function will load translations from + * the server endpoint specified in that tag's "href"-attribute. + * + * It will then set up the Neos UI i18n mechanism globally, with the locale + * provided in the -tag's "data-locale"-attribute, and the plural rule in + * the order specified in the "data-locale-plural-rules"-attribute. + */ +export async function initializeI18n(): Promise { + const link = getLinkTag(); + const href = getHrefFromLinkTag(link); + const locale = getLocaleFromLinkTag(link); + const pluralRules = getPluralRulesFromLinkTag(link); + + const response = await fetch(href.toString(), {credentials: 'include'}); + const translations = await response.json(); + + setupI18n(locale, pluralRules, translations); +} + +function getPluralRulesFromLinkTag(link: HTMLLinkElement) { + const pluralRules = link?.dataset.localePluralRules; + if (pluralRules === undefined) { + throw I18nCouldNotBeInitialized + .becauseRouteLinkHasNoPluralRules(); + } + return pluralRules; +} + +function getLocaleFromLinkTag(link: HTMLLinkElement) { + const locale = link?.dataset.locale; + if (locale === undefined) { + throw I18nCouldNotBeInitialized + .becauseRouteLinkHasNoLocale(); + } + return locale; +} + +function getLinkTag() { + const link = document.getElementById(LINK_ID_FOR_I18N_ROUTE); + if (link === null || !(link instanceof HTMLLinkElement)) { + throw I18nCouldNotBeInitialized + .becauseRouteLinkCouldNotBeFound(); + } + return link; +} + +function getHrefFromLinkTag(link: HTMLLinkElement): URL { + const href = link?.getAttribute('href'); + if (href === null) { + throw I18nCouldNotBeInitialized + .becauseRouteLinkHasNoHref(); + } + + try { + return new URL(href); + } catch { + throw I18nCouldNotBeInitialized + .becauseRouteLinkHrefIsNotAValidURL(href); + } +} + +export class I18nCouldNotBeInitialized extends Error { + private constructor(message: string) { + super(`I18n could not be initialized, because ${message}`); + } + + public static becauseRouteLinkCouldNotBeFound = () => + new I18nCouldNotBeInitialized( + `this document has no -Tag with id "${LINK_ID_FOR_I18N_ROUTE}".` + ); + + public static becauseRouteLinkHasNoHref = () => + new I18nCouldNotBeInitialized( + `the found -Tag with id "${LINK_ID_FOR_I18N_ROUTE}" is` + + ` missing an "href"-attribute.` + ); + + public static becauseRouteLinkHrefIsNotAValidURL = (attemptedValue: string) => + new I18nCouldNotBeInitialized( + `the "href"-attribute of the -Tag with id "${LINK_ID_FOR_I18N_ROUTE}"` + + ` must be a valid, absolute URL, but was "${attemptedValue}".` + ); + + public static becauseRouteLinkHasNoLocale = () => + new I18nCouldNotBeInitialized( + `the found -Tag with id "${LINK_ID_FOR_I18N_ROUTE}" is` + + ` missing a "data-locale"-attribute.` + ); + + public static becauseRouteLinkHasNoPluralRules = () => + new I18nCouldNotBeInitialized( + `the found -Tag with id "${LINK_ID_FOR_I18N_ROUTE}" is` + + ` missing a "data-locale-plural-rules"-attribute.` + ); +} + diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx index 0c850c06fc..920c122de0 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {Parameters, i18nRegistry} from './registry'; -export {setupI18n, teardownI18n} from './global'; +export {initializeI18n, setupI18n, teardownI18n} from './global'; export type {I18nRegistry} from './registry'; diff --git a/packages/neos-ui/src/index.js b/packages/neos-ui/src/index.js index 553399f791..e1b7a97c10 100644 --- a/packages/neos-ui/src/index.js +++ b/packages/neos-ui/src/index.js @@ -10,7 +10,7 @@ import fetchWithErrorHandling from '@neos-project/neos-ui-backend-connector/src/ import {SynchronousMetaRegistry} from '@neos-project/neos-ui-extensibility/src/registry'; import backend from '@neos-project/neos-ui-backend-connector'; import {handleActions} from '@neos-project/utils-redux'; -import {setupI18n} from '@neos-project/neos-ui-i18n'; +import {initializeI18n} from '@neos-project/neos-ui-i18n'; import {showFlashMessage} from '@neos-project/neos-ui-error'; import { @@ -65,7 +65,7 @@ async function main() { await Promise.all([ loadNodeTypesSchema(), - loadTranslations(), + initializeI18n(), loadImpersonateStatus() ]); @@ -171,15 +171,6 @@ async function loadNodeTypesSchema() { nodeTypesRegistry.setRoles(roles); } -async function loadTranslations() { - const {getJsonResource} = backend.get().endpoints; - const link = document.getElementById('neos-ui-uri:/neos/xliff.json'); - const endpoint = link.getAttribute('href'); - const translations = await getJsonResource(endpoint); - - setupI18n(link.dataset.locale, link.dataset.localePluralRules, translations); -} - async function loadImpersonateStatus() { try { const {impersonateStatus} = backend.get().endpoints; diff --git a/yarn.lock b/yarn.lock index c7aebfae23..a1d3d591f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6628,6 +6628,15 @@ __metadata: languageName: node linkType: hard +"cross-fetch@npm:^4.0.0": + version: 4.0.0 + resolution: "cross-fetch@npm:4.0.0" + dependencies: + node-fetch: ^2.6.12 + checksum: ecca4f37ffa0e8283e7a8a590926b66713a7ef7892757aa36c2d20ffa27b0ac5c60dcf453119c809abe5923fc0bae3702a4d896bfb406ef1077b0d0018213e24 + languageName: node + linkType: hard + "cross-spawn@npm:^6.0.5": version: 6.0.5 resolution: "cross-spawn@npm:6.0.5" @@ -12221,6 +12230,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.12": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: ^5.0.0 + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: d76d2f5edb451a3f05b15115ec89fc6be39de37c6089f1b6368df03b91e1633fd379a7e01b7ab05089a25034b2023d959b47e59759cb38d88341b2459e89d6e5 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 9.0.0 resolution: "node-gyp@npm:9.0.0" @@ -14354,6 +14377,7 @@ __metadata: "@neos-project/eslint-config-neos": ^2.6.1 "@typescript-eslint/eslint-plugin": ^5.44.0 "@typescript-eslint/parser": ^5.44.0 + cross-fetch: ^4.0.0 editorconfig-checker: ^4.0.2 esbuild: ~0.17.0 eslint: ^8.27.0 From ecf1229f22d1d3964f48a541038ced9d6b9470e6 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 27 Jun 2024 14:25:44 +0200 Subject: [PATCH 29/39] TASK: Move `Translation` into `model` module --- .../src/{registry => model}/Translation.spec.ts | 3 +-- .../neos-ui-i18n/src/{registry => model}/Translation.ts | 7 ++++--- packages/neos-ui-i18n/src/model/index.ts | 1 + packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 2 +- .../src/registry/TranslationRepository.spec.ts | 3 +-- .../neos-ui-i18n/src/registry/TranslationRepository.ts | 3 +-- 6 files changed, 9 insertions(+), 10 deletions(-) rename packages/neos-ui-i18n/src/{registry => model}/Translation.spec.ts (99%) rename packages/neos-ui-i18n/src/{registry => model}/Translation.ts (89%) diff --git a/packages/neos-ui-i18n/src/registry/Translation.spec.ts b/packages/neos-ui-i18n/src/model/Translation.spec.ts similarity index 99% rename from packages/neos-ui-i18n/src/registry/Translation.spec.ts rename to packages/neos-ui-i18n/src/model/Translation.spec.ts index 45182fca4c..04cf77b92a 100644 --- a/packages/neos-ui-i18n/src/registry/Translation.spec.ts +++ b/packages/neos-ui-i18n/src/model/Translation.spec.ts @@ -7,8 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale} from '../model'; - +import {Locale} from './Locale'; import {Translation} from './Translation'; describe('Translation', () => { diff --git a/packages/neos-ui-i18n/src/registry/Translation.ts b/packages/neos-ui-i18n/src/model/Translation.ts similarity index 89% rename from packages/neos-ui-i18n/src/registry/Translation.ts rename to packages/neos-ui-i18n/src/model/Translation.ts index d113904937..a04daeb157 100644 --- a/packages/neos-ui-i18n/src/registry/Translation.ts +++ b/packages/neos-ui-i18n/src/model/Translation.ts @@ -7,10 +7,11 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale} from '../model'; -import type {Parameters} from './Parameters'; -import {substitutePlaceholders} from './substitutePlaceholders'; +import type {Parameters} from '../registry/Parameters'; +import {substitutePlaceholders} from '../registry/substitutePlaceholders'; + +import {Locale} from './Locale'; export type TranslationDTO = string | TranslationDTOTuple; type TranslationDTOTuple = string[] | Record; diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts index 2df64b4aed..a3be175275 100644 --- a/packages/neos-ui-i18n/src/model/index.ts +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -8,3 +8,4 @@ * source code. */ export {Locale} from './Locale'; +export {Translation, TranslationDTO} from './Translation'; diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 9883458f19..725bacc034 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -12,9 +12,9 @@ import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/regis import logger from '@neos-project/utils-logger'; import {requireGlobals} from '../global'; +import type {Translation} from '../model'; import {getTranslationAddress} from './getTranslationAddress'; -import type {Translation} from './Translation'; import type {TranslationAddress} from './TranslationAddress'; import type {Parameters} from './Parameters'; diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts index 26cdd32584..b62f408a9c 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts @@ -7,10 +7,9 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale} from '../model'; +import {Locale, Translation} from '../model'; import {TranslationAddress} from './TranslationAddress'; -import {Translation} from './Translation'; import {TranslationRepository} from './TranslationRepository'; describe('TranslationRepository', () => { diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts index 200c02bfe4..c9e9b00bb3 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts @@ -7,10 +7,9 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale} from '../model'; +import {Locale, Translation, TranslationDTO} from '../model'; import type {TranslationAddress} from './TranslationAddress'; -import {Translation, TranslationDTO} from './Translation'; export type TranslationsDTO = Record>>; From 36f7c4632dd11a6c8bbf4e9e7ed05e7096bb4733 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 27 Jun 2024 14:51:40 +0200 Subject: [PATCH 30/39] TASK: Move `TranslationAddress` into `model` module --- .../src/{registry => model}/TranslationAddress.spec.ts | 0 .../src/{registry => model}/TranslationAddress.ts | 0 packages/neos-ui-i18n/src/model/index.ts | 1 + packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 3 +-- .../src/registry/TranslationRepository.spec.ts | 3 +-- .../neos-ui-i18n/src/registry/TranslationRepository.ts | 9 ++++++--- .../neos-ui-i18n/src/registry/getTranslationAddress.ts | 2 +- packages/neos-ui-i18n/src/registry/index.ts | 1 - packages/neos-ui-i18n/src/translate.ts | 3 ++- 9 files changed, 12 insertions(+), 10 deletions(-) rename packages/neos-ui-i18n/src/{registry => model}/TranslationAddress.spec.ts (100%) rename packages/neos-ui-i18n/src/{registry => model}/TranslationAddress.ts (100%) diff --git a/packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts b/packages/neos-ui-i18n/src/model/TranslationAddress.spec.ts similarity index 100% rename from packages/neos-ui-i18n/src/registry/TranslationAddress.spec.ts rename to packages/neos-ui-i18n/src/model/TranslationAddress.spec.ts diff --git a/packages/neos-ui-i18n/src/registry/TranslationAddress.ts b/packages/neos-ui-i18n/src/model/TranslationAddress.ts similarity index 100% rename from packages/neos-ui-i18n/src/registry/TranslationAddress.ts rename to packages/neos-ui-i18n/src/model/TranslationAddress.ts diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts index a3be175275..1e769ea7f5 100644 --- a/packages/neos-ui-i18n/src/model/index.ts +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -9,3 +9,4 @@ */ export {Locale} from './Locale'; export {Translation, TranslationDTO} from './Translation'; +export {TranslationAddress} from './TranslationAddress'; diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 725bacc034..6d40152265 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -12,10 +12,9 @@ import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/regis import logger from '@neos-project/utils-logger'; import {requireGlobals} from '../global'; -import type {Translation} from '../model'; +import type {Translation, TranslationAddress} from '../model'; import {getTranslationAddress} from './getTranslationAddress'; -import type {TranslationAddress} from './TranslationAddress'; import type {Parameters} from './Parameters'; const errorCache: Record = {}; diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts index b62f408a9c..641d28998f 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts @@ -7,9 +7,8 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale, Translation} from '../model'; +import {Locale, Translation, TranslationAddress} from '../model'; -import {TranslationAddress} from './TranslationAddress'; import {TranslationRepository} from './TranslationRepository'; describe('TranslationRepository', () => { diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts index c9e9b00bb3..056dfa55e0 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts +++ b/packages/neos-ui-i18n/src/registry/TranslationRepository.ts @@ -7,9 +7,12 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale, Translation, TranslationDTO} from '../model'; - -import type {TranslationAddress} from './TranslationAddress'; +import { + Locale, + Translation, + TranslationDTO, + type TranslationAddress +} from '../model'; export type TranslationsDTO = Record>>; diff --git a/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts b/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts index 89ec9c75c9..4a3a742824 100644 --- a/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts +++ b/packages/neos-ui-i18n/src/registry/getTranslationAddress.ts @@ -7,7 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {TranslationAddress} from './TranslationAddress'; +import {TranslationAddress} from '../model'; export function getTranslationAddress( fullyQualifiedTransUnitId: string diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts index 92bc20d699..26744b1bdc 100644 --- a/packages/neos-ui-i18n/src/registry/index.ts +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -14,5 +14,4 @@ export type {Parameters} from './Parameters'; export {substitutePlaceholders} from './substitutePlaceholders'; -export {TranslationAddress} from './TranslationAddress'; export {TranslationRepository} from './TranslationRepository'; diff --git a/packages/neos-ui-i18n/src/translate.ts b/packages/neos-ui-i18n/src/translate.ts index aa63c325b2..f4d607a27a 100644 --- a/packages/neos-ui-i18n/src/translate.ts +++ b/packages/neos-ui-i18n/src/translate.ts @@ -8,7 +8,8 @@ * source code. */ import {requireGlobals} from './global'; -import {substitutePlaceholders, TranslationAddress} from './registry'; +import {TranslationAddress} from './model'; +import {substitutePlaceholders} from './registry'; /** * Retrieves a the translation string that is identified by the given fully From 324d3e83cfa3d8e7dd61b1868eb864c109c8fcf0 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 27 Jun 2024 15:36:21 +0200 Subject: [PATCH 31/39] TASK: Move `TranslationRepository` into `model` module --- packages/neos-ui-i18n/src/global/globals.ts | 3 +-- packages/neos-ui-i18n/src/global/setupI18n.spec.ts | 3 +-- packages/neos-ui-i18n/src/global/setupI18n.ts | 3 +-- .../{registry => model}/TranslationRepository.spec.ts | 4 +++- .../src/{registry => model}/TranslationRepository.ts | 9 +++------ packages/neos-ui-i18n/src/model/index.ts | 4 ++++ packages/neos-ui-i18n/src/registry/index.ts | 2 -- 7 files changed, 13 insertions(+), 15 deletions(-) rename packages/neos-ui-i18n/src/{registry => model}/TranslationRepository.spec.ts (91%) rename packages/neos-ui-i18n/src/{registry => model}/TranslationRepository.ts (90%) diff --git a/packages/neos-ui-i18n/src/global/globals.ts b/packages/neos-ui-i18n/src/global/globals.ts index 663c6d9e91..d926933f6e 100644 --- a/packages/neos-ui-i18n/src/global/globals.ts +++ b/packages/neos-ui-i18n/src/global/globals.ts @@ -7,8 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale} from '../model'; -import {TranslationRepository} from '../registry'; +import {Locale, TranslationRepository} from '../model'; export const globals = { current: null as null | { diff --git a/packages/neos-ui-i18n/src/global/setupI18n.spec.ts b/packages/neos-ui-i18n/src/global/setupI18n.spec.ts index 88afcd9ca3..c1b4efe2f9 100644 --- a/packages/neos-ui-i18n/src/global/setupI18n.spec.ts +++ b/packages/neos-ui-i18n/src/global/setupI18n.spec.ts @@ -7,8 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale} from '../model'; -import {TranslationRepository} from '../registry/TranslationRepository'; +import {Locale, TranslationRepository} from '../model'; import {requireGlobals, unsetGlobals} from './globals'; import {setupI18n} from './setupI18n'; diff --git a/packages/neos-ui-i18n/src/global/setupI18n.ts b/packages/neos-ui-i18n/src/global/setupI18n.ts index 4150a91d0d..ac390a8317 100644 --- a/packages/neos-ui-i18n/src/global/setupI18n.ts +++ b/packages/neos-ui-i18n/src/global/setupI18n.ts @@ -7,8 +7,7 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale} from '../model'; -import {TranslationRepository, TranslationsDTO} from '../registry/TranslationRepository'; +import {Locale, TranslationRepository, type TranslationsDTO} from '../model'; import {setGlobals} from './globals'; diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts similarity index 91% rename from packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts rename to packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts index 641d28998f..7f7c027b82 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.spec.ts +++ b/packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts @@ -7,7 +7,9 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import {Locale, Translation, TranslationAddress} from '../model'; +import {Locale} from './Locale'; +import {Translation} from './Translation'; +import {TranslationAddress} from './TranslationAddress'; import {TranslationRepository} from './TranslationRepository'; diff --git a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts b/packages/neos-ui-i18n/src/model/TranslationRepository.ts similarity index 90% rename from packages/neos-ui-i18n/src/registry/TranslationRepository.ts rename to packages/neos-ui-i18n/src/model/TranslationRepository.ts index 056dfa55e0..36c32ae069 100644 --- a/packages/neos-ui-i18n/src/registry/TranslationRepository.ts +++ b/packages/neos-ui-i18n/src/model/TranslationRepository.ts @@ -7,12 +7,9 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -import { - Locale, - Translation, - TranslationDTO, - type TranslationAddress -} from '../model'; +import {Locale} from './Locale'; +import {Translation, type TranslationDTO} from './Translation'; +import type {TranslationAddress} from './TranslationAddress'; export type TranslationsDTO = Record>>; diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts index 1e769ea7f5..379060fe4c 100644 --- a/packages/neos-ui-i18n/src/model/index.ts +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -10,3 +10,7 @@ export {Locale} from './Locale'; export {Translation, TranslationDTO} from './Translation'; export {TranslationAddress} from './TranslationAddress'; +export { + TranslationRepository, + type TranslationsDTO +} from './TranslationRepository'; diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts index 26744b1bdc..906b9350fa 100644 --- a/packages/neos-ui-i18n/src/registry/index.ts +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -13,5 +13,3 @@ export {i18nRegistry} from './I18nRegistry'; export type {Parameters} from './Parameters'; export {substitutePlaceholders} from './substitutePlaceholders'; - -export {TranslationRepository} from './TranslationRepository'; From 72b8f30d8bfc4db841aaa834912247332e57b5b1 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 27 Jun 2024 16:01:00 +0200 Subject: [PATCH 32/39] TASK: Rename `Parameters` -> `LegacyParameters` --- packages/neos-ui-i18n/src/index.tsx | 4 ++-- .../neos-ui-i18n/src/model/Translation.ts | 4 ++-- .../neos-ui-i18n/src/registry/I18nRegistry.ts | 20 +++++++++---------- .../{Parameters.ts => LegacyParameters.ts} | 2 +- packages/neos-ui-i18n/src/registry/index.ts | 2 +- .../src/registry/substitutePlaceholders.ts | 4 ++-- 6 files changed, 18 insertions(+), 18 deletions(-) rename packages/neos-ui-i18n/src/registry/{Parameters.ts => LegacyParameters.ts} (80%) diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx index 920c122de0..143ad7d8ce 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Parameters, i18nRegistry} from './registry'; +import {LegacyParameters, i18nRegistry} from './registry'; export {initializeI18n, setupI18n, teardownI18n} from './global'; @@ -19,7 +19,7 @@ interface I18nProps { sourceName?: string; // Additional parameters which are passed to the i18n service. - params?: Parameters; + params?: LegacyParameters; // Optional className which gets added to the translation span. className?: string; diff --git a/packages/neos-ui-i18n/src/model/Translation.ts b/packages/neos-ui-i18n/src/model/Translation.ts index a04daeb157..b704c63e7b 100644 --- a/packages/neos-ui-i18n/src/model/Translation.ts +++ b/packages/neos-ui-i18n/src/model/Translation.ts @@ -8,7 +8,7 @@ * source code. */ -import type {Parameters} from '../registry/Parameters'; +import type {LegacyParameters} from '../registry/Parameters'; import {substitutePlaceholders} from '../registry/substitutePlaceholders'; import {Locale} from './Locale'; @@ -34,7 +34,7 @@ export class Translation { private static fromString = (locale: Locale, string: string): Translation => new Translation(locale, [string]); - public render(parameters: undefined | Parameters, quantity: number): string { + public render(parameters: undefined | LegacyParameters, quantity: number): string { return parameters ? substitutePlaceholders(this.byQuantity(quantity), parameters) : this.byQuantity(quantity); diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 6d40152265..d54514760d 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -15,7 +15,7 @@ import {requireGlobals} from '../global'; import type {Translation, TranslationAddress} from '../model'; import {getTranslationAddress} from './getTranslationAddress'; -import type {Parameters} from './Parameters'; +import type {LegacyParameters} from './LegacyParameters'; const errorCache: Record = {}; @@ -69,12 +69,12 @@ export class I18nRegistry extends SynchronousRegistry { * * @param {string} transUnitIdOrFullyQualifiedTranslationAddress The fully qualified translation address, that follows the format "{Package.Key:SourceName:trans.unit.id}" * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. - * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string + * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string */ translate( transUnitIdOrFullyQualifiedTranslationAddress: string, fallback: undefined | string, - parameters: Parameters + parameters: LegacyParameters ): string; /** @@ -93,13 +93,13 @@ export class I18nRegistry extends SynchronousRegistry { * * @param {string} transUnitId The trans-unit id * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. - * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string + * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string * @param {string} packageKey The key of the package in which to look for the translation file */ translate( transUnitId: string, fallback: undefined | string, - parameters: undefined | Parameters, + parameters: undefined | LegacyParameters, packageKey: string ): string; @@ -120,14 +120,14 @@ export class I18nRegistry extends SynchronousRegistry { * * @param {string} transUnitId The trans-unit id * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. - * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string + * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string * @param {string} packageKey The key of the package in which to look for the translation file * @param {string} sourceName The name of the translation file in that package's resource translations */ translate( transUnitId: string, fallback: undefined | string, - parameters: undefined | Parameters, + parameters: undefined | LegacyParameters, packageKey: string, sourceName: string ): string; @@ -153,14 +153,14 @@ export class I18nRegistry extends SynchronousRegistry { * * @param {string} transUnitId The trans-unit id * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. - * @param {Parameters} parameters The values to replace substitution placeholders with in the translation string + * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string * @param {string} packageKey The key of the package in which to look for the translation file * @param {string} sourceName The name of the translation file in that package's resource translations */ translate( transUnitId: string, fallback: undefined | string, - parameters: undefined | Parameters, + parameters: undefined | LegacyParameters, packageKey: string, sourceName: string, quantity: number @@ -169,7 +169,7 @@ export class I18nRegistry extends SynchronousRegistry { translate( transUnitIdOrFullyQualifiedTranslationAddress: string, explicitlyProvidedFallback?: string, - parameters?: Parameters, + parameters?: LegacyParameters, explicitlyProvidedPackageKey: string = 'Neos.Neos', explicitlyProvidedSourceName: string = 'Main', quantity: number = 0 diff --git a/packages/neos-ui-i18n/src/registry/Parameters.ts b/packages/neos-ui-i18n/src/registry/LegacyParameters.ts similarity index 80% rename from packages/neos-ui-i18n/src/registry/Parameters.ts rename to packages/neos-ui-i18n/src/registry/LegacyParameters.ts index 7c4ef56823..843f953ed2 100644 --- a/packages/neos-ui-i18n/src/registry/Parameters.ts +++ b/packages/neos-ui-i18n/src/registry/LegacyParameters.ts @@ -7,4 +7,4 @@ * information, please view the LICENSE file which was distributed with this * source code. */ -export type Parameters = unknown[] | Record; +export type LegacyParameters = unknown[] | Record; diff --git a/packages/neos-ui-i18n/src/registry/index.ts b/packages/neos-ui-i18n/src/registry/index.ts index 906b9350fa..f1f308166b 100644 --- a/packages/neos-ui-i18n/src/registry/index.ts +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -10,6 +10,6 @@ export type {I18nRegistry} from './I18nRegistry'; export {i18nRegistry} from './I18nRegistry'; -export type {Parameters} from './Parameters'; +export type {LegacyParameters} from './LegacyParameters'; export {substitutePlaceholders} from './substitutePlaceholders'; diff --git a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts index 1403e5060a..ad7c57077e 100644 --- a/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts @@ -9,13 +9,13 @@ */ import logger from '@neos-project/utils-logger'; -import {Parameters} from './Parameters'; +import {LegacyParameters} from './LegacyParameters'; /** * This code is taken from the Ember version with minor adjustments. Possibly refactor it later * as its style is not superb. */ -export const substitutePlaceholders = function (textWithPlaceholders: string, parameters: Parameters) { +export const substitutePlaceholders = function (textWithPlaceholders: string, parameters: LegacyParameters) { const result = []; let startOfPlaceholder; let offset = 0; From 94862c3bb9bef2e6f936dcd5cfb2ddfbbcaa924b Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 27 Jun 2024 16:42:45 +0200 Subject: [PATCH 33/39] TASK: Introduce proper `Parameters` type --- packages/neos-ui-i18n/src/model/Parameters.ts | 14 ++++++++++++++ packages/neos-ui-i18n/src/model/Translation.ts | 4 ++-- packages/neos-ui-i18n/src/model/index.ts | 1 + packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 2 +- packages/neos-ui-i18n/src/translate.ts | 8 ++++---- 5 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 packages/neos-ui-i18n/src/model/Parameters.ts diff --git a/packages/neos-ui-i18n/src/model/Parameters.ts b/packages/neos-ui-i18n/src/model/Parameters.ts new file mode 100644 index 0000000000..ac8f2618cb --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Parameters.ts @@ -0,0 +1,14 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export type Parameters = + | ParameterValue[] + | Record; + +type ParameterValue = number | string; diff --git a/packages/neos-ui-i18n/src/model/Translation.ts b/packages/neos-ui-i18n/src/model/Translation.ts index b704c63e7b..f9796388ac 100644 --- a/packages/neos-ui-i18n/src/model/Translation.ts +++ b/packages/neos-ui-i18n/src/model/Translation.ts @@ -8,10 +8,10 @@ * source code. */ -import type {LegacyParameters} from '../registry/Parameters'; import {substitutePlaceholders} from '../registry/substitutePlaceholders'; import {Locale} from './Locale'; +import type {Parameters} from './Parameters'; export type TranslationDTO = string | TranslationDTOTuple; type TranslationDTOTuple = string[] | Record; @@ -34,7 +34,7 @@ export class Translation { private static fromString = (locale: Locale, string: string): Translation => new Translation(locale, [string]); - public render(parameters: undefined | LegacyParameters, quantity: number): string { + public render(parameters: undefined | Parameters, quantity: number): string { return parameters ? substitutePlaceholders(this.byQuantity(quantity), parameters) : this.byQuantity(quantity); diff --git a/packages/neos-ui-i18n/src/model/index.ts b/packages/neos-ui-i18n/src/model/index.ts index 379060fe4c..acaae67935 100644 --- a/packages/neos-ui-i18n/src/model/index.ts +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -8,6 +8,7 @@ * source code. */ export {Locale} from './Locale'; +export type {Parameters} from './Parameters'; export {Translation, TranslationDTO} from './Translation'; export {TranslationAddress} from './TranslationAddress'; export { diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index d54514760d..d92f557e1a 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -182,7 +182,7 @@ export class I18nRegistry extends SynchronousRegistry { return fallback; } - return translation.render(parameters, quantity); + return translation.render(parameters as any, quantity); } private logTranslationNotFound(address: TranslationAddress, fallback: string) { diff --git a/packages/neos-ui-i18n/src/translate.ts b/packages/neos-ui-i18n/src/translate.ts index f4d607a27a..82c1a7dc82 100644 --- a/packages/neos-ui-i18n/src/translate.ts +++ b/packages/neos-ui-i18n/src/translate.ts @@ -8,7 +8,7 @@ * source code. */ import {requireGlobals} from './global'; -import {TranslationAddress} from './model'; +import {TranslationAddress, type Parameters} from './model'; import {substitutePlaceholders} from './registry'; /** @@ -31,13 +31,13 @@ import {substitutePlaceholders} from './registry'; * @api * @param {string} fullyQualifiedTranslationAddressAsString The translation address * @param {string | [string, string]} fallback The string that shall be displayed, when no translation string could be found. If a tuple of two values is given, the first value will be treated as the singular, the second value as the plural form. - * @param {(string | number)[] | Record} [parameters] The values to replace substitution placeholders with in the translation string + * @param {Parameters} [parameters] The values to replace substitution placeholders with in the translation string * @param {quantity} [quantity] The key of the package in which to look for the translation file */ export function translate( fullyQualifiedTranslationAddressAsString: string, fallback: string | [string, string], - parameters: (string | number)[] | Record = [], + parameters: Parameters = [], quantity: number = 0 ): string { const {translationRepository} = requireGlobals(); @@ -54,7 +54,7 @@ export function translate( function renderFallback( fallback: string | [string, string], quantity: number, - parameters: (string | number)[] | Record + parameters: Parameters ) { const fallbackHasPluralForms = Array.isArray(fallback); let result: string; From 9bf5f37d276604efd09666216f94744e3ae39b0b Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 28 Jun 2024 12:03:02 +0200 Subject: [PATCH 34/39] TASK: Move ``-component into separate module --- packages/neos-ui-i18n/package.json | 2 +- .../neos-ui-i18n/src/component/I18n.spec.tsx | 41 +++++++++++++++++++ .../src/{index.tsx => component/I18n.tsx} | 18 ++++---- packages/neos-ui-i18n/src/component/index.ts | 10 +++++ packages/neos-ui-i18n/src/index.spec.tsx | 36 ---------------- packages/neos-ui-i18n/src/index.ts | 16 ++++++++ 6 files changed, 79 insertions(+), 44 deletions(-) create mode 100644 packages/neos-ui-i18n/src/component/I18n.spec.tsx rename packages/neos-ui-i18n/src/{index.tsx => component/I18n.tsx} (68%) create mode 100644 packages/neos-ui-i18n/src/component/index.ts delete mode 100644 packages/neos-ui-i18n/src/index.spec.tsx create mode 100644 packages/neos-ui-i18n/src/index.ts diff --git a/packages/neos-ui-i18n/package.json b/packages/neos-ui-i18n/package.json index 6e924f7a70..19255b5256 100644 --- a/packages/neos-ui-i18n/package.json +++ b/packages/neos-ui-i18n/package.json @@ -3,7 +3,7 @@ "version": "", "description": "I18n utilities and components for Neos CMS UI.", "private": true, - "main": "./src/index.tsx", + "main": "./src/index.ts", "devDependencies": { "@neos-project/jest-preset-neos-ui": "workspace:*", "enzyme": "^3.8.0", diff --git a/packages/neos-ui-i18n/src/component/I18n.spec.tsx b/packages/neos-ui-i18n/src/component/I18n.spec.tsx new file mode 100644 index 0000000000..3d17410c21 --- /dev/null +++ b/packages/neos-ui-i18n/src/component/I18n.spec.tsx @@ -0,0 +1,41 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +import {mount} from 'enzyme'; + +import {i18nRegistry} from '../registry'; + +import {I18n} from './I18n'; + +describe('', () => { + beforeEach(() => { + jest.spyOn(i18nRegistry, 'translate'); + (jest as any) + .mocked(i18nRegistry.translate) + .mockImplementation((key: string) => { + return key; + }); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it(`should render a node.`, () => { + const original = mount(); + + expect(original.html()).toBe(''); + }); + + it(`should call translation service with key.`, () => { + const original = mount(); + + expect(original.html()).toBe('My key'); + }); +}); diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/component/I18n.tsx similarity index 68% rename from packages/neos-ui-i18n/src/index.tsx rename to packages/neos-ui-i18n/src/component/I18n.tsx index 143ad7d8ce..db5655039f 100644 --- a/packages/neos-ui-i18n/src/index.tsx +++ b/packages/neos-ui-i18n/src/component/I18n.tsx @@ -1,11 +1,15 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ import React from 'react'; -import {LegacyParameters, i18nRegistry} from './registry'; -export {initializeI18n, setupI18n, teardownI18n} from './global'; - -export type {I18nRegistry} from './registry'; - -export {translate} from './translate'; +import {LegacyParameters, i18nRegistry} from '../registry'; interface I18nProps { // Fallback key which gets rendered once the i18n service doesn't return a translation. @@ -25,7 +29,7 @@ interface I18nProps { className?: string; } -export default class I18n extends React.PureComponent { +export class I18n extends React.PureComponent { public render(): JSX.Element { const {packageKey, sourceName, params, id, fallback} = this.props; diff --git a/packages/neos-ui-i18n/src/component/index.ts b/packages/neos-ui-i18n/src/component/index.ts new file mode 100644 index 0000000000..94f93367d8 --- /dev/null +++ b/packages/neos-ui-i18n/src/component/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {I18n} from './I18n'; diff --git a/packages/neos-ui-i18n/src/index.spec.tsx b/packages/neos-ui-i18n/src/index.spec.tsx deleted file mode 100644 index dc50becbaa..0000000000 --- a/packages/neos-ui-i18n/src/index.spec.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * This file is part of the Neos.Neos.Ui package. - * - * (c) Contributors of the Neos Project - www.neos.io - * - * This package is Open Source Software. For the full copyright and license - * information, please view the LICENSE file which was distributed with this - * source code. - */ -import React from 'react'; -import {mount} from 'enzyme'; - -import I18n from './index'; -import {i18nRegistry} from './registry'; - -beforeEach(() => { - jest.spyOn(i18nRegistry, 'translate'); - (jest as any).mocked(i18nRegistry.translate).mockImplementation((key: string) => { - return key; - }); -}); -afterEach(() => { - jest.restoreAllMocks(); -}); - -test(` should render a node.`, () => { - const original = mount(); - - expect(original.html()).toBe(''); -}); - -test(` should call translation service with key.`, () => { - const original = mount(); - - expect(original.html()).toBe('My key'); -}); diff --git a/packages/neos-ui-i18n/src/index.ts b/packages/neos-ui-i18n/src/index.ts new file mode 100644 index 0000000000..ed57b10ba3 --- /dev/null +++ b/packages/neos-ui-i18n/src/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {I18n as default} from './component'; + +export {initializeI18n, setupI18n, teardownI18n} from './global'; + +export type {I18nRegistry} from './registry'; + +export {translate} from './translate'; From 560474a00e1a2e31d5a8015800bde03db1822cf8 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 28 Jun 2024 12:08:08 +0200 Subject: [PATCH 35/39] TASK: Deprecate `I18nRegistry.translate` --- packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index d92f557e1a..0596fd0289 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -31,6 +31,7 @@ export class I18nRegistry extends SynchronousRegistry { * If no translation string can be found for the given id, the fully * qualified translation address will be returned. * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead * @param {string} transUnitIdOrFullyQualifiedTranslationAddress A trans-unit id or a fully qualified translation address */ translate(transUnitIdOrFullyQualifiedTranslationAddress: string): string; @@ -46,6 +47,7 @@ export class I18nRegistry extends SynchronousRegistry { * If no translation string can be found for the given id, the given * fallback value will be returned. * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead * @param {string} transUnitIdOrFullyQualifiedTranslationAddress A trans-unit id or a fully qualified translation address * @param {string} fallback The string that shall be displayed, when no translation string could be found. */ @@ -67,6 +69,7 @@ export class I18nRegistry extends SynchronousRegistry { * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced * with the corresponding values that were passed as parameters. * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead * @param {string} transUnitIdOrFullyQualifiedTranslationAddress The fully qualified translation address, that follows the format "{Package.Key:SourceName:trans.unit.id}" * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string @@ -91,6 +94,7 @@ export class I18nRegistry extends SynchronousRegistry { * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced * with the corresponding values that were passed as parameters. * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead * @param {string} transUnitId The trans-unit id * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string @@ -118,6 +122,7 @@ export class I18nRegistry extends SynchronousRegistry { * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced * with the corresponding values that were passed as parameters. * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead * @param {string} transUnitId The trans-unit id * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string @@ -151,6 +156,7 @@ export class I18nRegistry extends SynchronousRegistry { * (e.g.: "{0}", or "{somePlaceholder}"), the placeholders will be replaced * with the corresponding values that were passed as parameters. * + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead * @param {string} transUnitId The trans-unit id * @param {undefined|string} fallback The string that shall be displayed, when no translation string could be found. * @param {LegacyParameters} parameters The values to replace substitution placeholders with in the translation string From 0cc3772283ee2263674e1615727d4c2c97c1d1d2 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 28 Jun 2024 12:14:19 +0200 Subject: [PATCH 36/39] TASK: Deprecate `I18nRegistry` --- packages/neos-ui-i18n/src/registry/I18nRegistry.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts index 0596fd0289..b97b6ba2fc 100644 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.ts +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -19,6 +19,9 @@ import type {LegacyParameters} from './LegacyParameters'; const errorCache: Record = {}; +/** + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + */ export class I18nRegistry extends SynchronousRegistry { /** * Retrieves a the translation string that is identified by the given @@ -205,4 +208,7 @@ export class I18nRegistry extends SynchronousRegistry { } } +/** + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + */ export const i18nRegistry = new I18nRegistry('The i18n registry'); From ee9588762bc89d6901b921ea46e08830d0d9805a Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 28 Jun 2024 12:14:38 +0200 Subject: [PATCH 37/39] TASK: Deprecate ``-component --- packages/neos-ui-i18n/src/component/I18n.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/neos-ui-i18n/src/component/I18n.tsx b/packages/neos-ui-i18n/src/component/I18n.tsx index db5655039f..e14b6d353a 100644 --- a/packages/neos-ui-i18n/src/component/I18n.tsx +++ b/packages/neos-ui-i18n/src/component/I18n.tsx @@ -29,6 +29,9 @@ interface I18nProps { className?: string; } +/** + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + */ export class I18n extends React.PureComponent { public render(): JSX.Element { const {packageKey, sourceName, params, id, fallback} = this.props; From 307d8bb7529273d71391642a4def7832930d4bcd Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 28 Jun 2024 14:09:23 +0200 Subject: [PATCH 38/39] TASK: Add documentation for `@neos-project/neos-ui-i18n` package --- packages/neos-ui-i18n/README.md | 244 ++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 packages/neos-ui-i18n/README.md diff --git a/packages/neos-ui-i18n/README.md b/packages/neos-ui-i18n/README.md new file mode 100644 index 0000000000..0fd7260786 --- /dev/null +++ b/packages/neos-ui-i18n/README.md @@ -0,0 +1,244 @@ +# @neos-project/neos-ui-i18n + +> I18n utilities for Neos CMS UI. + +This package connects Flow's Internationalization (I18n) framework with the Neos UI. + +In Flow, translations are organized in [XLIFF](http://en.wikipedia.org/wiki/XLIFF) files that are stored in the `Resources/Private/Translations/`-folder of each Flow package. + +The Neos UI does not load all translation files at once, but only those that have been made discoverable explicitly via settings: +```yaml +Neos: + Neos: + userInterface: + translation: + autoInclude: + 'Neos.Neos.Ui': + - Error + - Main + // ... + 'Vendor.Package': + - Main + // ... +``` + +At the beginning of the UI bootstrapping process, translations are loaded from an enpoint (see: [`\Neos\Neos\Controller\Backend\BackendController->xliffAsJsonAction()`](https://neos.github.io/neos/9.0/Neos/Neos/Controller/Backend/BackendController.html#method_xliffAsJsonAction)) and are available afterwards via the `translate` function exposed by this package. + +## API + +### `translate` + +```typescript +function translate( + fullyQualifiedTranslationAddressAsString: string, + fallback: string | [string, string], + parameters: Parameters = [], + quantity: number = 0 +): string; +``` + +`translate` will use the given translation address to look up a translation from the ones that are currently available (see: [`initializeI18n`](#initializeI18n)). + +To understand how the translation address maps onto the translations stored in XLIFF files, let's take a look at the structure of the address: +``` +"Neos.Neos.Ui:Main:errorBoundary.title" + └────┬─────┘ └─┬┘ └───────────┬─────┘ + Package Key Source Name trans-unit ID +``` + +Each translation address consists of three Parts, one identifying the package (Package Key), one identifying the XLIFF file (Source Name), and one identifying the translation itself within the XLIFF file (trans-unit ID). + +Together with the currently set `Locale`, Package Key and Source Name identify the exact XLIFF file for translation thusly: +``` +resource://{Package Key}/Private/Translations/{Locale}/{Source Name}.xlf +``` + +So, the address `Neos.Neos.Ui:Main:errorBoundary.title` would lead us to: +``` +resource://Neos.Neos.Ui/Private/Translations/de/Main.xlf +``` + +Within the XLIFF-file, the trans-unit ID identifies the exact translation to be used: +```xml + + + + + + + + Sorry, but the Neos UI could not recover from this error. + Es tut uns leid, aber die Neos Benutzeroberfläche konnte von diesem Fehler nicht wiederhergestellt werden. + + + + + +``` + +If no translation can be found, `translate` will return the given `fallback` string. + +Translations (and fallbacks) may contain placeholders, like: +``` +All changes from workspace "{0}" have been discarded. +``` + +Placeholders may be numerically indexed (like the one above), or indexed by name, like: +``` +Copy {source} to {target} +``` + +For numerically indexed placeholders, you can pass an array of strings to the `parameters` argument of `translate`. For named parameters, you can pass an object with string values and keys identifying the parameters. + +Translations may also have plural forms. `translate` uses the [`Intl` Web API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) to pick the currect plural form for the current `Locale` based on the given `quantity`. + +Fallbacks can also provide plural forms, but will always treated as if we're in locale `en-US`, so you can only provide two different plural forms. + +#### Arguments + +| Name | Description | +|-|-| +| `fullyQualifiedTranslationAddressAsString` | The translation address for the translation to use, e.g.: `"Neos.Neos.Ui:Main:errorBoundary.title"` | +| `fallback` | The string to return, if no translation can be found under the given address. If a tuple of two strings is passed here, these will be treated as singular and plural forms of the translation. | +| `parameters` | Values to replace placeholders in the translation with. This can be passed as an array of strings (to replace numerically indexed placeholders) or as a `Record` (to replace named placeholders) | +| `quantity` | The quantity is used to determine which plural form (if any) to use for the translation | + +#### Examples + +##### Translation without placeholders or plural forms + +```typescript +translate('Neos.Neos.Ui:Main:insert', 'insert'); +// output (en): "insert" +``` + +##### Translation with a numerically indexed placeholder + +```typescript +translate( + 'Neos.Neos:Main:workspaces.allChangesInWorkspaceHaveBeenDiscarded', + 'All changes from workspace "{0}" have been discarded.', + ['user-admin'] +); + +// output (en): All changes from workspace "user-admin" have been discarded. +``` + +##### Translation with a named placeholder + +```typescript +translate( + 'Neos.Neos.Ui:Main:deleteXNodes', + 'Delete {amount} nodes', + {amount: 12} +); + +// output (en): "Delete 12 nodes" +``` + +##### Translations with placeholders and plural forms + +```typescript +translate( + 'Neos.Neos.Ui:Main:changesPublished', + ['Published {0} change to "{1}".', 'Published {0} changes to "{1}".'] + [1, "live"], + 1 +); +// output (en): "Published 1 change to "live"." + +translate( + 'Neos.Neos.Ui:Main:changesPublished', + ['Published {0} change to "{1}".', 'Published {0} changes to "{1}".'] + [20], + 20 +); +// output (en): "Published 20 changes to "live"." +``` + +### `initializeI18n` + +```typescript +async function initializeI18n(): Promise; +``` + +> [!NOTE] +> Usually you won't have to call this function yourself. The Neos UI will +> set up I18n automatically. + +This function loads the translations from the translations endpoint and makes them available globally. It must be run exactly once before any call to `translate`. + +The exact URL of the translations endpoint is discoverd via the DOM. The document needs to have a link tag with the id `neos-ui-uri:/neos/xliff.json`, with the following attributes: +```html + +``` + +The `ApplicationView` PHP class takes care of rendering this tag. + +### `setupI18n` + +```typescript +function setupI18n( + localeIdentifier: string, + pluralRulesAsString: string, + translations: TranslationsDTO +): void; +``` + +This function can be used in unit tests to set up I18n. + +#### Arguments + +| Name | Description | +|-|-| +| `localeIdentifier` | A valid [Unicode Language Identifier](https://www.unicode.org/reports/tr35/#unicode-language-identifier), e.g.: `de-DE`, `en-US`, `ar-EG`, ... | +| `pluralRulesAsString` | A comma-separated list of [Language Plural Rules](http://www.unicode.org/reports/tr35/#Language_Plural_Rules) matching the locale specified by `localeIdentifier`. Here, the output of [`\Neos\Flow\I18n\Cldr\Reader\PluralsReader->getPluralForms()`](https://neos.github.io/flow/9.0/Neos/Flow/I18n/Cldr/Reader/PluralsReader.html#method_getPluralForms) is expected, e.g.: `one,other` for `de-DE`, or `zero,one,two,few,many` for `ar-EG` | +| `translations` | The XLIFF translations in their JSON-serialized form | + +##### `TranslationsDTO` + +```typescript +type TranslationsDTO = { + [serializedPackageKey: string]: { + [serializedSourceName: string]: { + [serializedTransUnitId: string]: string | string[] + } + } +} +``` + +The `TranslationDTO` is the payload of the response from the translations endpoint (see: [`\Neos\Neos\Controller\Backend\BackendController->xliffAsJsonAction()`](https://neos.github.io/neos/9.0/Neos/Neos/Controller/Backend/BackendController.html#method_xliffAsJsonAction)). + +###### Example: + +```jsonc +{ + "Neos_Neos_Ui": { // <- Package Key with "_" instead of "." + "Main": { // <- Source name with "_" instead of "." + + // Example without plural forms + "errorBoundary_title": // <- trans-unit ID with "_" instead of "." + "Sorry, but the Neos UI could not recover from this error.", + + // Example with plural forms + "changesDiscarded": [ // <- trans-unit ID with "_" instead of "." + "Discarded {0} change.", + "Discarded {0} changes." + ] + } + } +} +``` + +### `teardownI18n` + +```typescript +function teardownI18n(): void; +``` + +This function must be used in unit tests to clean up when `setupI18n` has been used. From 395502afdaf839eb8c4bfd8906e49c1894634e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Markus=20Gu=CC=88nther?= Date: Fri, 13 Dec 2024 12:10:55 +0100 Subject: [PATCH 39/39] TASK: Resolve linting issues --- packages/neos-ts-interfaces/src/index.ts | 2 ++ .../src/Editors/SelectBox/selectBoxHelpers.spec.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/neos-ts-interfaces/src/index.ts b/packages/neos-ts-interfaces/src/index.ts index aaaeac447d..efcaae26db 100644 --- a/packages/neos-ts-interfaces/src/index.ts +++ b/packages/neos-ts-interfaces/src/index.ts @@ -274,3 +274,5 @@ export interface GlobalRegistry { get: (key: K) => K extends 'i18n' ? I18nRegistry : K extends 'validators' ? ValidatorRegistry : null; } + +export type {I18nRegistry} from '@neos-project/neos-ui-i18n'; diff --git a/packages/neos-ui-editors/src/Editors/SelectBox/selectBoxHelpers.spec.ts b/packages/neos-ui-editors/src/Editors/SelectBox/selectBoxHelpers.spec.ts index 02f5a618f5..52fa4b2334 100644 --- a/packages/neos-ui-editors/src/Editors/SelectBox/selectBoxHelpers.spec.ts +++ b/packages/neos-ui-editors/src/Editors/SelectBox/selectBoxHelpers.spec.ts @@ -1,9 +1,9 @@ import {processSelectBoxOptions} from './selectBoxHelpers'; import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; -const fakeI18NRegistry: I18nRegistry = { +const fakeI18NRegistry = { translate: (id) => id ?? '' -}; +} as I18nRegistry; describe('processSelectBoxOptions', () => { it('transforms an associative array with labels to list of objects', () => {