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/.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 0000000000..bddbef91a2 Binary files /dev/null and b/.yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip differ diff --git a/.yarn/cache/node-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip b/.yarn/cache/node-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip new file mode 100644 index 0000000000..a067dc7b1c Binary files /dev/null and b/.yarn/cache/node-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip differ diff --git a/Classes/Presentation/ApplicationView.php b/Classes/Presentation/ApplicationView.php index c54f1d7c5c..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,6 +118,15 @@ 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( '', json_encode($this->variables['initialData']), 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-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..efcaae26db 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,10 +270,9 @@ 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; } + +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', () => { 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/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. diff --git a/packages/neos-ui-i18n/package.json b/packages/neos-ui-i18n/package.json index 76232a5cb2..19255b5256 100644 --- a/packages/neos-ui-i18n/package.json +++ b/packages/neos-ui-i18n/package.json @@ -3,15 +3,13 @@ "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", "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/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/component/I18n.tsx b/packages/neos-ui-i18n/src/component/I18n.tsx new file mode 100644 index 0000000000..e14b6d353a --- /dev/null +++ b/packages/neos-ui-i18n/src/component/I18n.tsx @@ -0,0 +1,43 @@ +/* + * 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'; + +interface I18nProps { + // Fallback key which gets rendered once the i18n service doesn't return a translation. + fallback?: string; + + // The target id which the i18n service accepts. + id?: string; + + // The destination paths for the package and source of the translation. + packageKey?: string; + sourceName?: string; + + // Additional parameters which are passed to the i18n service. + params?: LegacyParameters; + + // Optional className which gets added to the translation span. + 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; + + return ( + {i18nRegistry.translate(id ?? '', fallback, params, packageKey ?? 'Neos.Neos', sourceName ?? 'Main')} + ); + } +} 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/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..d926933f6e --- /dev/null +++ b/packages/neos-ui-i18n/src/global/globals.ts @@ -0,0 +1,64 @@ +/* + * 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, TranslationRepository} from '../model'; + +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..6250e8b255 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/index.ts @@ -0,0 +1,13 @@ +/* + * 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 {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/global/setupI18n.spec.ts b/packages/neos-ui-i18n/src/global/setupI18n.spec.ts new file mode 100644 index 0000000000..c1b4efe2f9 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/setupI18n.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 {Locale, TranslationRepository} from '../model'; +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..ac390a8317 --- /dev/null +++ b/packages/neos-ui-i18n/src/global/setupI18n.ts @@ -0,0 +1,37 @@ +/* + * 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, TranslationRepository, type TranslationsDTO} from '../model'; + +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.spec.js b/packages/neos-ui-i18n/src/index.spec.js deleted file mode 100644 index a749a39785..0000000000 --- a/packages/neos-ui-i18n/src/index.spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import {mount} from 'enzyme'; - -import I18n from './index'; - -const FakeRegistry = { - translate(key) { - return key; - } -}; - -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'; diff --git a/packages/neos-ui-i18n/src/index.tsx b/packages/neos-ui-i18n/src/index.tsx deleted file mode 100644 index e0ca2540af..0000000000 --- a/packages/neos-ui-i18n/src/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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; - -interface I18nProps { - // Fallback key which gets rendered once the i18n service doesn't return a translation. - fallback?: string; - - // The target id which the i18n service accepts. - id?: string; - - // The destination paths for the package and source of the translation. - packageKey?: string; - sourceName?: string; - - // Additional parameters which are passed to the i18n service. - params?: object; - - // Optional className which gets added to the translation span. - className?: string; -} - -class I18n extends React.PureComponent { - public render(): JSX.Element { - const {i18nRegistry, packageKey, sourceName, params, id, fallback} = this.props; - - return ( - {i18nRegistry.translate(id, fallback, params, packageKey, sourceName)} - ); - } -} - -export default neos(regsToProps)(I18n); 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/model/Locale.spec.ts b/packages/neos-ui-i18n/src/model/Locale.spec.ts new file mode 100644 index 0000000000..be42536117 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Locale.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 {InvalidLocale, Locale} 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); + }); + }); +}); 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..72a7a910ed --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Locale.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. + */ +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); +} 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/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); +} diff --git a/packages/neos-ui-i18n/src/model/Translation.spec.ts b/packages/neos-ui-i18n/src/model/Translation.spec.ts new file mode 100644 index 0000000000..04cf77b92a --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Translation.spec.ts @@ -0,0 +1,142 @@ +/* + * 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 './Locale'; +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(locale_en_US, [ + 'This translation has only a singular form, despite its DTO being an array.' + ]); + + 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 translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and no placeholders.' + ); + + 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 translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and no placeholders.' + ); + + 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 translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and no placeholders.' + ); + + 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 translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and {some} placeholder.' + ); + + 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 translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and {some} placeholder.' + ); + + 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 translation = Translation.fromDTO(locale_en_US, + 'This translation has only a singular form and {some} placeholder.' + ); + + 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 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 plural form with no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity = 1', () => { + 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, 1)) + .toBe('This translation has a singular form with no placeholders.'); + }); + + it('renders a translation string without placeholders and with quantity > 1', () => { + 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, 42)) + .toBe('This translation has a plural form with no placeholders.'); + }); + + it('renders a translation string with placeholders and quantity = 0', () => { + 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 plural form with one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity = 1', () => { + 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'}, 1)) + .toBe('This translation has a singular form with one placeholder.'); + }); + + it('renders a translation string with placeholders and with quantity > 1', () => { + 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'}, 42)) + .toBe('This translation has a plural form with one placeholder.'); + }); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/Translation.ts b/packages/neos-ui-i18n/src/model/Translation.ts new file mode 100644 index 0000000000..f9796388ac --- /dev/null +++ b/packages/neos-ui-i18n/src/model/Translation.ts @@ -0,0 +1,48 @@ +/* + * 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 {substitutePlaceholders} from '../registry/substitutePlaceholders'; + +import {Locale} from './Locale'; +import type {Parameters} from './Parameters'; + +export type TranslationDTO = string | TranslationDTOTuple; +type TranslationDTOTuple = string[] | Record; + +export class Translation { + private constructor( + private readonly locale: Locale, + private readonly value: string[] + ) { + } + + public static fromDTO = (locale: Locale, dto: TranslationDTO): Translation => + dto instanceof Object + ? Translation.fromTuple(locale, dto) + : Translation.fromString(locale, dto); + + private static fromTuple = (locale: Locale, tuple: TranslationDTOTuple): Translation => + new Translation(locale, Object.values(tuple)); + + private static fromString = (locale: Locale, string: string): Translation => + new Translation(locale, [string]); + + public render(parameters: undefined | Parameters, quantity: number): string { + return parameters + ? substitutePlaceholders(this.byQuantity(quantity), parameters) + : this.byQuantity(quantity); + } + + private byQuantity(quantity: number): string { + const index = this.locale.getPluralFormIndexForQuantity(quantity); + + return this.value[index] ?? this.value[0] ?? ''; + } +} diff --git a/packages/neos-ui-i18n/src/model/TranslationAddress.spec.ts b/packages/neos-ui-i18n/src/model/TranslationAddress.spec.ts new file mode 100644 index 0000000000..f10ae30a7f --- /dev/null +++ b/packages/neos-ui-i18n/src/model/TranslationAddress.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 {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'); + expect(translationAddress.fullyQualified).toBe('Some.Package:SomeSource:some.transunit.id'); + }); + + 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'); + expect(translationAddress.fullyQualified).toBe('Some.Package:SomeSource:some.transunit.id'); + }); + + 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/model/TranslationAddress.ts b/packages/neos-ui-i18n/src/model/TranslationAddress.ts new file mode 100644 index 0000000000..a563b3889b --- /dev/null +++ b/packages/neos-ui-i18n/src/model/TranslationAddress.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. + */ +const TRANSLATION_ADDRESS_SEPARATOR = ':'; + +export class TranslationAddress { + private constructor( + public readonly id: string, + public readonly sourceName: string, + public readonly packageKey: string, + public readonly fullyQualified: string + ) {} + + public static create = (props: { + id: string; + sourceName: string; + packageKey: string; + }): TranslationAddress => + 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); + if (parts.length !== 3) { + throw TranslationAddressIsInvalid + .becauseStringDoesNotAdhereToExpectedFormat(string); + } + + const [packageKey, sourceName, id] = parts; + + return new TranslationAddress(id, sourceName, packageKey, string); + } +} + +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.` + ); + } +} diff --git a/packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts b/packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts new file mode 100644 index 0000000000..7f7c027b82 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts @@ -0,0 +1,35 @@ +/* + * 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 './Locale'; +import {Translation} from './Translation'; +import {TranslationAddress} from './TranslationAddress'; + +import {TranslationRepository} from './TranslationRepository'; + +describe('TranslationRepository', () => { + 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 + } + } + }); + const translationAddressThatCanBeFound = TranslationAddress.fromString('Neos.Neos:Main:someLabel'); + const translationAddressThatCannotBeFound = TranslationAddress.fromString('Vendor.Site:Main:someLabel'); + + expect(translationRepository.findOneByAddress(translationAddressThatCannotBeFound)) + .toBeNull(); + expect(translationRepository.findOneByAddress(translationAddressThatCanBeFound)) + .toStrictEqual(Translation.fromDTO(locale_en_US, 'The Translation')); + }); +}); diff --git a/packages/neos-ui-i18n/src/model/TranslationRepository.ts b/packages/neos-ui-i18n/src/model/TranslationRepository.ts new file mode 100644 index 0000000000..36c32ae069 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/TranslationRepository.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 {Locale} from './Locale'; +import {Translation, type TranslationDTO} from './Translation'; +import type {TranslationAddress} from './TranslationAddress'; + +export type TranslationsDTO = Record>>; + +export class TranslationRepository { + private _translationsByAddress: Record = {}; + + private constructor( + private readonly locale: Locale, + private readonly translations: TranslationsDTO + ) {} + + public static fromDTO = (locale: Locale, translations: TranslationsDTO): TranslationRepository => + new TranslationRepository(locale, translations); + + 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 translationDTO = this.translations[packageKey]?.[sourceName]?.[id] ?? null; + const translation = translationDTO + ? Translation.fromDTO(this.locale, translationDTO) + : null; + this._translationsByAddress[address.fullyQualified] = translation; + + return translation; + } +} 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..acaae67935 --- /dev/null +++ b/packages/neos-ui-i18n/src/model/index.ts @@ -0,0 +1,17 @@ +/* + * 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 {Locale} from './Locale'; +export type {Parameters} from './Parameters'; +export {Translation, TranslationDTO} from './Translation'; +export {TranslationAddress} from './TranslationAddress'; +export { + TranslationRepository, + type TranslationsDTO +} from './TranslationRepository'; 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 f5f45ef896..0000000000 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.js +++ /dev/null @@ -1,111 +0,0 @@ -import {SynchronousRegistry} from '@neos-project/neos-ui-extensibility/src/registry'; - -import logger from '@neos-project/utils-logger'; - -const errorCache = {}; - -const getTranslationAddress = function (id, packageKey, sourceName) { - if (id && id.indexOf(':') !== -1) { - return id.split(':'); - } - - return [packageKey, sourceName, id]; -}; - -/** - * 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; - - // 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 = {}; - - 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.js deleted file mode 100644 index 32f4038a29..0000000000 --- a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.js +++ /dev/null @@ -1,180 +0,0 @@ -import I18nRegistry from './I18nRegistry'; - -test(` - Host > Containers > I18n: should display configured fallback, if no translation - was found.`, () => { - const registry = new I18nRegistry(); - const actual = registry.translate('', 'The Fallback'); - - expect(actual).toBe('The Fallback'); -}); - -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 actual = registry.translate('The Trans Unit ID'); - - expect(actual).toBe('The Trans Unit ID'); -}); - -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'); -}); - -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'); -}); - -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'); -}); - -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'); -}); - -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'); -}); - -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'); -}); - -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'); -}); - -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); - - expect(actual).toBe('Singular Translation'); -}); diff --git a/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts new file mode 100644 index 0000000000..0c926dd8bb --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.spec.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 '../global'; + +import {I18nRegistry} from './I18nRegistry'; + +beforeAll(() => { + 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 + '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 + was found.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('', 'The Fallback'); + + expect(actual).toBe('The Fallback'); +}); + +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 actual = registry.translate('The Trans Unit ID'); + + expect(actual).toBe('The Trans Unit ID'); +}); + +test(` + Host > Containers > I18n: should display the translated string, if a translation + was found via short-string.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:someLabel'); + + expect(actual).toBe('The Translation'); +}); + +test(` + Host > Containers > I18n: should display the translated string, if a translation + was found via full-length prop description.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main'); + + expect(actual).toBe('The Translation'); +}); + +test(` + 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('Plural Translation'); +}); + +test(` + 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('Plural Translation'); +}); + +test(` + Host > Containers > I18n: Should display singular when quantity is one.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 1); + + expect(actual).toBe('Singular Translation'); +}); + +test(` + Host > Containers > I18n: Should display plural when quantity is two.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:pluralLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); + + expect(actual).toBe('Plural Translation'); +}); + +test(` + Host > Containers > I18n: Should display regular language label even when no plural exists and a quantity is defined.`, () => { + const registry = new I18nRegistry(''); + const actual = registry.translate('Neos.Neos:Main:someLabel', undefined, undefined, 'Neos.Neos', 'Main', 2); + + expect(actual).toBe('The Translation'); +}); + +test(` + Host > Containers > I18n: Should display singular when quantity is higher but plural label is not defined`, () => { + const registry = new I18nRegistry(''); + 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 new file mode 100644 index 0000000000..b97b6ba2fc --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/I18nRegistry.ts @@ -0,0 +1,214 @@ +/* + * 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 {requireGlobals} from '../global'; +import type {Translation, TranslationAddress} from '../model'; + +import {getTranslationAddress} from './getTranslationAddress'; +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 + * 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 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 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; + + /** + * Retrieves a the translation string that is identified by the given + * 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 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. + * + * @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. + */ + translate(transUnitIdOrFullyQualifiedTranslationAddress: string, fallback: string): string; + + /** + * Retrieves a the translation string that is identified by the given + * 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 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 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. + * + * @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 + */ + translate( + transUnitIdOrFullyQualifiedTranslationAddress: string, + fallback: undefined | string, + parameters: LegacyParameters + ): 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 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. + * + * @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 + * @param {string} packageKey The key of the package in which to look for the translation file + */ + translate( + transUnitId: string, + fallback: undefined | string, + parameters: undefined | LegacyParameters, + 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 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. + * + * @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 + * @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 | LegacyParameters, + 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 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 + * 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. + * + * @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 + * @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 | LegacyParameters, + packageKey: string, + sourceName: string, + quantity: number + ): string; + + translate( + transUnitIdOrFullyQualifiedTranslationAddress: string, + explicitlyProvidedFallback?: string, + parameters?: LegacyParameters, + explicitlyProvidedPackageKey: string = 'Neos.Neos', + explicitlyProvidedSourceName: string = 'Main', + quantity: number = 0 + ) { + const fallback = explicitlyProvidedFallback || transUnitIdOrFullyQualifiedTranslationAddress; + const translationAddess = getTranslationAddress(transUnitIdOrFullyQualifiedTranslationAddress, explicitlyProvidedPackageKey, explicitlyProvidedSourceName); + const translation = this.getTranslation(translationAddess); + if (translation === null) { + this.logTranslationNotFound(translationAddess, fallback); + return fallback; + } + + return translation.render(parameters as any, quantity); + } + + private logTranslationNotFound(address: TranslationAddress, fallback: string) { + if (!errorCache[address.fullyQualified]) { + 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 { + const {translationRepository} = requireGlobals(); + return translationRepository.findOneByAddress(address) ?? null; + } +} + +/** + * @deprecated Use `import {tranlsate} from '@neos-project/neos-ui-i18n'` instead + */ +export const i18nRegistry = new I18nRegistry('The i18n registry'); diff --git a/packages/neos-ui-i18n/src/registry/LegacyParameters.ts b/packages/neos-ui-i18n/src/registry/LegacyParameters.ts new file mode 100644 index 0000000000..843f953ed2 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/LegacyParameters.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 LegacyParameters = unknown[] | Record; 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..3707e20377 --- /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 translationAddress = getTranslationAddress( + '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('provides a translation address tuple if given three separate parameters', () => { + const translationAddress = getTranslationAddress( + 'some.transunit.id', + 'Some.Package', + 'SomeSource' + ); + + 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 new file mode 100644 index 0000000000..4a3a742824 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/getTranslationAddress.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 {TranslationAddress} from '../model'; + +export function getTranslationAddress( + fullyQualifiedTransUnitId: string +): TranslationAddress; +export function getTranslationAddress( + transUnitId: string, + packageKey: string, + sourceName: string +): TranslationAddress; +export function getTranslationAddress( + id: string, + packageKey?: string, + sourceName?: string +) { + if (id && id.indexOf(':') !== -1) { + 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 TranslationAddress.create({packageKey, sourceName, id}); +} 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..f1f308166b --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/index.ts @@ -0,0 +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. + */ +export type {I18nRegistry} from './I18nRegistry'; +export {i18nRegistry} from './I18nRegistry'; + +export type {LegacyParameters} from './LegacyParameters'; + +export {substitutePlaceholders} from './substitutePlaceholders'; 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..26fa7bef50 --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts @@ -0,0 +1,182 @@ +/* + * 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('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'); + }); + + 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('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( + '{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..ad7c57077e --- /dev/null +++ b/packages/neos-ui-i18n/src/registry/substitutePlaceholders.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. + */ +import logger from '@neos-project/utils-logger'; + +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: LegacyParameters) { + 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; + } + 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') { + // 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(''); +}; 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..a25e62472c --- /dev/null +++ b/packages/neos-ui-i18n/src/translate.spec.ts @@ -0,0 +1,308 @@ +/* + * 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, teardownI18n} from './global'; +import {translate} from './translate'; + +/* eslint-disable max-nested-callbacks */ +describe('translate', () => { + describe('when no translation was found', () => { + beforeAll(() => { + setupI18n('en-US', 'one,other', {}); + }); + afterAll(() => { + teardownI18n(); + }); + + 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(() => { + setupI18n('en-US', 'one,other', { + '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(() => { + teardownI18n(); + }); + + 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(() => { + setupI18n('ar-EG', 'zero,one,two,few,many', { + '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(() => { + teardownI18n(); + }); + + 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..82c1a7dc82 --- /dev/null +++ b/packages/neos-ui-i18n/src/translate.ts @@ -0,0 +1,68 @@ +/* + * 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 {requireGlobals} from './global'; +import {TranslationAddress, type Parameters} from './model'; +import {substitutePlaceholders} from './registry'; + +/** + * 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 {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: Parameters = [], + quantity: number = 0 +): string { + const {translationRepository} = requireGlobals(); + 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: Parameters +) { + const fallbackHasPluralForms = Array.isArray(fallback); + let result: string; + if (fallbackHasPluralForms) { + result = quantity === 1 ? fallback[0] : fallback[1]; + } else { + result = fallback; + } + + return substitutePlaceholders(result, parameters); +} 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/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/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..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,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 {setupI18n} from '@neos-project/neos-ui-i18n'; const store = createStore(state => state, {}); +beforeAll(() => { + setupI18n('en-US', 'one,other', { + '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..e1b7a97c10 100644 --- a/packages/neos-ui/src/index.js +++ b/packages/neos-ui/src/index.js @@ -10,6 +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 {initializeI18n} from '@neos-project/neos-ui-i18n'; import {showFlashMessage} from '@neos-project/neos-ui-error'; import { @@ -64,7 +65,7 @@ async function main() { await Promise.all([ loadNodeTypesSchema(), - loadTranslations(), + initializeI18n(), loadImpersonateStatus() ]); @@ -170,14 +171,6 @@ async function loadNodeTypesSchema() { nodeTypesRegistry.setRoles(roles); } -async function loadTranslations() { - const {getJsonResource} = backend.get().endpoints; - const i18nRegistry = globalRegistry.get('i18n'); - const translations = await getJsonResource(configuration.endpoints.translations); - - i18nRegistry.setTranslations(translations); -} - async function loadImpersonateStatus() { try { const {impersonateStatus} = backend.get().endpoints; diff --git a/yarn.lock b/yarn.lock index b540920254..a1d3d591f1 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 @@ -3654,8 +3655,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 @@ -6629,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" @@ -12222,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" @@ -14355,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