Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Feature: Introduce __experimentalCreateInterpolateElement #17376

Merged
merged 38 commits into from
Nov 16, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
12814c4
add initial commit of createInterpolateElement
nerrad Sep 8, 2019
d2417aa
switch to an object for conversionMap argument
nerrad Sep 8, 2019
d9f0298
remove necessity for `hasChildren` entry in conversionConfig.
nerrad Sep 9, 2019
e63f87c
update docs
nerrad Sep 9, 2019
1a91dd1
update changelog
nerrad Sep 9, 2019
7e756ec
remove token-count and move key index incrementation internally
nerrad Sep 9, 2019
8dfc527
don’t export internal only function
nerrad Sep 9, 2019
4a88c20
export as experimental
nerrad Sep 18, 2019
9e22fb8
simplify destructure
nerrad Oct 5, 2019
5eb992a
simplify regex pattern generation and matching
nerrad Oct 5, 2019
675c749
refactor of parser
nerrad Oct 13, 2019
529274d
add new algorithm for parsing i18n string
nerrad Nov 5, 2019
80e7626
add test for exception thrown on invalid element (and fix exposed bug)
nerrad Nov 5, 2019
77d3daf
Improve documentation on tokenizer
nerrad Nov 8, 2019
71e2965
use map name as key on created element (and add tests)
nerrad Nov 8, 2019
2081343
add @private jsdoc notation to all internal only symbols
nerrad Nov 8, 2019
e672682
improve function description
nerrad Nov 8, 2019
fe275c2
more js doc fixes
nerrad Nov 8, 2019
1bf34c6
change signature to receive WPElement and update tests
nerrad Nov 8, 2019
de1093d
simplify output returned
nerrad Nov 8, 2019
8987cba
fix inline docs
nerrad Nov 8, 2019
755b134
refactor to remove elementCreator and simplify invoking cloneElement
nerrad Nov 9, 2019
d8b57c3
add test for string with emojii to ensure it parses correctly
nerrad Nov 9, 2019
88a703f
remove unnecessary space in error message
nerrad Nov 9, 2019
ac46d69
don’t test for specific string in thrown error
nerrad Nov 9, 2019
94e96e5
fix incorrect validation assessment and add additional test
nerrad Nov 9, 2019
07a3dba
remove usage of backticks and use single quotes
nerrad Nov 12, 2019
f294d7b
fix incorrect syntax for jsdoc
nerrad Nov 12, 2019
dabaa82
remove unnecessary falsey check.
nerrad Nov 12, 2019
1adca42
refactor to remove unnecessary iterations and keys
nerrad Nov 13, 2019
c05a26d
rename docs, functions and variables to better reflect the logic.
nerrad Nov 13, 2019
5d3e00e
construct Frame
nerrad Nov 13, 2019
05f4d5c
finish off and tweak inline docs
nerrad Nov 13, 2019
6a6d351
rename Element to Filament and necessary doc changes
nerrad Nov 14, 2019
5b2cb1a
remove unnecessary parameter from addText
nerrad Nov 14, 2019
f35a51c
flattern arrays in tests
nerrad Nov 14, 2019
5c16adf
remove unnecessary object
nerrad Nov 15, 2019
954eb2a
remove garbage input handling and related test.
nerrad Nov 16, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/element/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
325 changes: 325 additions & 0 deletions packages/element/src/create-interpolate-element.js
Original file line number Diff line number Diff line change
@@ -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. <strong>, </strong>, <br/>)
* 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 <span>string</span> with <a>a link</a> and a self-closing
* <CustomComponentB/> tag"
*
* You would have something like this as the conversionMap value:
*
* ```js
* {
* span: <span />,
* a: <a href={ 'https://github.com' } />,
* CustomComponentB: <CustomComponent />,
* }
* ```
*
* @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}
nerrad marked this conversation as resolved.
Show resolved Hide resolved
* @return {WPElement} A wp element.
*/
const createInterpolateElement = ( interpolatedString, conversionMap ) => {
indoc = interpolatedString;
offset = 0;
output = [];
stack = [];
tokenizer.lastIndex = 0;

if ( ! isValidConversionMap( conversionMap ) ) {
nerrad marked this conversation as resolved.
Show resolved Hide resolved
throw new TypeError(
'The conversionMap provided is not valid. It must be an object with values that are WPElements'
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we shouldn't throw an error instead. Same for the following if block.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya I wondered about this as well. There's a similar conditional in the new code.


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;
}
nerrad marked this conversation as resolved.
Show resolved Hide resolved
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;
}
nerrad marked this conversation as resolved.
Show resolved Hide resolved
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;
1 change: 1 addition & 0 deletions packages/element/src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as __experimentalCreateInterpolateElement } from './create-interpolate-element';
export * from './react';
export * from './react-platform';
export * from './utils';
Expand Down
Loading