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..dac57c9e54647b 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` or `i18n.has_translation`.
+ */
+const I18N_HOOK_REGEXP = /^i18n\.(n?gettext|has_translation)(_|$)/;
+
+/**
+ * @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
+ ] );
+ } );
+} );