UNPKG

@wordpress/element

Version:
382 lines (349 loc) 9.71 kB
/** * 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;