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 ) ); + } ); +} );