From 2bd465d3d0f0a97927ffd5a3e4bf449ba9142625 Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Fri, 29 Jan 2021 13:28:53 +0100 Subject: [PATCH 1/2] i18n: add new APIs: defaultI18n, getLocaleData, subscribe, hasTranslation --- packages/i18n/CHANGELOG.md | 7 + packages/i18n/README.md | 48 ++++++- packages/i18n/src/create-i18n.js | 121 +++++++++++++++- packages/i18n/src/default-i18n.js | 45 +++++- packages/i18n/src/index.js | 13 +- packages/i18n/src/test/create-i18n.js | 168 +++++++++++++++++++---- packages/i18n/src/test/subscribe-i18n.js | 44 ++++++ 7 files changed, 404 insertions(+), 42 deletions(-) create mode 100644 packages/i18n/src/test/subscribe-i18n.js diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index ca9852b7e2f666..604c284d0b96ee 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Enhancements + +- Export the default `I18n` instance as `defaultI18n`, in addition to already exported bound methods. +- Add new `getLocaleData` method to get the internal Tannin locale data object. +- Add new `subscribe` method to subscribe to changes in the internal locale data. +- Add new `hasTranslation` method to determine whether a translation for a string is available. + ## 3.17.0 (2020-12-17) ### Enhancements diff --git a/packages/i18n/README.md b/packages/i18n/README.md index a713321f1edfbd..6984e44e761a73 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -35,12 +35,46 @@ _Parameters_ - _initialData_ `[LocaleData]`: Locale data configuration. - _initialDomain_ `[string]`: Domain for which configuration applies. -- _hooks_ `[ApplyFiltersInterface]`: Hooks implementation. +- _hooks_ `[Hooks]`: Hooks implementation. _Returns_ - `I18n`: I18n instance +# **defaultI18n** + +Default, singleton instance of `I18n`. + +# **getLocaleData** + +Returns locale data by domain in a Jed-formatted JSON object shape. + +_Related_ + +- + +_Parameters_ + +- _domain_ `[string]`: Domain for which to get the data. + +_Returns_ + +- `LocaleData`: Locale data. + +# **hasTranslation** + +Check if there is a translation for a given string (in singular form). + +_Parameters_ + +- _single_ `string`: Singular form of the string to look up. +- _context_ `[string]`: Context information for the translators. +- _domain_ `[string]`: Domain to retrieve the translated text. + +_Returns_ + +- `boolean`: Whether the translation exists or not. + # **isRTL** Check if current locale is RTL. @@ -86,6 +120,18 @@ _Returns_ - `string`: The formatted string. +# **subscribe** + +Subscribes to changes of locale data + +_Parameters_ + +- _callback_ `SubscribeCallback`: Subscription callback + +_Returns_ + +- `UnsubscribeCallback`: Unsubscribe callback + # **\_n** Translates and retrieves the singular or plural form based on the supplied diff --git a/packages/i18n/src/create-i18n.js b/packages/i18n/src/create-i18n.js index de1440c4ace56a..0ae0a959e5b6bf 100644 --- a/packages/i18n/src/create-i18n.js +++ b/packages/i18n/src/create-i18n.js @@ -22,13 +22,35 @@ const DEFAULT_LOCALE_DATA = { }, }; +/* + * Regular expression that matches i18n hooks like `i18n.gettext`, `i18n.ngettext`, + * `i18n.gettext_domain` or `i18n.ngettext_with_context` + */ +const I18N_HOOK_REGEXP = /^i18n\.(n?)gettext(_|$)/; + +/** + * @typedef {(domain?: string) => LocaleData} GetLocaleData + * + * Returns locale data by domain in a + * Jed-formatted JSON object shape. + * + * @see http://messageformat.github.io/Jed/ + */ /** * @typedef {(data?: LocaleData, domain?: string) => void} SetLocaleData + * * Merges locale data into the Tannin instance by domain. Accepts data in a * Jed-formatted JSON object shape. * * @see http://messageformat.github.io/Jed/ */ +/** @typedef {() => void} SubscribeCallback */ +/** @typedef {() => void} UnsubscribeCallback */ +/** + * @typedef {(callback: SubscribeCallback) => UnsubscribeCallback} Subscribe + * + * Subscribes to changes of locale data + */ /** * @typedef {(domain?: string) => string} GetFilterDomain * Retrieve the domain to use when calling domain-specific filters. @@ -74,15 +96,20 @@ const DEFAULT_LOCALE_DATA = { * including English (`en`, `en-US`, `en-GB`, etc.), Spanish (`es`), and French (`fr`). */ /** - * @typedef {{ applyFilters: (hookName:string, ...args: unknown[]) => unknown}} ApplyFiltersInterface + * @typedef {(single: string, context?: string, domain?: string) => boolean} HasTranslation + * + * Check if there is a translation for a given string in singular form. */ +/** @typedef {import('@wordpress/hooks').Hooks} Hooks */ /** * An i18n instance * * @typedef I18n + * @property {GetLocaleData} getLocaleData Returns locale data by domain in a Jed-formatted JSON object shape. * @property {SetLocaleData} setLocaleData Merges locale data into the Tannin instance by domain. Accepts data in a * Jed-formatted JSON object shape. + * @property {Subscribe} subscribe Subscribes to changes of Tannin locale data. * @property {__} __ Retrieve the translation of text. * @property {_x} _x Retrieve translated string with gettext context. * @property {_n} _n Translates and retrieves the singular or plural form based on the supplied @@ -90,6 +117,7 @@ const DEFAULT_LOCALE_DATA = { * @property {_nx} _nx Translates and retrieves the singular or plural form based on the supplied * number, with gettext context. * @property {IsRtl} isRTL Check if current locale is RTL. + * @property {HasTranslation} hasTranslation Check if there is a translation for a given string. */ /** @@ -97,7 +125,7 @@ const DEFAULT_LOCALE_DATA = { * * @param {LocaleData} [initialData] Locale data configuration. * @param {string} [initialDomain] Domain for which configuration applies. - * @param {ApplyFiltersInterface} [hooks] Hooks implementation. + * @param {Hooks} [hooks] Hooks implementation. * @return {I18n} I18n instance */ export const createI18n = ( initialData, initialDomain, hooks ) => { @@ -108,8 +136,31 @@ export const createI18n = ( initialData, initialDomain, hooks ) => { */ const tannin = new Tannin( {} ); - /** @type {SetLocaleData} */ - const setLocaleData = ( data, domain = 'default' ) => { + const listeners = new Set(); + + const notifyListeners = () => { + listeners.forEach( ( listener ) => listener() ); + }; + + /** + * Subscribe to changes of locale data. + * + * @param {SubscribeCallback} callback Subscription callback. + * @return {UnsubscribeCallback} Unsubscribe callback. + */ + const subscribe = ( callback ) => { + listeners.add( callback ); + return () => listeners.delete( callback ); + }; + + /** @type {GetLocaleData} */ + const getLocaleData = ( domain = 'default' ) => tannin.data[ domain ]; + + /** + * @param {LocaleData} [data] + * @param {string} [domain] + */ + const doSetLocaleData = ( data, domain = 'default' ) => { tannin.data[ domain ] = { ...DEFAULT_LOCALE_DATA, ...tannin.data[ domain ], @@ -124,6 +175,12 @@ export const createI18n = ( initialData, initialDomain, hooks ) => { }; }; + /** @type {SetLocaleData} */ + const setLocaleData = ( data, domain ) => { + doSetLocaleData( data, domain ); + notifyListeners(); + }; + /** * Wrapper for Tannin's `dcnpgettext`. Populates default locale data if not * otherwise previously assigned. @@ -147,7 +204,8 @@ export const createI18n = ( initialData, initialDomain, hooks ) => { number ) => { if ( ! tannin.data[ domain ] ) { - setLocaleData( undefined, domain ); + // use `doSetLocaleData` to set silently, without notifying listeners + doSetLocaleData( undefined, domain ); } return tannin.dcnpgettext( domain, context, single, plural, number ); @@ -320,16 +378,69 @@ export const createI18n = ( initialData, initialDomain, hooks ) => { return 'rtl' === _x( 'ltr', 'text direction' ); }; + /** @type {HasTranslation} */ + const hasTranslation = ( single, context, domain ) => { + const key = context ? context + '\u0004' + single : single; + let result = !! tannin.data?.[ domain ?? 'default' ]?.[ key ]; + if ( hooks ) { + /** + * Filters the presence of a translation in the locale data. + * + * @param {boolean} hasTranslation Whether the translation is present or not.. + * @param {string} single The singular form of the translated text (used as key in locale data) + * @param {string} context Context information for the translators. + * @param {string} domain Text domain. Unique identifier for retrieving translated strings. + */ + result = /** @type { boolean } */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.has_translation', + result, + single, + context, + domain + ) + ); + + result = /** @type { boolean } */ ( + /** @type {*} */ hooks.applyFilters( + 'i18n.has_translation_' + getFilterDomain( domain ), + result, + single, + context, + domain + ) + ); + } + return result; + }; + if ( initialData ) { setLocaleData( initialData, initialDomain ); } + if ( hooks ) { + /** + * @param {string} hookName + */ + const onHookAddedOrRemoved = ( hookName ) => { + if ( I18N_HOOK_REGEXP.test( hookName ) ) { + notifyListeners(); + } + }; + + hooks.addAction( 'hookAdded', 'core/i18n', onHookAddedOrRemoved ); + hooks.addAction( 'hookRemoved', 'core/i18n', onHookAddedOrRemoved ); + } + return { + getLocaleData, setLocaleData, + subscribe, __, _x, _n, _nx, isRTL, + hasTranslation, }; }; diff --git a/packages/i18n/src/default-i18n.js b/packages/i18n/src/default-i18n.js index 5099a62b04b7fc..91e9a1f3acdd92 100644 --- a/packages/i18n/src/default-i18n.js +++ b/packages/i18n/src/default-i18n.js @@ -1,14 +1,19 @@ /** - * WordPress dependencies + * Internal dependencies */ -import { applyFilters } from '@wordpress/hooks'; +import { createI18n } from './create-i18n'; /** - * Internal dependencies + * WordPress dependencies */ -import { createI18n } from './create-i18n'; +import { defaultHooks } from '@wordpress/hooks'; -const i18n = createI18n( undefined, undefined, { applyFilters } ); +const i18n = createI18n( undefined, undefined, defaultHooks ); + +/** + * Default, singleton instance of `I18n`. + */ +export default i18n; /* * Comments in this file are duplicated from ./i18n due to @@ -17,7 +22,19 @@ const i18n = createI18n( undefined, undefined, { applyFilters } ); /** * @typedef {import('./create-i18n').LocaleData} LocaleData + * @typedef {import('./create-i18n').SubscribeCallback} SubscribeCallback + * @typedef {import('./create-i18n').UnsubscribeCallback} UnsubscribeCallback + */ + +/** + * Returns locale data by domain in a Jed-formatted JSON object shape. + * + * @see http://messageformat.github.io/Jed/ + * + * @param {string} [domain] Domain for which to get the data. + * @return {LocaleData} Locale data. */ +export const getLocaleData = i18n.getLocaleData.bind( i18n ); /** * Merges locale data into the Tannin instance by domain. Accepts data in a @@ -30,6 +47,14 @@ const i18n = createI18n( undefined, undefined, { applyFilters } ); */ export const setLocaleData = i18n.setLocaleData.bind( i18n ); +/** + * Subscribes to changes of locale data + * + * @param {SubscribeCallback} callback Subscription callback + * @return {UnsubscribeCallback} Unsubscribe callback + */ +export const subscribe = i18n.subscribe.bind( i18n ); + /** * Retrieve the translation of text. * @@ -99,3 +124,13 @@ export const _nx = i18n._nx.bind( i18n ); * @return {boolean} Whether locale is RTL. */ export const isRTL = i18n.isRTL.bind( i18n ); + +/** + * Check if there is a translation for a given string (in singular form). + * + * @param {string} single Singular form of the string to look up. + * @param {string} [context] Context information for the translators. + * @param {string} [domain] Domain to retrieve the translated text. + * @return {boolean} Whether the translation exists or not. + */ +export const hasTranslation = i18n.hasTranslation.bind( i18n ); diff --git a/packages/i18n/src/index.js b/packages/i18n/src/index.js index e5e04852a3c227..6f89ce413e357c 100644 --- a/packages/i18n/src/index.js +++ b/packages/i18n/src/index.js @@ -1,3 +1,14 @@ export { sprintf } from './sprintf'; export * from './create-i18n'; -export { setLocaleData, __, _x, _n, _nx, isRTL } from './default-i18n'; +export { + default as defaultI18n, + setLocaleData, + getLocaleData, + subscribe, + __, + _x, + _n, + _nx, + isRTL, + hasTranslation, +} from './default-i18n'; diff --git a/packages/i18n/src/test/create-i18n.js b/packages/i18n/src/test/create-i18n.js index 6a82285cc7f6d4..439c60a06c6541 100644 --- a/packages/i18n/src/test/create-i18n.js +++ b/packages/i18n/src/test/create-i18n.js @@ -1,5 +1,10 @@ /* eslint-disable @wordpress/i18n-text-domain, @wordpress/i18n-translator-comments */ +/** + * WordPress dependencies + */ +import { createHooks } from '@wordpress/hooks'; + /** * Internal dependencies */ @@ -192,62 +197,165 @@ describe( 'createI18n', () => { } ); describe( 'i18n filters', () => { + function createHooksWithI18nFilters() { + const hooks = createHooks(); + hooks.addFilter( + 'i18n.gettext', + 'test', + ( translation ) => translation + '/i18n.gettext' + ); + hooks.addFilter( + 'i18n.gettext_default', + 'test', + ( translation ) => translation + '/i18n.gettext_default' + ); + hooks.addFilter( + 'i18n.gettext_domain', + 'test', + ( translation ) => translation + '/i18n.gettext_domain' + ); + + hooks.addFilter( + 'i18n.ngettext', + 'test', + ( translation ) => translation + '/i18n.ngettext' + ); + hooks.addFilter( + 'i18n.ngettext_default', + 'test', + ( translation ) => translation + '/i18n.ngettext_default' + ); + hooks.addFilter( + 'i18n.ngettext_domain', + 'test', + ( translation ) => translation + '/i18n.ngettext_domain' + ); + + hooks.addFilter( + 'i18n.gettext_with_context', + 'test', + ( translation, text, context ) => + translation + `/i18n.gettext_with_${ context }` + ); + hooks.addFilter( + 'i18n.gettext_with_context_default', + 'test', + ( translation, text, context ) => + translation + `/i18n.gettext_with_${ context }_default` + ); + hooks.addFilter( + 'i18n.gettext_with_context_domain', + 'test', + ( translation, text, context ) => + translation + `/i18n.gettext_with_${ context }_domain` + ); + + hooks.addFilter( + 'i18n.ngettext_with_context', + 'test', + ( translation, single, plural, number, context ) => + translation + `/i18n.ngettext_with_${ context }` + ); + hooks.addFilter( + 'i18n.ngettext_with_context_default', + 'test', + ( translation, single, plural, number, context ) => + translation + `/i18n.ngettext_with_${ context }_default` + ); + hooks.addFilter( + 'i18n.ngettext_with_context_domain', + 'test', + ( translation, single, plural, number, context ) => + translation + `/i18n.ngettext_with_${ context }_domain` + ); + hooks.addFilter( + 'i18n.has_translation', + 'test', + ( hasTranslation, single, context, domain ) => { + if ( + single === 'Always' && + ! context && + ( domain ?? 'default' ) === 'default' + ) { + return true; + } + + return hasTranslation; + } + ); + return hooks; + } + test( '__() calls filters', () => { - const i18n = createI18n( undefined, undefined, { - applyFilters: ( filter, translation ) => translation + filter, - } ); + const hooks = createHooksWithI18nFilters(); + const i18n = createI18n( undefined, undefined, hooks ); + expect( i18n.__( 'hello' ) ).toEqual( - 'helloi18n.gettexti18n.gettext_default' + 'hello/i18n.gettext/i18n.gettext_default' ); expect( i18n.__( 'hello', 'domain' ) ).toEqual( - 'helloi18n.gettexti18n.gettext_domain' + 'hello/i18n.gettext/i18n.gettext_domain' ); } ); + test( '_x() calls filters', () => { - const i18n = createI18n( undefined, undefined, { - applyFilters: ( filter, translation ) => translation + filter, - } ); - expect( i18n._x( 'hello', 'context' ) ).toEqual( - 'helloi18n.gettext_with_contexti18n.gettext_with_context_default' + const hooks = createHooksWithI18nFilters(); + const i18n = createI18n( undefined, undefined, hooks ); + + expect( i18n._x( 'hello', 'ctx' ) ).toEqual( + 'hello/i18n.gettext_with_ctx/i18n.gettext_with_ctx_default' ); - expect( i18n._x( 'hello', 'context', 'domain' ) ).toEqual( - 'helloi18n.gettext_with_contexti18n.gettext_with_context_domain' + expect( i18n._x( 'hello', 'ctx', 'domain' ) ).toEqual( + 'hello/i18n.gettext_with_ctx/i18n.gettext_with_ctx_domain' ); } ); + test( '_n() calls filters', () => { - const i18n = createI18n( undefined, undefined, { - applyFilters: ( filter, translation ) => translation + filter, - } ); + const hooks = createHooksWithI18nFilters(); + const i18n = createI18n( undefined, undefined, hooks ); + expect( i18n._n( 'hello', 'hellos', 1 ) ).toEqual( - 'helloi18n.ngettexti18n.ngettext_default' + 'hello/i18n.ngettext/i18n.ngettext_default' ); expect( i18n._n( 'hello', 'hellos', 1, 'domain' ) ).toEqual( - 'helloi18n.ngettexti18n.ngettext_domain' + 'hello/i18n.ngettext/i18n.ngettext_domain' ); expect( i18n._n( 'hello', 'hellos', 2 ) ).toEqual( - 'hellosi18n.ngettexti18n.ngettext_default' + 'hellos/i18n.ngettext/i18n.ngettext_default' ); expect( i18n._n( 'hello', 'hellos', 2, 'domain' ) ).toEqual( - 'hellosi18n.ngettexti18n.ngettext_domain' + 'hellos/i18n.ngettext/i18n.ngettext_domain' ); } ); + test( '_nx() calls filters', () => { - const i18n = createI18n( undefined, undefined, { - applyFilters: ( filter, translation ) => translation + filter, - } ); - expect( i18n._nx( 'hello', 'hellos', 1, 'context' ) ).toEqual( - 'helloi18n.ngettext_with_contexti18n.ngettext_with_context_default' + const hooks = createHooksWithI18nFilters(); + const i18n = createI18n( undefined, undefined, hooks ); + + expect( i18n._nx( 'hello', 'hellos', 1, 'ctx' ) ).toEqual( + 'hello/i18n.ngettext_with_ctx/i18n.ngettext_with_ctx_default' ); - expect( i18n._nx( 'hello', 'hellos', 1, 'context', 'domain' ) ).toEqual( - 'helloi18n.ngettext_with_contexti18n.ngettext_with_context_domain' + expect( i18n._nx( 'hello', 'hellos', 1, 'ctx', 'domain' ) ).toEqual( + 'hello/i18n.ngettext_with_ctx/i18n.ngettext_with_ctx_domain' ); - expect( i18n._nx( 'hello', 'hellos', 2, 'context' ) ).toEqual( - 'hellosi18n.ngettext_with_contexti18n.ngettext_with_context_default' + expect( i18n._nx( 'hello', 'hellos', 2, 'ctx' ) ).toEqual( + 'hellos/i18n.ngettext_with_ctx/i18n.ngettext_with_ctx_default' ); - expect( i18n._nx( 'hello', 'hellos', 2, 'context', 'domain' ) ).toEqual( - 'hellosi18n.ngettext_with_contexti18n.ngettext_with_context_domain' + expect( i18n._nx( 'hello', 'hellos', 2, 'ctx', 'domain' ) ).toEqual( + 'hellos/i18n.ngettext_with_ctx/i18n.ngettext_with_ctx_domain' ); } ); + + test( 'hasTranslation() calls filters', () => { + const hooks = createHooksWithI18nFilters(); + const { hasTranslation } = createI18n( frenchLocale, undefined, hooks ); + + expect( hasTranslation( 'hello' ) ).toBe( true ); + expect( hasTranslation( 'hello', 'not a greeting' ) ).toBe( false ); + expect( hasTranslation( 'Always' ) ).toBe( true ); + expect( hasTranslation( 'Always', 'other context' ) ).toBe( false ); + expect( hasTranslation( 'Always', undefined, 'domain' ) ).toBe( false ); + } ); } ); /* eslint-enable @wordpress/i18n-text-domain, @wordpress/i18n-translator-comments */ diff --git a/packages/i18n/src/test/subscribe-i18n.js b/packages/i18n/src/test/subscribe-i18n.js new file mode 100644 index 00000000000000..93103f22fd43e6 --- /dev/null +++ b/packages/i18n/src/test/subscribe-i18n.js @@ -0,0 +1,44 @@ +/** + * Internal dependencies + */ +import { createI18n } from '..'; + +/** + * WordPress dependencies + */ +import { createHooks } from '@wordpress/hooks'; + +describe( 'i18n updates', () => { + it( 'updates on setLocaleData', () => { + const hooks = createHooks(); + const i18n = createI18n( undefined, undefined, hooks ); + + const doneTranslations = []; + + function doTranslation() { + doneTranslations.push( i18n.__( 'original' ) ); + } + + i18n.subscribe( doTranslation ); + + // do translation on empty instance with no translation data + doTranslation(); + + // set translation data + i18n.setLocaleData( { + original: [ 'translated' ], + } ); + + // add a filter and then remove it + const filter = ( text ) => '[' + text + ']'; + hooks.addFilter( 'i18n.gettext', 'test', filter ); + hooks.removeFilter( 'i18n.gettext', 'test', filter ); + + expect( doneTranslations ).toEqual( [ + 'original', // no translations before setLocaleData + 'translated', // after setLocaleData + '[translated]', // after addFilter + 'translated', // after removeFilter + ] ); + } ); +} ); From a245ae0c4b0171ea158d9c2fb56501f1e0fcb98d Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Fri, 5 Feb 2021 13:53:34 +0100 Subject: [PATCH 2/2] Notify listeners also on has_translation filter add/remove --- packages/i18n/src/create-i18n.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/i18n/src/create-i18n.js b/packages/i18n/src/create-i18n.js index 0ae0a959e5b6bf..dac57c9e54647b 100644 --- a/packages/i18n/src/create-i18n.js +++ b/packages/i18n/src/create-i18n.js @@ -24,9 +24,9 @@ const DEFAULT_LOCALE_DATA = { /* * Regular expression that matches i18n hooks like `i18n.gettext`, `i18n.ngettext`, - * `i18n.gettext_domain` or `i18n.ngettext_with_context` + * `i18n.gettext_domain` or `i18n.ngettext_with_context` or `i18n.has_translation`. */ -const I18N_HOOK_REGEXP = /^i18n\.(n?)gettext(_|$)/; +const I18N_HOOK_REGEXP = /^i18n\.(n?gettext|has_translation)(_|$)/; /** * @typedef {(domain?: string) => LocaleData} GetLocaleData