Skip to content

Commit

Permalink
Merge pull request #1684 from Automattic/update/interpolate-components
Browse files Browse the repository at this point in the history
Framework: Update/interpolate components
  • Loading branch information
rralian committed Dec 16, 2015
2 parents 8415159 + 34eeaea commit 54427f4
Show file tree
Hide file tree
Showing 8 changed files with 289 additions and 300 deletions.
8 changes: 4 additions & 4 deletions client/lib/interpolate-components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ Interpolate-Components

The Interpolate-Components module takes an options object that includes:

### translation
### mixedString
A string that contains component tokens to be interpolated
### components
An object with components assigned to named attributes
### throwErrors
Whether errors should be thrown (as in pre-production environments) or we should more gracefully return the un-interpolated original string (as in production). This option is optional and is false by default.
Whether errors should be thrown (as in pre-production environments) or we should more gracefully return the un-interpolated original string (as in production). This is optional and is false by default.

The Interpolate-Components module takes these input options and returns a child object containing a mix of strings and components that can be injected into a React element. This allows us to mix markup and content in a string without having to inject that markup using `_dangerouslySetInnerHTML()`. This is particularly useful for i18n purposes.
The Interpolate-Components module takes these input options and returns a child object containing a mix of strings and components that can be injected into a React element. This allows us to mix markup and content in a string without having to inject that markup using `_dangerouslySetInnerHTML()`. This is particularly useful for translation purposes, where it hinders translation to separate markup from content.

Component tokens are strings (containing letters, numbers, or underscores only) wrapped inside double-curly braces and have an opening, closing, and self-closing syntax, similar to html.

Expand All @@ -30,7 +30,7 @@ var interpolateComponents = require( 'lib/interpolate-components' ),
em: <em />
},
children = interpolateComponents( {
translation: example,
mixedString: example,
components; components
} ),
jsxExample = <p>{ children }</p>;
Expand Down
43 changes: 22 additions & 21 deletions client/lib/interpolate-components/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
/**
* External Dependencies
*/
var React = require( 'react/addons' );
import React from 'react';
import createFragment from 'react-addons-create-fragment';

/**
* Internal Dependencies
*/
var warn = require( 'lib/warn' ),
tokenize = require( './tokenize.js' );
import tokenize from './tokenize';

var currentTranslation;
let currentMixedString;

