From 527203350e9279ad1b67678a3cbfc93b472967b5 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke <2522299+grebaldi@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:55:19 +0100 Subject: [PATCH] !!!FEATURE: Reform i18n mechanism (#3804) Prelude to: https://github.com/neos/neos-ui/pull/3773 refs: https://github.com/neos/neos-ui/issues/3119 This PR reforms the UI's i18n mechanism by exposing a new function `translate` directly from the `@neos-project/neos-ui-i18n` package. The function looks as follows: ```typescript function translate( fullyQualifiedTranslationAddressAsString: string, fallback: string | [string, string], parameters: Parameters = [], quantity: number = 0 ): string; ``` And can be used like this: ```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. ``` The new mechanism is completely independent from registries. There's no longer a need for injecting the `I18nRegistry` to use translations, instead `translate` can be used directly. `translate` will only work after the translations have been loaded during the bootstrapping process of `@neos-project/neos-ui`. If it is used too early, it throws an exception. The function now also properly supports plural rules (via [`Intl` Web API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)). Tha `I18nRegistry` had only supported English plurals, but with broken rules (quantity=0 yielded singular instead of plural). Other changes included in this PR are: - Everything in `@neos-project/neos-ui-i18n` is now TypeScript (save `manifest.js`) - The ``-component was deprecated - The `I18nRegistry` is now properly documented, but was also deprecated - The `xliff.json` endpoint is now discovered via ``-tag, instead of initial data - The ``-tag is marked with `prefetch` to slightly speed up the fetch request - The `@neos-project/neos-ui-i18n` now has a comprehensive README.md This change is breaking, because translations may have relied on the broken plural rules. It is also breaking, because the signature for substitution parameters has changed slightly - although this should be non-breaking at runtime. --- .eslintrc.js | 5 +- ...-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip | Bin 0 -> 30760 bytes ...-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip | Bin 0 -> 47935 bytes Classes/Presentation/ApplicationView.php | 14 + package.json | 1 + .../src/setupBrowserEnv.js | 3 +- packages/neos-ts-interfaces/package.json | 3 + packages/neos-ts-interfaces/src/index.ts | 7 +- .../SelectBox/selectBoxHelpers.spec.ts | 4 +- .../container/ErrorBoundary/ErrorBoundary.tsx | 2 +- packages/neos-ui-i18n/README.md | 244 ++++++++++++++ packages/neos-ui-i18n/package.json | 4 +- .../neos-ui-i18n/src/component/I18n.spec.tsx | 41 +++ packages/neos-ui-i18n/src/component/I18n.tsx | 43 +++ packages/neos-ui-i18n/src/component/index.ts | 10 + .../neos-ui-i18n/src/global/globals.spec.ts | 45 +++ packages/neos-ui-i18n/src/global/globals.ts | 64 ++++ packages/neos-ui-i18n/src/global/index.ts | 13 + .../src/global/initializeI18n.spec.ts | 196 +++++++++++ .../neos-ui-i18n/src/global/initializeI18n.ts | 113 +++++++ .../neos-ui-i18n/src/global/setupI18n.spec.ts | 45 +++ packages/neos-ui-i18n/src/global/setupI18n.ts | 37 +++ .../src/global/teardownI18n.spec.ts | 34 ++ .../neos-ui-i18n/src/global/teardownI18n.ts | 20 ++ packages/neos-ui-i18n/src/index.spec.js | 22 -- packages/neos-ui-i18n/src/index.ts | 16 + packages/neos-ui-i18n/src/index.tsx | 39 --- packages/neos-ui-i18n/src/manifest.js | 11 +- .../neos-ui-i18n/src/model/Locale.spec.ts | 50 +++ packages/neos-ui-i18n/src/model/Locale.ts | 66 ++++ packages/neos-ui-i18n/src/model/Parameters.ts | 14 + .../neos-ui-i18n/src/model/PluralRule.spec.ts | 39 +++ packages/neos-ui-i18n/src/model/PluralRule.ts | 66 ++++ .../src/model/PluralRules.spec.ts | 50 +++ .../neos-ui-i18n/src/model/PluralRules.ts | 52 +++ .../src/model/Translation.spec.ts | 142 ++++++++ .../neos-ui-i18n/src/model/Translation.ts | 48 +++ .../src/model/TranslationAddress.spec.ts | 44 +++ .../src/model/TranslationAddress.ts | 52 +++ .../src/model/TranslationRepository.spec.ts | 35 ++ .../src/model/TranslationRepository.ts | 44 +++ packages/neos-ui-i18n/src/model/index.ts | 17 + .../neos-ui-i18n/src/registry/I18nRegistry.js | 111 ------- .../src/registry/I18nRegistry.spec.js | 180 ---------- .../src/registry/I18nRegistry.spec.ts | 113 +++++++ .../neos-ui-i18n/src/registry/I18nRegistry.ts | 214 ++++++++++++ .../src/registry/LegacyParameters.ts | 10 + .../registry/getTranslationAddress.spec.ts | 34 ++ .../src/registry/getTranslationAddress.ts | 38 +++ packages/neos-ui-i18n/src/registry/index.js | 5 - packages/neos-ui-i18n/src/registry/index.ts | 15 + .../registry/substitutePlaceholders.spec.ts | 182 +++++++++++ .../src/registry/substitutePlaceholders.ts | 66 ++++ packages/neos-ui-i18n/src/translate.spec.ts | 308 ++++++++++++++++++ packages/neos-ui-i18n/src/translate.ts | 68 ++++ .../neos-ui-sagas/src/UI/Impersonate/index.js | 15 +- .../SyncWorkspaceDialog/ConflictList.tsx | 3 +- .../ResolutionStrategyConfirmationDialog.tsx | 4 +- .../ResolutionStrategySelectionDialog.tsx | 4 +- .../SyncWorkspaceDialog.tsx | 3 +- .../WorkspaceSync/WorkspaceSync.tsx | 3 +- .../Inspector/PropertyGroup/index.spec.js | 11 + packages/neos-ui/src/index.js | 11 +- yarn.lock | 27 +- 64 files changed, 2776 insertions(+), 404 deletions(-) create mode 100644 .yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip create mode 100644 .yarn/cache/node-fetch-npm-2.7.0-587d57004e-d76d2f5edb.zip create mode 100644 packages/neos-ui-i18n/README.md create mode 100644 packages/neos-ui-i18n/src/component/I18n.spec.tsx create mode 100644 packages/neos-ui-i18n/src/component/I18n.tsx create mode 100644 packages/neos-ui-i18n/src/component/index.ts create mode 100644 packages/neos-ui-i18n/src/global/globals.spec.ts create mode 100644 packages/neos-ui-i18n/src/global/globals.ts create mode 100644 packages/neos-ui-i18n/src/global/index.ts create mode 100644 packages/neos-ui-i18n/src/global/initializeI18n.spec.ts create mode 100644 packages/neos-ui-i18n/src/global/initializeI18n.ts create mode 100644 packages/neos-ui-i18n/src/global/setupI18n.spec.ts create mode 100644 packages/neos-ui-i18n/src/global/setupI18n.ts create mode 100644 packages/neos-ui-i18n/src/global/teardownI18n.spec.ts create mode 100644 packages/neos-ui-i18n/src/global/teardownI18n.ts delete mode 100644 packages/neos-ui-i18n/src/index.spec.js create mode 100644 packages/neos-ui-i18n/src/index.ts delete mode 100644 packages/neos-ui-i18n/src/index.tsx create mode 100644 packages/neos-ui-i18n/src/model/Locale.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/Locale.ts create mode 100644 packages/neos-ui-i18n/src/model/Parameters.ts create mode 100644 packages/neos-ui-i18n/src/model/PluralRule.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/PluralRule.ts create mode 100644 packages/neos-ui-i18n/src/model/PluralRules.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/PluralRules.ts create mode 100644 packages/neos-ui-i18n/src/model/Translation.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/Translation.ts create mode 100644 packages/neos-ui-i18n/src/model/TranslationAddress.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/TranslationAddress.ts create mode 100644 packages/neos-ui-i18n/src/model/TranslationRepository.spec.ts create mode 100644 packages/neos-ui-i18n/src/model/TranslationRepository.ts create mode 100644 packages/neos-ui-i18n/src/model/index.ts delete mode 100644 packages/neos-ui-i18n/src/registry/I18nRegistry.js delete mode 100644 packages/neos-ui-i18n/src/registry/I18nRegistry.spec.js create mode 100644 packages/neos-ui-i18n/src/registry/I18nRegistry.spec.ts create mode 100644 packages/neos-ui-i18n/src/registry/I18nRegistry.ts create mode 100644 packages/neos-ui-i18n/src/registry/LegacyParameters.ts create mode 100644 packages/neos-ui-i18n/src/registry/getTranslationAddress.spec.ts create mode 100644 packages/neos-ui-i18n/src/registry/getTranslationAddress.ts delete mode 100644 packages/neos-ui-i18n/src/registry/index.js create mode 100644 packages/neos-ui-i18n/src/registry/index.ts create mode 100644 packages/neos-ui-i18n/src/registry/substitutePlaceholders.spec.ts create mode 100644 packages/neos-ui-i18n/src/registry/substitutePlaceholders.ts create mode 100644 packages/neos-ui-i18n/src/translate.spec.ts create mode 100644 packages/neos-ui-i18n/src/translate.ts diff --git a/.eslintrc.js b/.eslintrc.js index cfff665808..b25c1fd719 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,6 +30,9 @@ module.exports = { 'default-case': 'off', 'no-mixed-operators': 'off', 'no-negated-condition': 'off', - 'complexity': 'off' + 'complexity': 'off', + + // This rule would prevent us from implementing meaningful value objects + 'no-useless-constructor': 'off' }, } diff --git a/.yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip b/.yarn/cache/cross-fetch-npm-4.0.0-9c67668db4-ecca4f37ff.zip new file mode 100644 index 0000000000000000000000000000000000000000..bddbef91a20ac444aa633999625641f72f7d327b GIT binary patch literal 30760 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≤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 Wl>i0%sT;D1Y@nE?C literal 0 HcmV?d00001 diff --git a/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..2eb3ee1758 --- /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