diff --git a/packages/element/CHANGELOG.md b/packages/element/CHANGELOG.md
index de45ce3a90904..f6a20d5840ab4 100644
--- a/packages/element/CHANGELOG.md
+++ b/packages/element/CHANGELOG.md
@@ -1,3 +1,7 @@
+## Master
+
+- Added `__experimentalCreateInterpolateElement` function (see [17376](https://github.com/WordPress/gutenberg/pull/17376))
+
## 2.8.0 (2019-09-16)
### New Features
diff --git a/packages/element/src/create-interpolate-element.js b/packages/element/src/create-interpolate-element.js
new file mode 100644
index 0000000000000..c32c3446ef98a
--- /dev/null
+++ b/packages/element/src/create-interpolate-element.js
@@ -0,0 +1,325 @@
+/**
+ * External dependencies
+ */
+import { createElement, cloneElement, Fragment, isValidElement } from 'react';
+
+let indoc,
+ offset,
+ output,
+ stack;
+
+/**
+ * Matches tags in the localized string
+ *
+ * This is used for extracting the tag pattern groups for parsing the localized
+ * string and along with the map converting it to a react element.
+ *
+ * There are four references extracted using this tokenizer:
+ *
+ * match: Full match of the tag (i.e. , ,
)
+ * isClosing: The closing slash, it it exists.
+ * name: The name portion of the tag (strong, br) (if )
+ * isSelfClosed: The slash on a self closing tag, if it exists.
+ *
+ * @type {RegExp}
+ */
+const tokenizer = /<(\/)?(\w+)\s*(\/)?>/g;
+
+/**
+ * Tracks recursive-descent parse state.
+ *
+ * This is a Stack frame holding parent elements until all children have been
+ * parsed.
+ *
+ * @private
+ * @param {WPElement} element A parent element which may still have
+ * nested children not yet parsed.
+ * @param {number} tokenStart Offset at which parent element first
+ * appears.
+ * @param {number} tokenLength Length of string marking start of parent
+ * element.
+ * @param {number} prevOffset Running offset at which parsing should
+ * continue.
+ * @param {number} leadingTextStart Offset at which last closing element
+ * finished, used for finding text between
+ * elements
+ *
+ * @return {Frame} The stack frame tracking parse progress.
+ */
+function Frame(
+ element,
+ tokenStart,
+ tokenLength,
+ prevOffset,
+ leadingTextStart,
+) {
+ return {
+ element,
+ tokenStart,
+ tokenLength,
+ prevOffset,
+ leadingTextStart,
+ children: [],
+ };
+}
+
+/**
+ * This function creates an interpolated element from a passed in string with
+ * specific tags matching how the string should be converted to an element via
+ * the conversion map value.
+ *
+ * @example
+ * For example, for the given string:
+ *
+ * "This is a string with a link and a self-closing
+ * tag"
+ *
+ * You would have something like this as the conversionMap value:
+ *
+ * ```js
+ * {
+ * span: ,
+ * a: ,
+ * CustomComponentB: ,
+ * }
+ * ```
+ *
+ * @param {string} interpolatedString The interpolation string to be parsed.
+ * @param {Object} conversionMap The map used to convert the string to
+ * a react element.
+ * @throws {TypeError}
+ * @return {WPElement} A wp element.
+ */
+const createInterpolateElement = ( interpolatedString, conversionMap ) => {
+ indoc = interpolatedString;
+ offset = 0;
+ output = [];
+ stack = [];
+ tokenizer.lastIndex = 0;
+
+ if ( ! isValidConversionMap( conversionMap ) ) {
+ throw new TypeError(
+ 'The conversionMap provided is not valid. It must be an object with values that are WPElements'
+ );
+ }
+
+ do {
+ // twiddle our thumbs
+ } while ( proceed( conversionMap ) );
+ return createElement( Fragment, null, ...output );
+};
+
+/**
+ * Validate conversion map.
+ *
+ * A map is considered valid if it's an object and every value in the object
+ * is a WPElement
+ *
+ * @private
+ *
+ * @param {Object} conversionMap The map being validated.
+ *
+ * @return {boolean} True means the map is valid.
+ */
+const isValidConversionMap = ( conversionMap ) => {
+ const isObject = typeof conversionMap === 'object';
+ const values = isObject && Object.values( conversionMap );
+ return isObject &&
+ values.length &&
+ values.every( ( element ) => isValidElement( element ) );
+};
+
+/**
+ * This is the iterator over the matches in the string.
+ *
+ * @private
+ *
+ * @param {Object} conversionMap The conversion map for the string.
+ *
+ * @return {boolean} true for continuing to iterate, false for finished.
+ */
+function proceed( conversionMap ) {
+ const next = nextToken();
+ const [ tokenType, name, startOffset, tokenLength ] = next;
+ const stackDepth = stack.length;
+ const leadingTextStart = startOffset > offset ? offset : null;
+ if ( ! conversionMap[ name ] ) {
+ addText();
+ return false;
+ }
+ switch ( tokenType ) {
+ case 'no-more-tokens':
+ if ( stackDepth !== 0 ) {
+ const { leadingTextStart: stackLeadingText, tokenStart } = stack.pop();
+ output.push( indoc.substr( stackLeadingText, tokenStart ) );
+ }
+ addText();
+ return false;
+
+ case 'self-closed':
+ if ( 0 === stackDepth ) {
+ if ( null !== leadingTextStart ) {
+ output.push(
+ indoc.substr( leadingTextStart, startOffset - leadingTextStart )
+ );
+ }
+ output.push( conversionMap[ name ] );
+ offset = startOffset + tokenLength;
+ return true;
+ }
+
+ // otherwise we found an inner element
+ addChild(
+ new Frame( conversionMap[ name ], startOffset, tokenLength )
+ );
+ offset = startOffset + tokenLength;
+ return true;
+
+ case 'opener':
+ stack.push(
+ new Frame(
+ conversionMap[ name ],
+ startOffset,
+ tokenLength,
+ startOffset + tokenLength,
+ leadingTextStart
+ )
+ );
+ offset = startOffset + tokenLength;
+ return true;
+
+ case 'closer':
+ // if we're not nesting then this is easy - close the block
+ if ( 1 === stackDepth ) {
+ closeOuterElement( startOffset );
+ offset = startOffset + tokenLength;
+ return true;
+ }
+
+ // otherwise we're nested and we have to close out the current
+ // block and add it as a innerBlock to the parent
+ const stackTop = stack.pop();
+ const text = indoc.substr(
+ stackTop.prevOffset,
+ startOffset - stackTop.prevOffset
+ );
+ stackTop.children.push( text );
+ stackTop.prevOffset = startOffset + tokenLength;
+ const frame = new Frame(
+ stackTop.element,
+ stackTop.tokenStart,
+ stackTop.tokenLength,
+ startOffset + tokenLength,
+ );
+ frame.children = stackTop.children;
+ addChild( frame );
+ offset = startOffset + tokenLength;
+ return true;
+
+ default:
+ addText();
+ return false;
+ }
+}
+
+/**
+ * Grabs the next token match in the string and returns it's details.
+ *
+ * @private
+ *
+ * @return {Array} An array of details for the token matched.
+ */
+function nextToken() {
+ const matches = tokenizer.exec( indoc );
+ // we have no more tokens
+ if ( null === matches ) {
+ return [ 'no-more-tokens' ];
+ }
+ const startedAt = matches.index;
+ const [ match, isClosing, name, isSelfClosed ] = matches;
+ const length = match.length;
+ if ( isSelfClosed ) {
+ return [ 'self-closed', name, startedAt, length ];
+ }
+ if ( isClosing ) {
+ return [ 'closer', name, startedAt, length ];
+ }
+ return [ 'opener', name, startedAt, length ];
+}
+
+/**
+ * Pushes text extracted from the indoc string to the output stack given the
+ * current rawLength value and offset (if rawLength is provided ) or the
+ * indoc.length and offset.
+ *
+ * @private
+ */
+function addText() {
+ const length = indoc.length - offset;
+ if ( 0 === length ) {
+ return;
+ }
+ output.push( indoc.substr( offset, length ) );
+}
+
+/**
+ * Pushes a child element to the associated parent element's children for the
+ * parent currently active in the stack.
+ *
+ * @private
+ *
+ * @param {Frame} frame The Frame containing the child element and it's
+ * token information.
+ */
+function addChild( frame ) {
+ const { element, tokenStart, tokenLength, prevOffset, children } = frame;
+ const parent = stack[ stack.length - 1 ];
+ const text = indoc.substr( parent.prevOffset, tokenStart - parent.prevOffset );
+
+ if ( text ) {
+ parent.children.push( text );
+ }
+
+ parent.children.push(
+ cloneElement( element, null, ...children )
+ );
+ parent.prevOffset = prevOffset ? prevOffset : tokenStart + tokenLength;
+}
+
+/**
+ * This is called for closing tags. It creates the element currently active in
+ * the stack.
+ *
+ * @private
+ *
+ * @param {number} endOffset Offset at which the closing tag for the element
+ * begins in the string. If this is greater than the
+ * prevOffset attached to the element, then this
+ * helps capture any remaining nested text nodes in
+ * the element.
+ */
+function closeOuterElement( endOffset ) {
+ const { element, leadingTextStart, prevOffset, tokenStart, children } = stack.pop();
+
+ const text = endOffset ?
+ indoc.substr( prevOffset, endOffset - prevOffset ) :
+ indoc.substr( prevOffset );
+
+ if ( text ) {
+ children.push( text );
+ }
+
+ if ( null !== leadingTextStart ) {
+ output.push( indoc.substr( leadingTextStart, tokenStart - leadingTextStart ) );
+ }
+
+ output.push(
+ cloneElement(
+ element,
+ null,
+ ...children
+ )
+ );
+}
+
+export default createInterpolateElement;
diff --git a/packages/element/src/index.js b/packages/element/src/index.js
index ad8bae6eff98b..899a308c3b1c7 100644
--- a/packages/element/src/index.js
+++ b/packages/element/src/index.js
@@ -1,3 +1,4 @@
+export { default as __experimentalCreateInterpolateElement } from './create-interpolate-element';
export * from './react';
export * from './react-platform';
export * from './utils';
diff --git a/packages/element/src/test/create-interpolate-element.js b/packages/element/src/test/create-interpolate-element.js
new file mode 100644
index 0000000000000..006647d6b267a
--- /dev/null
+++ b/packages/element/src/test/create-interpolate-element.js
@@ -0,0 +1,245 @@
+/**
+ * External dependencies
+ */
+import TestRenderer, { act } from 'react-test-renderer';
+
+/**
+ * Internal dependencies
+ */
+import { createElement, Fragment, Component } from '../react';
+import createInterpolateElement from '../create-interpolate-element';
+
+describe( 'createInterpolateElement', () => {
+ it( 'throws an error when there is no conversion map', () => {
+ const testString = 'This is a string';
+ expect(
+ () => createInterpolateElement( testString, {} )
+ ).toThrow( TypeError );
+ } );
+ it( 'returns same string when there are no tokens in the string', () => {
+ const testString = 'This is a string';
+ const expectedElement = <>{ testString }>;
+ expect(
+ createInterpolateElement(
+ testString,
+ { someValue: }
+ )
+ ).toEqual( expectedElement );
+ } );
+ it( 'throws an error when there is an invalid conversion map', () => {
+ const testString = 'This is a string';
+ expect(
+ () => createInterpolateElement(
+ testString,
+ [ 'someValue', { value: 10 } ],
+ )
+ ).toThrow( TypeError );
+ } );
+ it( 'throws an error when there is an invalid entry in the conversion ' +
+ 'map', () => {
+ const testString = 'This is a string and ';
+ expect(
+ () => createInterpolateElement(
+ testString,
+ {
+ someValue: ,
+ somethingElse: 10,
+ }
+ )
+ ).toThrow( TypeError );
+ } );
+ it( 'returns same string when there is an non matching token in the ' +
+ 'string', () => {
+ const testString = 'This is a string';
+ const expectedElement = <>{ testString }>;
+ expect(
+ createInterpolateElement(
+ testString,
+ { someValue: }
+ )
+ ).toEqual( expectedElement );
+ } );
+ it( 'returns same string when there is spaces in the token', () => {
+ const testString = 'This is a string';
+ const expectedElement = <>{ testString }>;
+ expect(
+ createInterpolateElement(
+ testString,
+ { 'spaced token': }
+ )
+ ).toEqual( expectedElement );
+ } );
+ it( 'returns expected react element for non nested components', () => {
+ const testString = 'This is a string with a link.';
+ const expectedElement = createElement(
+ Fragment,
+ null,
+ 'This is a string with ',
+ createElement(
+ 'a',
+ { href: 'https://github.com', className: 'some_class' },
+ 'a link'
+ ),
+ '.'
+ );
+ const component = createInterpolateElement(
+ testString,
+ {
+ // eslint-disable-next-line jsx-a11y/anchor-has-content
+ a: ,
+ }
+ );
+ expect(
+ JSON.stringify( component )
+ ).toEqual(
+ JSON.stringify( expectedElement )
+ );
+ } );
+ it( 'returns expected react element for nested components', () => {
+ const testString = 'This is a string that is linked.';
+ const expectedElement = createElement(
+ Fragment,
+ {},
+ 'This is a ',
+ createElement(
+ 'a',
+ null,
+ 'string that is ',
+ createElement(
+ 'em',
+ null,
+ 'linked'
+ ),
+ ),
+ '.'
+ );
+ expect( JSON.stringify( createInterpolateElement(
+ testString,
+ {
+ a: createElement( 'a' ),
+ em: ,
+ }
+ ) ) ).toEqual( JSON.stringify( expectedElement ) );
+ } );
+ it( 'returns expected output for a custom component with children ' +
+ 'replacement', () => {
+ const TestComponent = ( props ) => {
+ return
{ props.children }
;
+ };
+ const testString = 'This is a string with a Custom Component';
+ const expectedElement = createElement(
+ Fragment,
+ null,
+ 'This is a string with a ',
+ createElement(
+ TestComponent,
+ null,
+ 'Custom Component'
+ ),
+ );
+ expect( JSON.stringify( createInterpolateElement(
+ testString,
+ {
+ TestComponent: ,
+ }
+ ) ) ).toEqual( JSON.stringify( expectedElement ) );
+ } );
+ it( 'returns expected output for self closing custom component', () => {
+ const TestComponent = ( props ) => {
+ return ;
+ };
+ const testString = 'This is a string with a self closing custom component: ';
+ const expectedElement = createElement(
+ Fragment,
+ null,
+ 'This is a string with a self closing custom component: ',
+ createElement( TestComponent ),
+ );
+ expect( JSON.stringify( createInterpolateElement(
+ testString,
+ {
+ TestComponent: ,
+ }
+ ) ) ).toEqual( JSON.stringify( expectedElement ) );
+ } );
+ it( 'throws an error with an invalid element in the conversion map', () => {
+ const test = () => (
+ createInterpolateElement( 'This is a string', { invalid: 10 } )
+ );
+ expect( test ).toThrow( TypeError );
+ } );
+ it( 'returns expected output for complex replacement', () => {
+ class TestComponent extends Component {
+ render( props ) {
+ return ;
+ }
+ }
+ const testString = 'This is a complex string with ' +
+ 'a nested emphasized string link and value: ';
+ const expectedElement = createElement(
+ Fragment,
+ null,
+ 'This is a complex string with a ',
+ createElement(
+ 'a',
+ null,
+ 'nested ',
+ createElement(
+ 'em',
+ null,
+ 'emphasized string'
+ ),
+ ' link',
+ ),
+ ' and value: ',
+ createElement( TestComponent ),
+ );
+ expect( JSON.stringify( createInterpolateElement(
+ testString,
+ {
+ TestComponent: ,
+ em1: ,
+ a1: createElement( 'a' ),
+ }
+ ) ) ).toEqual( JSON.stringify( expectedElement ) );
+ } );
+ it( 'renders expected components across renders for keys in use', () => {
+ const TestComponent = ( { switchKey } ) => {
+ const elementConfig = switchKey ? { item: } : { item: };
+ return
+ { createInterpolateElement( 'This is a - string!
', elementConfig ) }
+
;
+ };
+ let renderer;
+ act( () => {
+ renderer = TestRenderer.create( );
+ } );
+ expect( () => renderer.root.findByType( 'em' ) ).not.toThrow();
+ expect( () => renderer.root.findByType( 'strong' ) ).toThrow();
+ act( () => {
+ renderer.update( );
+ } );
+ expect( () => renderer.root.findByType( 'strong' ) ).not.toThrow();
+ expect( () => renderer.root.findByType( 'em' ) ).toThrow();
+ } );
+ it( 'handles parsing emojii correctly', () => {
+ const testString = '👳♀️🚨🤷♂️⛈️fully here';
+ const expectedElement = createElement(
+ Fragment,
+ null,
+ '👳♀️',
+ createElement(
+ 'strong',
+ null,
+ '🚨🤷♂️⛈️fully',
+ ),
+ ' here',
+ );
+ expect( JSON.stringify( createInterpolateElement(
+ testString,
+ {
+ icon: ,
+ }
+ ) ) ).toEqual( JSON.stringify( expectedElement ) );
+ } );
+} );