function getCloseIndex( openIndex, tokens ) {
var openToken = tokens[ openIndex ],
Expand Down Expand Up @@ -47,11 +47,11 @@ function buildChildren( tokens, components ) {
}
// component node should at least be set
if ( ! components.hasOwnProperty( token.value ) || typeof components[ token.value ] === 'undefined' ) {
throw new Error( 'Invalid translation, missing component node: `' + token.value + '`' );
throw new Error( 'Invalid interpolation, missing component node: `' + token.value + '`' );
}
// should be either ReactElement or null (both type "object"), all other types deprecated
if ( typeof components[ token.value ] !== 'object' ) {
warn( 'Invalid translation, component node must be a ReactElement or null: `' + token.value + '`', '\n> ' + currentTranslation );
throw new Error( 'Invalid interpolation, component node must be a ReactElement or null: `' + token.value + '`', '\n> ' + currentMixedString );
}
// we should never see a componentClose token in this loop
if ( token.type === 'componentClose' ) {
Expand Down Expand Up @@ -85,46 +85,47 @@ function buildChildren( tokens, components ) {
return children[ 0 ];
}

children.forEach( function( child, index ) {
children.forEach( ( child, index ) => {
if ( child ) {
childrenObject[ 'translation-child-' + index ] = child;
childrenObject[ `interpolation-child-${index}` ] = child;
}
} );

return React.addons.createFragment( childrenObject );
return createFragment( childrenObject );
}

module.exports = function interpolate( options ) {
var translation = options.translation,
components = options.components,
throwErrors = options.throwErrors,
tokens;
function interpolate( options ) {
const { mixedString, components, throwErrors } = options;

currentTranslation = translation;
currentMixedString = mixedString;

if ( ! components ) {
return translation;
return mixedString;
}

if ( typeof components !== 'object' ) {
warn( 'Translation Error: components argument must be an object', translation, components );
return translation;
if ( throwErrors ) {
throw new Error( 'Interpolation Error: components argument must be an object', mixedString, components );
}
return mixedString;
}

tokens = tokenize( translation );
let tokens = tokenize( mixedString );

try {
return buildChildren( tokens, components );
} catch( error ) {
// don't mess around in production, just return what we can
if ( ! throwErrors ) {
return translation;
return mixedString;
}
// in pre-production environments we should make errors very visible
if ( window && window.console && window.console.error ) {
window.console.error( '\nTranslation Error: ', interpolate.caller.caller, '\n> ' + translation );
window.console.error( '\nInterpolation Error: ', interpolate.caller.caller, '\n> ' + mixedString );
}

throw error;
}
};

export default interpolate;
6 changes: 0 additions & 6 deletions client/lib/interpolate-components/test/lib/warn.js

This file was deleted.

259 changes: 259 additions & 0 deletions client/lib/interpolate-components/test/test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
/**
* External dependencies
*/
import assert from 'assert';
import ReactDomServer from 'react-dom/server';
import React from 'react';

/**
* Internal dependencies
*/
import interpolateComponents from 'lib/interpolate-components';

describe( 'interpolate-components', () => {
const input = React.DOM.input();
const div = React.DOM.div();
const link = <a href="#" />;
const em = <em />;
const CustomComponentClass = React.createClass( {
displayName: 'CustomComponentClass',
render() {
return <span className="special">{ this.props.intro }{ this.props.children }</span>;
}
} );

describe( 'with default container', () => {
it( 'should return a react object with a span container', () => {
const expectedResultString = '<span>test<input/>test</span>';
const interpolatedResult = interpolateComponents( {
mixedString: 'test{{input/}}test',
components: {
input: input
}
} );
const instance = <span>{ interpolatedResult }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
it( 'should allow whitespace in the component placeholder', () => {
const expectedResultString = '<span>test<input/>test</span>';
const interpolatedResult = interpolateComponents( {
mixedString: 'test{{ input /}}test',
components: {
input: input
}
} );
const instance = <span>{ interpolatedResult }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
it( 'should not add extra span nodes if component is at end of string', () => {
const expectedResultString = '<span>test<input/></span>';
const interpolatedResult = interpolateComponents( {
mixedString: 'test{{ input /}}',
components: {
input: input
}
} );
const instance = <span>{ interpolatedResult }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
it( 'should not throw when component node is null', () => {
assert.doesNotThrow( () => {
interpolateComponents( {
mixedString: 'test{{input/}}test',
components: {
input: null
},
throwErrors: true
} );
} );
} );
it( 'should not throw when component node is not an object', () => {
assert.doesNotThrow( () => {
interpolateComponents( {
mixedString: 'test{{input/}}test{{input2/}}',
components: {
input: 'string',
input2: 123
},
} );
} );
} );
it( 'should return original string when component node is not an object', () => {
const expectedResultString = '<span>test{{input/}}test{{input2/}}</span>';
const interpolatedResult = interpolateComponents( {
mixedString: 'test{{input/}}test{{input2/}}',
components: {
input: 'string',
input2: 123
}
} );
const instance = <span>{ interpolatedResult }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );

describe( 'when allowing thrown errors', () => {
it( 'should throw when component node is not set', () => {
assert.throws( () => {
interpolateComponents( {
mixedString: 'test{{input/}}',
components: {
mismatch: true
},
throwErrors: true
} );
} );
} );
it( 'should throw when component node is undefined', () => {
assert.throws( () => {
interpolateComponents( {
mixedString: 'test{{input/}}',
components: {
input: undefined
},
throwErrors: true
} );
} );
} );
it( 'should throw error on invalid cross-nesting', () => {
assert.throws( () => {
interpolateComponents( {
mixedString: '{{link}}a{{em}}b{{/link}}c{{/em}}',
components: {
link: link,
em: em
},
throwErrors: true
} );
} );
} );
it( 'should throw when component is unclosed', () => {
assert.throws( () => {
interpolateComponents( {
mixedString: '{{link}}test',
components: {
link: link
},
throwErrors: true
} );
} );
} );
} );

describe( 'when not allowing thrown errors', () => {
it( 'should return original string when component node is not set', () => {
const mixedString = 'test{{input/}}';
const results = interpolateComponents( {
mixedString: mixedString,
components: {
mismatch: true
}
} );
assert.equal( mixedString, results );
} );
it( 'should return original string when component node is undefined', () => {
const mixedString = 'test{{input/}}';
const results = interpolateComponents( {
mixedString: mixedString,
components: {
input: undefined
}
} );
assert.equal( mixedString, results );
} );
it( 'should return original string on invalid cross-nesting', () => {
const mixedString = '{{link}}a{{em}}b{{/link}}c{{/em}}';
const results = interpolateComponents( {
mixedString: mixedString,
components: {
link: link,
em: em
}
} );
assert.equal( mixedString, results );
} );
it( 'should return original string when component is unclosed', () => {
const mixedString = '{{link}}test';
const results = interpolateComponents( {
mixedString: mixedString,
components: {
link: link
}
} );
assert.equal( mixedString, results );
} );
} );
} );

describe( 'with components that wrap content', () => {
it( 'should wrap the component around the inner contents', () => {
const expectedResultString = '<span>test <a href="#">link content</a> test</span>';
const interpolatedComponent = interpolateComponents( {
mixedString: 'test {{link}}link content{{/link}} test',
components: {
link: link
}
} );
const instance = <span>{ interpolatedComponent }</span>;

assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
it( 'should handle multiple wrapping components', () => {
const expectedResultString = '<span>test <a href="#">link content</a> <em>link content2</em> test</span>';
const interpolatedComponent = interpolateComponents( {
mixedString: 'test {{link}}link content{{/link}} {{em}}link content2{{/em}} test',
components: {
link: link,
em: em
}
} );
const instance = <span>{ interpolatedComponent }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
it( 'should handle nested wrapping components', () => {
const expectedResultString = '<span>test <a href="#">link content <em>emphasis</em></a> test</span>';
const interpolatedComponent = interpolateComponents( {
mixedString: 'test {{link}}link content {{em}}emphasis{{/em}}{{/link}} test',
components: {
link: link,
em: em
}
} );
const instance = <span>{ interpolatedComponent }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
it( 'should work with custom components', () => {
const expectedResultString = '<span>here is: <span class="special">baba <span class="special">Hey!willie</span></span></span>';
const interpolatedComponent = interpolateComponents( {
mixedString: 'here is: {{x}}baba {{y}}willie{{/y}}{{/x}}',
components: {
x: <CustomComponentClass />,
y: <CustomComponentClass intro='Hey!' />
}
} );
const instance = <span>{ interpolatedComponent }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
it( 'should allow repeated component tokens', () => {
const expectedResultString = '<span><a href="#">baba</a><a href="#">dyado</a></span>';
const interpolatedComponent = interpolateComponents( {
mixedString: '{{link}}baba{{/link}}{{link}}dyado{{/link}}',
components: {
link: link
}
} );
const instance = <span>{ interpolatedComponent }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
it( 'should allow wrapping repeated components', () => {
const expectedResultString = '<span><div>baba<div>dyado</div></div></span>';
const interpolatedComponent = interpolateComponents( {
mixedString: '{{div}}baba{{div}}dyado{{/div}}{{/div}}',
components: {
div: div
}
} );
const instance = <span>{ interpolatedComponent }</span>;
assert.equal( expectedResultString, ReactDomServer.renderToStaticMarkup( instance ) );
} );
} );
} );
Loading

0 comments on commit 54427f4

Please sign in to comment.