@wordpress/element
Version:
Element React module for WordPress.
382 lines (349 loc) • 9.71 kB
text/typescript
/**
* Internal dependencies
*/
import {
createElement,
cloneElement,
Fragment,
isValidElement,
type Element as ReactElement,
} from './react';
let indoc: string;
let offset: number;
let output: ( string | ReactElement )[];
let stack: Frame[];
/**
* 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, if it exists.
* name: The name portion of the tag (strong, br) (if )
* isSelfClosed: The slash on a self closing tag, if it exists.
*/
const tokenizer = /<(\/)?(\w+)\s*(\/) /g;
interface Frame {
/**
* A parent element which may still have nested children not yet parsed.
*/
element: ReactElement;
/**
* Offset at which parent element first appears.
*/
tokenStart: number;
/**
* Length of string marking start of parent element.
*/
tokenLength: number;
/**
* Running offset at which parsing should continue.
*/
prevOffset?: number;
/**
* Offset at which last closing element finished, used for finding text between elements.
*/
leadingTextStart?: number | null;
/**
* Children.
*/
children: ( string | ReactElement )[];
}
/**
* Tracks recursive-descent parse state.
*
* This is a Stack frame holding parent elements until all children have been
* parsed.
*
* @private
* @param element A parent element which may still have
* nested children not yet parsed.
* @param tokenStart Offset at which parent element first
* appears.
* @param tokenLength Length of string marking start of parent
* element.
* @param prevOffset Running offset at which parsing should
* continue.
* @param leadingTextStart Offset at which last closing element
* finished, used for finding text between
* elements.
*
* @return The stack frame tracking parse progress.
*/
function createFrame(
element: ReactElement,
tokenStart: number,
tokenLength: number,
prevOffset?: number,
leadingTextStart?: number | null
): Frame {
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 interpolatedString The interpolation string to be parsed.
* @param conversionMap The map used to convert the string to
* a react element.
* @throws {TypeError}
* @return A wp element.
*/
const createInterpolateElement = (
interpolatedString: string,
conversionMap: Record< string, ReactElement >
): ReactElement => {
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 React Elements'
);
}
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 React Element
*
* @private
*
* @param conversionMap The map being validated.
*
* @return True means the map is valid.
*/
const isValidConversionMap = (
conversionMap: Record< string, ReactElement >
): boolean => {
const isObject =
typeof conversionMap === 'object' && conversionMap !== null;
const values = isObject && Object.values( conversionMap );
return (
isObject &&
values.length > 0 &&
values.every( ( element ) => isValidElement( element ) )
);
};
type TokenType = 'no-more-tokens' | 'self-closed' | 'opener' | 'closer';
type TokenResult =
| [ TokenType & 'no-more-tokens' ]
| [
TokenType & ( 'self-closed' | 'opener' | 'closer' ),
string,
number,
number,
];
/**
* This is the iterator over the matches in the string.
*
* @private
*
* @param conversionMap The conversion map for the string.
*
* @return true for continuing to iterate, false for finished.
*/
function proceed( conversionMap: Record< string, ReactElement > ): boolean {
const next = nextToken();
const [ tokenType, name, startOffset, tokenLength ] = next;
const stackDepth = stack.length;
const leadingTextStart = startOffset > offset ? offset : null;
if ( name && ! 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(
createFrame( conversionMap[ name ], startOffset, tokenLength )
);
offset = startOffset + tokenLength;
return true;
case 'opener':
stack.push(
createFrame(
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 = createFrame(
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 An array of details for the token matched.
*/
function nextToken(): TokenResult {
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(): void {
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: Frame ): void {
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: number ): void {
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;