`:
```js
import { IntlProvider } from 'preact-i18n';
import definition from './fr.json';
render(
);
```
3. Use ` ` to translate string literals:
```js
import { Text } from 'preact-i18n';
// Assume the "stories" prop is a list of news stories.
const App = ({ stories=[] }) => (
{/* Default fallback text example: */}
World News
{/* Pluralization example: */}
);
```
That's it!
### Fallback Text
Rendering our example app with an empty definition _(or without the Provider)_ will attempt to use any text contained within `.. ` as fallback text.
In our example, this would mean rendering without a definition for `news.title` would produce `World News `.
If we provide a definition that has a `title` key inside a `news` object, that value will be rendered instead.
### Pluralization and Templating
In our example, `` is using `` as a convenient way to do pluralization and templating. In our definition, `news.totalStories` is an Object with pluralization keys. The values in that object will be selected based on an integer `plural` prop passed to ``.
Any definition value _(including pluralization values)_ can contain `{{field}}` placeholders. These placeholders get replaced with matched keys in an object passed as the `fields` prop. In our example, the "many" plural form is such a template - it will render `"5 articles"` when `fields={{ count: 5 }}`.
The available forms for specifying pluralization values are as follows:
- `"key": { "singular":"apple", "plural":"apples" }`
- `"key": { "none":"no apples", "one":"apple", "many":"apples" }`
- `"key": { "zero":"no apples", "one":"apple", "other":"apples" }`
- `"key": ["apples", "apple"]`
Taking `` from our example:
- `<.. plural={0}>` renders `Aucun article` _(no articles)_
- `<.. plural={1}>` renders `Un article` _(one article)_
- `<.. plural={2} fields={{ count: 2 }}>` renders `2 articles`
- `<.. plural={3} fields={{ count: 3 }}>` renders `3 articles`
In addition to [``](#Text), [`withText()`](#withText) and [``](#Localizer) provide ways to translate more than just display text - HTML attributes, component props, arbitrary Strings, etc.
## ESLint Plugin
A companion ESLint plugin exists, [eslint-plugin-preact-i18n](https://www.npmjs.com/package/eslint-plugin-preact-i18n), which has several rules that help spot common issues like un-i18n'd text, misconfigured tags, and missing keys, that are beneficial in spotting defects early and ensuring that your application is properly i18n'd.
* * *
## API
#### Table of Contents
- [IntlProvider](#intlprovider)
- [Parameters](#parameters)
- [Examples](#examples)
- [Localizer](#localizer)
- [Parameters](#parameters-1)
- [Examples](#examples-1)
- [MarkupText](#markuptext)
- [Parameters](#parameters-2)
- [Examples](#examples-2)
- [Text](#text)
- [Parameters](#parameters-3)
- [Examples](#examples-3)
- [withText](#withtext)
- [Parameters](#parameters-4)
- [Examples](#examples-4)
- [intl](#intl)
- [Parameters](#parameters-5)
### IntlProvider
`` is a nestable internationalization definition provider.
It exposes an Intl scope & definition into the tree,
making them available to descendant components.
> **Note:** When nested, gives precedence to keys higher up the tree!
> This means lower-level components can set their defaults by wrapping themselves
> in an ``, but still remain localizable by their parent components.
#### Parameters
- `props`
- `props.scope` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)?** Nest `definition` under a root key, and set the active scope for the tree (essentially prefixing all ` ` keys).
- `props.mark` **[Boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** If `true`, all `` elements will be shown with a red/green background indicating whether they have valid Intl keys. (optional, default `false`)
- `props.definition` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** Merge the given definition into the current intl definition, giving the _current_ definition precedence (i.e., only adding keys, acting as defaults) (optional, default `{}`)
#### Examples
```javascript
// generally imported from a JSON file:
let definition = {
foo: 'Le Feux'
};
The Foo
// This will render the text:
"Le Feux"
```
### Localizer
` ` is a Compositional Component.
It "renders" out any ` ` values in its child's props.
#### Parameters
- `props` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)**
- `props.children` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** Child components with props to localize.
- `context` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)**
- `context.intl` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** [internal] dictionary and scope info
#### Examples
```javascript
} />
// produces:
```
```javascript
Open Source Software }>
OSS
// produces:
OSS
```
### MarkupText
`` is just like [Text](#text) but it can also contain html markup in rendered strings. It wraps its contents in a `` tag.
#### Parameters
- `props` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** props
- `props.id` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Key to look up in intl dictionary, within any parent scopes (`$scope1.$scope2.$id`)
- `props.fields` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** Values to inject into template `{{fields}}`. Values in the `fields` object will be coerced to strings, with the exception of ` ` nodes which will be resolved to their translated value (optional, default `{}`)
- `props.plural` **[Number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)?** Integer "count", used to select plural forms
- `context` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)**
- `context.intl` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** [internal] dictionary and scope info
#### Examples
```javascript
// If there is no dictionary in context..
The Foo
// ..produces the vnode:
The Foo
```
```javascript
// Given a dictionary and some fields..
{{bar}}' }}>
The Foo
// ..produces the vnode:
Le Feux BEAR
```
```javascript
// Within a scope, both `id` and the definition are namespaced..
Feux' }}>
The Foo
// ..produces the vnode:
Le Feux
```
```javascript
// renders nothing if there is no key match and no fallback
// ..produces the vnode:
```
### Text
`` renders internationalized text.
It attempts to look up translated values from a dictionary in context.
Template strings can contain `{{field}}` placeholders,
which injects values from the `fields` prop.
When string lookup fails, renders its children as fallback text.
#### Parameters
- `props` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** props
- `props.id` **[String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Key to look up in intl dictionary, within any parent scopes (`$scope1.$scope2.$id`)
- `props.plural` **[Number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)?** Integer "count", used to select plural forms
- `props.fields` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** Values to inject into template `{{fields}}`. Values in the `fields` object will be coerced to strings, with the exception of ` ` nodes which will be resolved to their translated value (optional, default `{}`)
- `props.children`
- `context` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)**
- `context.intl` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** [internal] dictionary and scope info
#### Examples
```javascript
// If there is no dictionary in context..
The Foo
// ..produces the text:
"The Foo"
```
```javascript
// Given a dictionary and some fields..
The Foo
// ..produces the text:
"Le Feux BEAR"
```
```javascript
// Within a scope, both `id` and the definition are namespaced..
The Foo
// ..produces the text:
"Le Feux"
```
### withText
`@withText()` is a Higher Order Component, often used as a decorator.
It wraps a child component and passes it translations
based on a mapping to the dictionary & scope in context.
#### Parameters
- `mapping` **([Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object) \| [Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function) \| [String](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String))** Maps prop names to intl keys (or `` nodes).
#### Examples
```javascript
@withText({
placeholder: 'user.placeholder'
})
class Foo {
// now the `placeholder` prop is our localized String:
render({ placeholder }) {
return
}
}
```
```javascript
@withText({
placeholder: fallback text
})
class Foo {
render({ placeholder }) {
return
}
}
```
```javascript
@withText('user.placeholder')
class Foo {
// for Strings/Arrays, the last path segment becomes the prop name:
render({ placeholder }) {
return
}
}
```
Works with functional components, too
```javascript
const Foo = withText('user.placeholder')( props =>
)
```
getWrappedComponent() returns wrapped child Component
```javascript
const Foo = () =>
;
const WrappedFoo = withText('user.placeholer')(Foo);
WrappedFoo.getWrappedComponent() === Foo; // true
```
### intl
Higher-order function that creates an ` ` wrapper component for the given component. It
takes two forms depending on how many arguments it's given:
It can take a functional form like:
intl(ComponentToWrap, options)
or it can take an annotation form like:
#### Parameters
- `Child`
- `options` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** If there are two arguments, the second argument is Passed as `props` to ` `
- `options.scope` Nest `definition` under a root key, and set the active scope for the tree (essentially prefixing all ` ` keys).
- `options.definition` Merge the given definition into the current intl definition, giving the _current_ definition precedence (i.e., only adding keys, acting as defaults) (optional, default `{}`)
## License
[](https://app.fossa.io/projects/git%2Bgithub.com%2Fsynacor%2Fpreact-i18n?ref=badge_large)
================================================
FILE: karma.conf.js
================================================
/* eslint-disable */
var path = require('path');
var pkg = require('./package.json');
var REPORTER = process.env.REPORTER || (process.env.ENVIRONMENT==='ci' && 'junit') || '';
module.exports = function(config) {
config.set({
browsers: ['ChromeHeadless'],
frameworks: ['mocha', 'chai-sinon'],
reporters: ['mocha'].concat(REPORTER.split(/[, ]/)).filter(dedupe),
junitReporter: {
outputDir: 'test-reports', // results will be saved as $outputDir/$browserName.xml
suite: require('./package.json').name
},
mochaReporter: { showDiff: true },
files: [
'test/**/*.js'
],
preprocessors: {
'{src,test}/**/*': ['webpack', 'sourcemap']
},
webpack: {
mode: 'development',
devtool: 'inline-source-map',
resolve: {
alias: {
'preact-i18n': path.resolve(__dirname, process.env.TEST_PRODUCTION ? pkg.main : 'src')
}
},
module: {
rules: [{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
presets: [ '@babel/env' ],
plugins: [
['@babel/plugin-transform-react-jsx', { pragma: 'h' }]
]
}
}]
},
},
webpackServer: { stats: 'errors-only' }
});
};
// filters out empties && dupes
function dedupe(v, i, arr) { return v && arr.indexOf(v)===i; }
================================================
FILE: package.json
================================================
{
"name": "preact-i18n",
"amdName": "preactLocalize",
"version": "1.5.0",
"description": "Preact internationalization, done simply.",
"source": "src/index.js",
"module": "dist/preact-i18n.esm.js",
"main": "dist/preact-i18n.js",
"umd:main": "dist/preact-i18n.umd.js",
"scripts": {
"test": "npm-run-all --silent -p lint build test:unit -s test:prod",
"lint": "eslint src test",
"test:watch": "npm run test:unit -- --no-single-run",
"test:unit": "karma start karma.conf.js --single-run",
"test:prod": "cross-env TEST_PRODUCTION=true karma start karma.conf.js --single-run",
"build": "npm-run-all --silent clean -p rollup:* -p minify:* -s docs size",
"clean": "rimraf dist && mkdirp dist",
"rollup:cjs": "cross-var rollup -c rollup.config.js -m -f cjs -n $npm_package_amdName -i $npm_package_source -o $npm_package_main",
"rollup:umd": "cross-var rollup -c rollup.config.js -m -f umd -n $npm_package_amdName -i $npm_package_source -o $npm_package_umd_main",
"rollup:esm": "cross-var rollup -c rollup.config.js -m -f es --environment FORMAT:es -n $npm_package_amdName -i $npm_package_source -o $npm_package_module",
"minify:cjs": "cross-var uglifyjs $npm_package_main -c -m toplevel -o $npm_package_main --source-map content=$npm_package_main.map",
"minify:umd": "cross-var uglifyjs $npm_package_umd_main -c -m -o $npm_package_umd_main --source-map content=$npm_package_umd_main.map",
"docs": "documentation readme -q --section API src",
"size": "cross-var echo \"Gzipped Size: $(strip-json-comments --no-whitespace $npm_package_main | gzip-size --raw)\"",
"prepublishOnly": "cross-var npm run build -s && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags"
},
"repository": "synacor/preact-i18n",
"keywords": [
"intl",
"internationalization",
"localization"
],
"homepage": "https://github.com/synacor/preact-i18n",
"authors": [
"Jason Miller ",
"Bill Neff "
],
"license": "BSD-3-Clause",
"files": [
"src",
"dist"
],
"eslintConfig": {
"extends": "eslint-config-synacor",
"rules": {
"guard-for-in": 0
}
},
"devDependencies": {
"@babel/core": "^7.4.0",
"@babel/plugin-transform-react-jsx": "^7.3.0",
"@babel/preset-env": "^7.4.2",
"babel-eslint": "^10.0.1",
"babel-loader": "^8.0.5",
"buble": "^0.19.7",
"chai": "^4.2.0",
"chai-as-promised": "^7.1.1",
"cross-env": "^5.2.0",
"cross-var": "^1.1.0",
"documentation": "^9.3.1",
"documentation-theme-default": "^3.0.0",
"eslint": "^5.15.3",
"eslint-config-synacor": "^3.0.3",
"gzip-size-cli": "^3.0.0",
"karma": "^4.0.1",
"karma-chai": "^0.1.0",
"karma-chai-as-promised": "^0.1.2",
"karma-chai-sinon": "^0.1.5",
"karma-chrome-launcher": "^2.2.0",
"karma-cli": "^2.0.0",
"karma-junit-reporter": "^1.2.0",
"karma-mocha": "^1.3.0",
"karma-mocha-reporter": "^2.2.5",
"karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "^3.0.5",
"mkdirp": "^0.5.1",
"mocha": "^6.0.2",
"npm-run-all": "^4.1.5",
"preact": "^8.2.5",
"preact-jsx-chai": "^2.2.1",
"rimraf": "^2.6.3",
"rollup": "^1.7.0",
"rollup-plugin-buble": "^0.19.6",
"rollup-plugin-memory": "^3.0.0",
"sinon": "^7.3.0",
"sinon-chai": "^3.3.0",
"strip-json-comments-cli": "^1.0.1",
"uglify-js": "^3.5.1",
"webpack": "^4.29.6"
},
"dependencies": {
"dlv": "^1.1.3"
},
"peerDependencies": {
"preact": "<10"
}
}
================================================
FILE: rollup.config.js
================================================
/*eslint-disable*/
import memory from 'rollup-plugin-memory';
import buble from 'rollup-plugin-buble';
export default function(config) {
let format = config.format;
return {
external: [
'preact',
'dlv'
],
output: {
strict: false,
exports: format==='es' ? null : 'named',
globals: {
preact: 'preact',
dlv: 'dlv'
}
},
plugins: [
format!=='es' && memory({
path: 'src/entry.js',
contents: "export { default } from './index';"
}),
buble({
jsx: 'h'
})
].filter(Boolean)
};
}
================================================
FILE: src/components/highlight-i18n.js
================================================
import { h } from 'preact';
import delve from 'dlv';
/** Highlight/colorize the i18n'd node if `mark` is set on `intl` in context. If not, just returns `value`
*
* @private
* @param {String|VNode} value The l10n'd text/vnode to highlight or pass through
* @param {string} id The key used to lookup the value in the intl dictionary
*/
export function HighlightI18N({ value, id }, { intl }) {
if (intl && intl.mark) {
const dictionaryKey = `dictionary${intl && intl.scope ? `.${intl.scope}` : ''}.${id}`;
return h('mark', {
style: 'background: ' + (
value
? delve(intl, dictionaryKey)
? 'rgba(119,231,117,.5)' // Green = this string is fully internationalized
: 'rgba(229,226,41,.5)' // Yellow = this string does not have a value in the dictionary, but it has a fallback value
: 'rgba(228,147,51,.5)' // Red = this string has no value and no fallback
),
title: id
}, value);
}
return value ;
}
================================================
FILE: src/components/intl-provider.js
================================================
import { h, Component } from 'preact';
import { assign, deepAssign } from '../lib/util';
const URL_FLAG = /[?]intl=show/;
/** `` is a nestable internationalization definition provider.
* It exposes an Intl scope & definition into the tree,
* making them available to descendant components.
*
* > **Note:** When nested, gives precedence to keys higher up the tree!
* > This means lower-level components can set their defaults by wrapping themselves
* > in an ``, but still remain localizable by their parent components.
*
* @name IntlProvider
* @param props
* @param {String} [props.scope] Nest `definition` under a root key, and set the active scope for the tree (essentially prefixing all ` ` keys).
* @param {Boolean} [props.mark=false] If `true`, all `` elements will be shown with a red/green background indicating whether they have valid Intl keys.
* @param {Object} [props.definition={}] Merge the given definition into the current intl definition, giving the *current* definition precedence (i.e., only adding keys, acting as defaults)
*
* @example
* // generally imported from a JSON file:
* let definition = {
* foo: 'Le Feux'
* };
*
*
* The Foo
*
*
* // This will render the text:
* "Le Feux"
*/
export class IntlProvider extends Component {
getChildContext() {
let { scope, definition, mark } = this.props,
intl = assign({}, this.context.intl || {});
// set active scope for the tree if given
if (scope) intl.scope = scope;
// merge definition into current with lower precedence
if (definition) {
intl.dictionary = deepAssign(intl.dictionary || {}, definition);
}
if (mark || (typeof location!=='undefined' && String(location).match(URL_FLAG))) {
intl.mark = true;
}
return { intl };
}
render({ children }) {
return children && children[0] || null;
}
}
================================================
FILE: src/components/localizer.js
================================================
import { cloneElement } from 'preact';
import translateMapping from '../lib/translate-mapping';
/** ` ` is a Compositional Component.
* It "renders" out any ` ` values in its child's props.
*
* @name Localizer
* @param {Object} props
* @param {Object} props.children Child components with props to localize.
* @param {Object} context
* @param {Object} context.intl [internal] dictionary and scope info
* @example
*
* } />
*
* // produces:
*
*
* @example
*
* Open Source Software }>
* OSS
*
*
* // produces:
* OSS
*/
export function Localizer({ children }, { intl }) {
let child = children && children[0];
return child && cloneElement(child, translateMapping(child.attributes, intl, true));
}
================================================
FILE: src/components/markup-text.js
================================================
import { h } from 'preact';
import { Text } from './text';
import { Localizer } from './localizer';
import { HighlightI18N } from './highlight-i18n';
/* eslint-disable react/no-danger */
/** `` is just like {@link Text} but it can also contain html markup in rendered strings. It wraps its contents in a `` tag.
*
* @param {Object} props props
* @param {String} props.id Key to look up in intl dictionary, within any parent scopes (`$scope1.$scope2.$id`)
* @param {Object} [props.fields={}] Values to inject into template `{{fields}}`. Values in the `fields` object will be coerced to strings, with the exception of ` ` nodes which will be resolved to their translated value
* @param {Number} [props.plural] Integer "count", used to select plural forms
* @param {Object} context
* @param {Object} context.intl [internal] dictionary and scope info
*
* @example
* // If there is no dictionary in context..
* The Foo
* // ..produces the vnode:
* The Foo
*
* @example
* // Given a dictionary and some fields..
* {{bar}}' }}>
* The Foo
*
* // ..produces the vnode:
* Le Feux BEAR
*
* @example
* // Within a scope, both `id` and the definition are namespaced..
* Feux' }}>
* The Foo
*
* // ..produces the vnode:
* Le Feux
*
* @example
* // renders nothing if there is no key match and no fallback
*
* // ..produces the vnode:
*
*/
export function MarkupText(props) {
return (
} id={props.id} />
);
}
function Html({ html, id }) {
let value = !html ? html : typeof html === 'string' ? : {html} ;
return ;
}
================================================
FILE: src/components/text.js
================================================
import { h } from 'preact';
import translate from '../lib/translate';
import { HighlightI18N } from './highlight-i18n';
/** `` renders internationalized text.
* It attempts to look up translated values from a dictionary in context.
*
* Template strings can contain `{{field}}` placeholders,
* which injects values from the `fields` prop.
*
* When string lookup fails, renders its children as fallback text.
*
* @param {Object} props props
* @param {String} props.id Key to look up in intl dictionary, within any parent scopes (`$scope1.$scope2.$id`)
* @param {Object} [props.fields={}] Values to inject into template `{{fields}}`. Values in the `fields` object will be coerced to strings, with the exception of ` ` nodes which will be resolved to their translated value
* @param {Number} [props.plural] Integer "count", used to select plural forms
* @param {Object} context
* @param {Object} context.intl [internal] dictionary and scope info
*
* @example
* // If there is no dictionary in context..
* The Foo
* // ..produces the text:
* "The Foo"
*
* @example
* // Given a dictionary and some fields..
*
* The Foo
*
* // ..produces the text:
* "Le Feux BEAR"
*
* @example
* // Within a scope, both `id` and the definition are namespaced..
*
* The Foo
*
* // ..produces the text:
* "Le Feux"
*/
export function Text({ id, children, plural, fields }, { intl }) {
let fallback = children && children[0];
let value = translate(
id,
intl && intl.scope,
intl && intl.dictionary,
fields,
plural,
fallback
);
return ;
}
================================================
FILE: src/components/with-text.js
================================================
import { h } from 'preact';
import translateMapping from '../lib/translate-mapping';
import { assign } from '../lib/util';
/** `@withText()` is a Higher Order Component, often used as a decorator.
*
* It wraps a child component and passes it translations
* based on a mapping to the dictionary & scope in context.
*
* @param {Object|Function|String} mapping Maps prop names to intl keys (or `` nodes).
*
* @example @withText({
* placeholder: 'user.placeholder'
* })
* class Foo {
* // now the `placeholder` prop is our localized String:
* render({ placeholder }) {
* return
* }
* }
*
* @example @withText({
* placeholder: fallback text
* })
* class Foo {
* render({ placeholder }) {
* return
* }
* }
*
* @example @withText('user.placeholder')
* class Foo {
* // for Strings/Arrays, the last path segment becomes the prop name:
* render({ placeholder }) {
* return
* }
* }
*
* @example Works with functional components, too
* const Foo = withText('user.placeholder')( props =>
*
* )
*
* @example getWrappedComponent() returns wrapped child Component
* const Foo = () =>
;
* const WrappedFoo = withText('user.placeholer')(Foo);
* WrappedFoo.getWrappedComponent() === Foo; // true
*/
export function withText(mapping) {
return function withTextWrapper(Child) {
function WithTextWrapper(props, context) {
let map = typeof mapping==='function' ? mapping(props, context) : mapping;
let translations = translateMapping(map, context.intl);
return h(Child, assign(assign({}, props), translations));
}
WithTextWrapper.getWrappedComponent = Child && Child.getWrappedComponent || (() => Child);
return WithTextWrapper;
};
}
================================================
FILE: src/index.js
================================================
import { intl } from './intl';
import { IntlProvider } from './components/intl-provider';
import { Text } from './components/text';
import { MarkupText } from './components/markup-text';
import { Localizer } from './components/localizer';
import { withText } from './components/with-text';
export { intl, IntlProvider, Text, MarkupText, Localizer, withText };
intl.intl = intl;
intl.IntlProvider = IntlProvider;
intl.Text = Text;
intl.MarkupText = MarkupText;
intl.Localizer = Localizer;
intl.withText = withText;
export default intl;
================================================
FILE: src/intl.js
================================================
import { h } from 'preact';
import { IntlProvider } from './components/intl-provider';
/**
* Higher-order function that creates an ` ` wrapper component for the given component. It
* takes two forms depending on how many arguments it's given:
* It can take a functional form like:
* intl(ComponentToWrap, options)
*
* or it can take an annotation form like:
* @intl(options)
* class ComponentToWrap extends Component {}
*
* @param {Component or Object} args[0] If there are two arguments, the first argument is the Component to wrap in ` `. If there is just one argument, this is the options object to pass as `props` to ` `. See the definition of the options param below for details.
* @param {Object} options If there are two arguments, the second argument is Passed as `props` to ` `
* @param [options.scope] Nest `definition` under a root key, and set the active scope for the tree (essentially prefixing all ` ` keys).
* @param [options.definition={}] Merge the given definition into the current intl definition, giving the *current* definition precedence (i.e., only adding keys, acting as defaults)
*/
export function intl(Child, options) {
if (arguments.length<2) {
options = Child;
return Child => intl(Child, options);
}
function IntlProviderWrapper(props) {
return h(
IntlProvider,
options || {},
h(Child, props)
);
}
IntlProviderWrapper.getWrappedComponent = Child && Child.getWrappedComponent || (() => Child);
return IntlProviderWrapper;
}
================================================
FILE: src/lib/template.js
================================================
import { Text } from '../components/text';
import translate from './translate';
const EMPTY = {};
/** Populate {{template.fields}} within a given string.
*
* @private
* @param {String} template The string containing fields to be resolved
* @param {Object} [fields={}] Optionally nested object of fields, referencable from `template`.
* @example
* template('foo{{bar}}', { bar:'baz' }) === 'foobaz'
*/
export default function template(template, fields, scope, dictionary) {
return template && template.replace(/\{\{([\w.-]+)\}\}/g, replacer.bind(null, fields || EMPTY, scope, dictionary));
}
/** Replacement callback for injecting fields into a String
* @private
*/
function replacer(currentFields, scope, dictionary, s, field) {
let parts = field.split('.'),
v = currentFields;
for (let i=0; i nodes
if (v && v.nodeName === Text) {
return translate(v.attributes.id, scope, dictionary, v.attributes.fields, v.attributes.plural, v.attributes.fallback);
}
}
// allow for recursive {{config.xx}} references:
if (typeof v==='string' && v.match(/\{\{/)) {
v = template(v, currentFields);
}
return v;
}
================================================
FILE: src/lib/translate-mapping.js
================================================
import { assign, select } from './util';
import translate from './translate';
import { Text } from '../components/text';
/** Translates the property values in an Object, returning a copy.
* **Note:** By default, `String` keys will be treated as Intl ID's.
* Pass `true` to return an Object containing *only* translated
* values where the prop is a node.
*
* @private
* @param {Object} props An object with values to translate
* @param {Object} intl An intl context object (eg: `context.intl`)
* @param {Boolean} [onlyTextNodes=false] Only process ` ` values
* @returns {Object} translatedProps
*/
export default function translateMapping(props, intl, onlyTextNodes) {
let out = {};
intl = intl || {};
props = select(props);
for (let name in props) {
if (props.hasOwnProperty(name) && props[name]) {
let def = props[name];
// if onlyTextNodes=true, skip any props that aren't vnodes
if (!onlyTextNodes && typeof def==='string') {
out[name] = translate(def, intl.scope, intl.dictionary);
}
else if (def.nodeName===Text) {
// it's a , just grab its props:
let c = def.children;
def = assign({
//no fallback if there are no children. Return first child if there is only 1, or array of children if there are more than one
fallback: c.length && (c.length === 1 ? c[0] : c)
}, def.attributes);
out[name] = translate(def.id, intl.scope, intl.dictionary, def.fields, def.plural, def.fallback);
}
}
}
return out;
}
================================================
FILE: src/lib/translate.js
================================================
import delve from 'dlv';
import { defined } from './util';
import template from './template';
/** Attempts to look up a translated value from a given dictionary.
* Also supports json templating using the format: {{variable}}
* Falls back to default text.
*
* @private
* @param {String} id Intl field name/id (subject to scope)
* @param {String} [scope=''] Scope, which prefixes `id` with `${scope}.`
* @param {Object} dictionary A nested object containing translations
* @param {Object} [fields={}] Template fields for use by translated strings
* @param {Number} [plural] Indicates a quantity, used to trigger pluralization
* @param {String|Array} [fallback] Text to return if no translation is found
* @returns {String} translated
*/
export default function translate(id, scope, dictionary, fields, plural, fallback) {
if (scope) id = scope + '.' + id;
let value = dictionary && delve(dictionary, id);
// plural forms:
// key: ['plural', 'singular']
// key: { none, one, many }
// key: { zero, one, other }
// key: { singular, plural }
if ((plural || plural===0) && value && typeof value==='object') {
if (value.splice) {
value = value[plural] || value[0];
}
else if (plural===0 && defined(value.none || value.zero)) {
value = value.none || value.zero;
}
else if (plural===1 && defined(value.one || value.singular)) {
value = value.one || value.singular;
}
else {
value = value.some || value.many || value.plural || value.other || value;
}
}
return value && template(value, fields, scope, dictionary) || fallback || null;
}
================================================
FILE: src/lib/util.js
================================================
/** Check if an object is not null or undefined
* @private
*/
export function defined(obj) {
return obj!==null && obj!==undefined;
}
/** A simpler Object.assign
* @private
*/
export function assign(obj, props) {
for (let i in props) {
obj[i] = props[i];
}
return obj;
}
/** Recursively copy keys from `source` to `target`, skipping truthy values already in `target`.
* @private
*/
export function deepAssign(target, source) {
let out = assign({}, target);
for (let i in source) {
if (source.hasOwnProperty(i)) {
if (target[i] && source[i] && typeof target[i]==='object' && typeof source[i]==='object') {
out[i] = deepAssign(target[i], source[i]);
}
else {
out[i] = target[i] || source[i];
}
}
}
return out;
}
/** select('foo,bar') creates a mapping: `{ foo, bar }`
* @private
*/
export function select(properties) {
properties = properties || {};
if (typeof properties==='string') {
properties = properties.split(',');
}
if ('join' in properties) {
let selected = {};
for (let i=0; i {
let scope = 'test-scope',
dictionary = { foo: 'bar', baz: 'bat' },
options = { scope, definition: dictionary };
before( () => rndr() );
let scratch = document.createElement('div'),
root;
function rndr(jsx) {
root = render( , scratch, root);
if (jsx) root = render(jsx, scratch, root);
return root;
}
it('should export things', () => {
expect(intl).to.be.a('function');
expect(wrap).to.be.a('function');
expect(IntlProvider).to.be.a('function');
expect(Text).to.be.a('function');
});
it('should work as a decorator @intl when given one argumnt', () => {
let TestClass = () =>
;
let IntlTestClass = intl(options)(TestClass);
expect( ).to.equal(
);
});
it('should work as a function when given two arguments', () => {
let TestClass = () =>
;
let IntlTestClass = intl(TestClass, options);
expect( ).to.equal(
);
});
describe('getWrappedComponent()', () => {
it('should be a function', () => {
let Wrapped = intl(options)(sinon.spy());
expect(Wrapped.getWrappedComponent).to.be.a('function');
});
it('should return the Child component that it is wrapping', () => {
let Foo = sinon.spy();
let Wrapped = intl(options)(Foo);
expect(Wrapped.getWrappedComponent()).to.equal(Foo);
});
it('should recursively call getWrappedComponent() on Child components to return the first non-decorator Child', () => {
let Foo = sinon.spy();
//Wrap Foo in two layers of configuration to make sure Foo is returned by the top level call to getWrappedComponent
let Wrapped = intl(options)(intl(options)(Foo));
expect(Wrapped.getWrappedComponent()).to.equal(Foo);
});
});
describe('', () => {
it('should provide context', () => {
let Spy = sinon.stub().returns(null);
rndr(
);
expect(Spy).to.have.been.calledOnce.and.calledWithMatch({}, { intl: { dictionary } });
Spy.reset();
rndr(
);
expect(Spy).to.have.been.calledOnce.and.calledWithMatch({}, {
intl: {
scope: 'foo',
dictionary: { foo: dictionary }
}
});
});
describe('mark', () => {
it('should be off by default', () => {
const Child = sinon.spy( () =>
);
rndr(
);
expect(Child).to.have.been.calledWithMatch({ }, { intl: { } });
});
it('should be triggered by ', () => {
const Child = sinon.spy( () =>
);
rndr(
);
expect(Child).to.have.been.calledWithMatch({ }, { intl: { mark: true } });
});
it('should be triggered by URL flag', () => {
let url = location.pathname;
function test(urlSuffix) {
history.replaceState(null, null, url+urlSuffix);
const Child = sinon.spy( () =>
);
rndr(
);
expect(Child).to.have.been.calledWithMatch({ }, { intl: { mark: true } });
}
test('?intl=show');
test('#intl=show');
test('?foo&intl=show');
test('?foo=bar=&intl=show&baz=bat');
history.replaceState(null, null, url);
});
});
});
describe('', () => {
it('should fall back if not wrapped in a Provider', () => {
rndr(
FOO
);
expect(root.innerHTML).to.equal('FOO');
});
it('should render text without scope', () => {
rndr(
);
expect(root.innerHTML).to.equal('FOO!
');
});
it('should render text with scope', () => {
rndr(
);
expect(root.innerHTML, '').to.equal('BAR!
');
});
it('should render html markup as string data, not markup', () => {
rndr(
);
expect(root.innerHTML, '').to.equal('<b>FOO</b>
');
});
it('should render default when requested id is not present', () => {
rndr(
);
expect(root.innerHTML, '').to.equal('DEFAULT
');
});
describe('mark', () => {
it('should render translations with a green wrapping ', () => {
expect(
).to.eql(
BAR!
);
});
it('should render translations relying on a fallback with a yellow wrapping ', () => {
expect(
Fooey
).to.eql(
Fooey
);
});
it('should render missing translations with an orange wrapping ', () => {
expect(
).to.eql(
);
});
});
});
describe('', () => {
it('should render nothing if no key or fallback is found', () => {
rndr(
);
expect(root.innerHTML).to.equal('');
});
it('should fall back if not wrapped in a Provider', () => {
rndr(
FOO
);
expect(root.innerHTML).to.equal('FOO ');
});
it('should render multi-child fallback', () => {
rndr(
This is the fallback with multiple children
);
expect(root.innerHTML).to.equal('This is the fallback with multiple children ');
});
it('should render text without scope', () => {
rndr(
);
expect(root.innerHTML).to.equal('FOO!
');
});
it('should render text with scope', () => {
rndr(
);
expect(root.innerHTML, '').to.equal('BAR!
');
});
it('should render default when requested id is not present', () => {
rndr(
);
expect(root.innerHTML, '').to.equal('DEFAULT
');
});
it('should render html markup as markup', () => {
rndr(
);
expect(root.innerHTML, '').to.equal('FOO
');
});
describe('mark', () => {
it('should render translations with a green wrapping ', () => {
expect(
BAR!' }}>
).to.eql(
BAR!' }} />
);
});
it('should render translations relying on a fallback with a yellow wrapping ', () => {
expect(
Fooey
).to.eql(
Fooey
);
});
it('should render missing translations with an orange wrapping ', () => {
expect(
).to.eql(
);
});
});
});
describe('withText()', () => {
it('should provide strings to child as props', () => {
const Child = sinon.stub().returns(
);
const Wrapped = withText({
propName: 'foo'
})(Child);
rndr(
);
expect(Child).to.have.been.calledOnce.and.calledWithMatch({ propName: 'FOO!' });
});
it('should accept a CSV of keys', () => {
const Child = sinon.stub().returns(
);
const Wrapped = withText('foo,bar,baz')(Child);
rndr(
);
expect(Child).to.have.been.calledOnce.and.calledWithMatch({ foo: '1', bar: '2', baz: '3' });
});
it('should accept an Array of keys', () => {
const Child = sinon.stub().returns(
);
const Wrapped = withText(['foo','bar','baz'])(Child);
rndr(
);
expect(Child).to.have.been.calledOnce.and.calledWithMatch({ foo: '1', bar: '2', baz: '3' });
});
it('should support as a value', () => {
const Child = sinon.stub().returns(
);
const Wrapped = withText({
normal: ,
missing: ,
withchild: child ,
withfallback:
})(Child);
rndr(
);
expect(Child).to.have.been.calledOnce.and.calledWithMatch({
normal: 'FOO!',
missing: null,
withchild: 'child',
withfallback: 'fallback'
});
});
describe('getWrappedComponent()', () => {
it('should be a function', () => {
const Wrapped = withText(['foo','bar','baz'])(sinon.spy());
expect(Wrapped.getWrappedComponent).to.be.a('function');
});
it('should return the Child component that it is wrapping', () => {
let Foo = sinon.spy();
let Wrapped = withText(['foo','bar','baz'])(Foo);
expect(Wrapped.getWrappedComponent()).to.equal(Foo);
});
it('should recursively call getWrappedComponent() on Child components to return the first non-decorator Child', () => {
let Foo = sinon.spy();
//Wrap Foo in two layers of configuration to make sure Foo is returned by the top level call to getWrappedComponent
let Wrapped = withText(['foo','bar','baz'])(withText(['buzz'])(Foo));
expect(Wrapped.getWrappedComponent()).to.equal(Foo);
});
});
});
describe('', () => {
it('should translate any props on its child', () => {
rndr(
}
title={blah }
type="email"
minlength={0}
maxlength={1}
required
/>
);
expect(root).to.have.property('innerHTML', ` `);
});
});
});
================================================
FILE: test/lib/template.js
================================================
import template from '../../src/lib/template';
describe('template()', () => {
it('should leave fieldless unmodified', () => {
expect(template('foo')).to.equal('foo');
expect(template('{foo}')).to.equal('{foo}');
expect(template('{{foo\\}}')).to.equal('{{foo\\}}');
expect(template('a{{$$}}')).to.equal('a{{$$}}');
});
it('should inject top-level fields', () => {
const FIELDS = {
foo: 'FOO',
bar: 'baz'
};
expect(template('{{foo}}', FIELDS)).to.equal('FOO');
expect(template('{{foo}}{{bar}}', FIELDS)).to.equal('FOObaz');
expect(template('a {{foo}} b {{bar}} c', FIELDS)).to.equal('a FOO b baz c');
});
it('should inject nested fields', () => {
const FIELDS = {
foo: {
bar: {
baz: 'bat'
}
},
arr: [
'a',
{ b: 1 }
]
};
expect(template('{{foo.bar.baz}}', FIELDS)).to.equal('bat');
expect(template('{{foo.bar}}', FIELDS)).to.equal('[object Object]');
expect(template('{{arr.0}}', FIELDS)).to.equal('a');
expect(template('{{arr.1.b}}', FIELDS)).to.equal('1');
});
it('should support recursive field injection', () => {
const FIELDS = {
first: '1{{second}}2',
second: '3{{third}}4',
third: 'THIRD'
};
expect(template('{{first}}', FIELDS)).to.equal('13THIRD42');
});
it('should replace empty fields with the empty string', () => {
const FIELDS = {
baz: 'baz'
};
expect(template('{{foo}}', FIELDS)).to.equal('');
expect(template('{{foo.bar}}', FIELDS)).to.equal('');
expect(template('Fooey {{foo.bar}}', FIELDS)).to.equal('Fooey ');
expect(template('Fooey {{foo.bar}} {{baz}}', FIELDS)).to.equal('Fooey baz');
});
it('should replace fields with falsey values', () => {
const FIELDS = {
foo: 0,
bar: false
};
expect(template('{{foo}}', FIELDS)).to.equal('0');
expect(template('{{bar}}', FIELDS)).to.equal('false');
});
});
================================================
FILE: test/lib/translate.js
================================================
import { h } from 'preact';
import translate from '../../src/lib/translate';
import { Text } from '../../src/components/text';
describe('translate', () => {
it('should return the value from the dictionary if a dot-notated match on id is found', () => {
expect(translate('foo.bar', undefined, { foo: { bar: 'hello' } })).to.equal('hello');
});
it('should prefix scope to id to resolve dot-notated id in dictionary', () => {
expect(translate('foo.bar', 'myScope', { myScope: { foo: { bar: 'hello' } } })).to.equal('hello');
expect(translate('foo.bar', 'myScope', { foo: { bar: 'hello' } })).to.be.null;
});
it('should return null if no id match is found in the dictionary and not fallback is provided', () => {
expect(translate('foo.bar', undefined, {})).to.be.null;
});
it('should return the fallback when a value from the dictionary by a dot-notated id match is not found', () => {
expect(translate('foo.bar', undefined, {}, undefined, undefined, 'testFallback')).to.equal('testFallback');
});
it('should replace dot-notated templated strings when given a fields attribute', () => {
expect(translate('foo.bar', undefined, { foo: { bar: 'hello{{c.d}}' } }, { c: { d: 'World' } })).to.equal('helloWorld');
});
it('should translate components that exist in field values when working on templated strings', () => {
expect(translate('foo.bar', undefined, { foo: { bar: 'hello{{c.d}}' }, e: 'World' }, { c: { d: } })).to.equal('helloWorld');
});
it('should pluralise for none/one/many pluralisation keys', () => {
expect(translate('foo.bar', undefined, { foo: { bar: { none: 'none', one: 'one', many: 'many' } } }, undefined, 0)).to.equal('none');
expect(translate('foo.bar', undefined, { foo: { bar: { none: 'none', one: 'one', many: 'many' } } }, undefined, 1)).to.equal('one');
expect(translate('foo.bar', undefined, { foo: { bar: { none: 'none', one: 'one', many: 'many' } } }, undefined, 2)).to.equal('many');
expect(translate('foo.bar', undefined, { foo: { bar: { none: 'none', one: 'one', many: 'many' } } }, undefined, 100)).to.equal('many');
});
it('should pluralise for zero/one/other pluralisation keys', () => {
expect(translate('foo.bar', undefined, { foo: { bar: { zero: 'zero', one: 'one', other: 'other' } } }, undefined, 0)).to.equal('zero');
expect(translate('foo.bar', undefined, { foo: { bar: { zero: 'zero', one: 'one', other: 'other' } } }, undefined, 1)).to.equal('one');
expect(translate('foo.bar', undefined, { foo: { bar: { zero: 'zero', one: 'one', other: 'other' } } }, undefined, 2)).to.equal('other');
expect(translate('foo.bar', undefined, { foo: { bar: { zero: 'zero', one: 'one', other: 'other' } } }, undefined, 100)).to.equal('other');
});
it('should pluralise for singular/plural pluralisation keys', () => {
// assume 0 is a plural form if using the singular/plural pluralisation convention
expect(translate('foo.bar', undefined, { foo: { bar: { singular: 'singular', plural: 'plural' } } }, undefined, 0)).to.equal('plural');
expect(translate('foo.bar', undefined, { foo: { bar: { singular: 'singular', plural: 'plural' } } }, undefined, 1)).to.equal('singular');
expect(translate('foo.bar', undefined, { foo: { bar: { singular: 'singular', plural: 'plural' } } }, undefined, 2)).to.equal('plural');
expect(translate('foo.bar', undefined, { foo: { bar: { singular: 'singular', plural: 'plural' } } }, undefined, 100)).to.equal('plural');
});
});
================================================
FILE: test/lib/util.js
================================================
import { select } from '../../src/lib/util';
describe('util', () => {
describe('select()', () => {
it('should return objects unmodified', () => {
let obj = { foo: 'foo', bar: 'bar' };
expect(select(obj)).to.equal(obj);
});
it('should create an object mapping from an Array', () => {
expect(select(['foo'])).to.eql({ foo: 'foo' });
expect(select(['foo', 'bar'])).to.eql({ foo: 'foo', bar: 'bar' });
expect(select(['foo.a', 'bar.b'])).to.eql({ a: 'foo.a', b: 'bar.b' });
});
it('should return create an object mapping from a CSV String', () => {
expect(select('a')).to.eql({ a: 'a' });
expect(select('foo,bar')).to.eql({ foo: 'foo', bar: 'bar' });
expect(select('foo , bar')).to.eql({ foo: 'foo', bar: 'bar' });
});
});
});