@wordpress/element
Version:
Element React module for WordPress.
831 lines (748 loc) • 19.3 kB
text/typescript
/**
* Parts of this source were derived and modified from fast-react-render,
* released under the MIT license.
*
* https://github.com/alt-j/fast-react-render
*
* Copyright (c) 2016 Andrey Morozov
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
/**
* External dependencies
*/
import { isPlainObject } from 'is-plain-object';
import { paramCase as kebabCase } from 'change-case';
/**
* WordPress dependencies
*/
import {
escapeHTML,
escapeAttribute,
isValidAttributeName,
} from '@wordpress/escape-html';
/**
* Internal dependencies
*/
import { createContext, Fragment, StrictMode, forwardRef } from './react';
import RawHTML from './raw-html';
/** @typedef {import('react').ReactElement} ReactElement */
const Context = createContext( undefined );
Context.displayName = 'ElementContext';
interface ComponentInstance {
render: () => React.ReactNode;
getChildContext?: () => Record< string, any >;
}
interface RawHTMLProps {
children: string;
[ key: string ]: any;
}
interface StyleObject {
[ property: string ]: string | number | null | undefined;
}
interface HTMLProps {
dangerouslySetInnerHTML?: {
__html: string;
};
children?: React.ReactNode;
value?: React.ReactNode;
style?: StyleObject | string;
className?: string;
htmlFor?: string;
[ key: string ]: any;
}
const { Provider, Consumer } = Context;
const ForwardRef = forwardRef( () => {
return null;
} );
/**
* Valid attribute types.
*/
const ATTRIBUTES_TYPES = new Set< string >( [ 'string', 'boolean', 'number' ] );
/**
* Element tags which can be self-closing.
*/
const SELF_CLOSING_TAGS = new Set< string >( [
'area',
'base',
'br',
'col',
'command',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr',
] );
/**
* Boolean attributes are attributes whose presence as being assigned is
* meaningful, even if only empty.
*
* See: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes
* Extracted from: https://html.spec.whatwg.org/multipage/indices.html#attributes-3
*
* Object.keys( [ ...document.querySelectorAll( '#attributes-1 > tbody > tr' ) ]
* .filter( ( tr ) => tr.lastChild.textContent.indexOf( 'Boolean attribute' ) !== -1 )
* .reduce( ( result, tr ) => Object.assign( result, {
* [ tr.firstChild.textContent.trim() ]: true
* } ), {} ) ).sort();
*/
const BOOLEAN_ATTRIBUTES = new Set< string >( [
'allowfullscreen',
'allowpaymentrequest',
'allowusermedia',
'async',
'autofocus',
'autoplay',
'checked',
'controls',
'default',
'defer',
'disabled',
'download',
'formnovalidate',
'hidden',
'ismap',
'itemscope',
'loop',
'multiple',
'muted',
'nomodule',
'novalidate',
'open',
'playsinline',
'readonly',
'required',
'reversed',
'selected',
'typemustmatch',
] );
/**
* Enumerated attributes are attributes which must be of a specific value form.
* Like boolean attributes, these are meaningful if specified, even if not of a
* valid enumerated value.
*
* See: https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#enumerated-attribute
* Extracted from: https://html.spec.whatwg.org/multipage/indices.html#attributes-3
*
* Object.keys( [ ...document.querySelectorAll( '#attributes-1 > tbody > tr' ) ]
* .filter( ( tr ) => /^("(.+?)";?\s*)+/.test( tr.lastChild.textContent.trim() ) )
* .reduce( ( result, tr ) => Object.assign( result, {
* [ tr.firstChild.textContent.trim() ]: true
* } ), {} ) ).sort();
*
* Some notable omissions:
*
* - `alt`: https://blog.whatwg.org/omit-alt
*/
const ENUMERATED_ATTRIBUTES = new Set< string >( [
'autocapitalize',
'autocomplete',
'charset',
'contenteditable',
'crossorigin',
'decoding',
'dir',
'draggable',
'enctype',
'formenctype',
'formmethod',
'http-equiv',
'inputmode',
'kind',
'method',
'preload',
'scope',
'shape',
'spellcheck',
'translate',
'type',
'wrap',
] );
/**
* Set of CSS style properties which support assignment of unitless numbers.
* Used in rendering of style properties, where `px` unit is assumed unless
* property is included in this set or value is zero.
*
* Generated via:
*
* Object.entries( document.createElement( 'div' ).style )
* .filter( ( [ key ] ) => (
* ! /^(webkit|ms|moz)/.test( key ) &&
* ( e.style[ key ] = 10 ) &&
* e.style[ key ] === '10'
* ) )
* .map( ( [ key ] ) => key )
* .sort();
*/
const CSS_PROPERTIES_SUPPORTS_UNITLESS = new Set< string >( [
'animation',
'animationIterationCount',
'baselineShift',
'borderImageOutset',
'borderImageSlice',
'borderImageWidth',
'columnCount',
'cx',
'cy',
'fillOpacity',
'flexGrow',
'flexShrink',
'floodOpacity',
'fontWeight',
'gridColumnEnd',
'gridColumnStart',
'gridRowEnd',
'gridRowStart',
'lineHeight',
'opacity',
'order',
'orphans',
'r',
'rx',
'ry',
'shapeImageThreshold',
'stopOpacity',
'strokeDasharray',
'strokeDashoffset',
'strokeMiterlimit',
'strokeOpacity',
'strokeWidth',
'tabSize',
'widows',
'x',
'y',
'zIndex',
'zoom',
] );
/**
* Returns true if the specified string is prefixed by one of an array of
* possible prefixes.
* @param string
* @param prefixes
*/
export function hasPrefix( string: string, prefixes: string[] ): boolean {
return prefixes.some( ( prefix ) => string.indexOf( prefix ) === 0 );
}
/**
* Returns true if the given prop name should be ignored in attributes
* serialization, or false otherwise.
* @param attribute
*/
function isInternalAttribute( attribute: string ): boolean {
return 'key' === attribute || 'children' === attribute;
}
/**
* Returns the normal form of the element's attribute value for HTML.
* @param attribute
* @param value
*/
function getNormalAttributeValue( attribute: string, value: any ): any {
switch ( attribute ) {
case 'style':
return renderStyle( value );
}
return value;
}
/**
* This is a map of all SVG attributes that have dashes. Map(lower case prop => dashed lower case attribute).
* We need this to render e.g strokeWidth as stroke-width.
*
* List from: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute.
*/
const SVG_ATTRIBUTE_WITH_DASHES_LIST: Record< string, string > = [
'accentHeight',
'alignmentBaseline',
'arabicForm',
'baselineShift',
'capHeight',
'clipPath',
'clipRule',
'colorInterpolation',
'colorInterpolationFilters',
'colorProfile',
'colorRendering',
'dominantBaseline',
'enableBackground',
'fillOpacity',
'fillRule',
'floodColor',
'floodOpacity',
'fontFamily',
'fontSize',
'fontSizeAdjust',
'fontStretch',
'fontStyle',
'fontVariant',
'fontWeight',
'glyphName',
'glyphOrientationHorizontal',
'glyphOrientationVertical',
'horizAdvX',
'horizOriginX',
'imageRendering',
'letterSpacing',
'lightingColor',
'markerEnd',
'markerMid',
'markerStart',
'overlinePosition',
'overlineThickness',
'paintOrder',
'panose1',
'pointerEvents',
'renderingIntent',
'shapeRendering',
'stopColor',
'stopOpacity',
'strikethroughPosition',
'strikethroughThickness',
'strokeDasharray',
'strokeDashoffset',
'strokeLinecap',
'strokeLinejoin',
'strokeMiterlimit',
'strokeOpacity',
'strokeWidth',
'textAnchor',
'textDecoration',
'textRendering',
'underlinePosition',
'underlineThickness',
'unicodeBidi',
'unicodeRange',
'unitsPerEm',
'vAlphabetic',
'vHanging',
'vIdeographic',
'vMathematical',
'vectorEffect',
'vertAdvY',
'vertOriginX',
'vertOriginY',
'wordSpacing',
'writingMode',
'xmlnsXlink',
'xHeight',
].reduce(
( map, attribute ) => {
// The keys are lower-cased for more robust lookup.
map[ attribute.toLowerCase() ] = attribute;
return map;
},
{} as Record< string, string >
);
/**
* This is a map of all case-sensitive SVG attributes. Map(lowercase key => proper case attribute).
* The keys are lower-cased for more robust lookup.
* Note that this list only contains attributes that contain at least one capital letter.
* Lowercase attributes don't need mapping, since we lowercase all attributes by default.
*/
const CASE_SENSITIVE_SVG_ATTRIBUTES: Record< string, string > = [
'allowReorder',
'attributeName',
'attributeType',
'autoReverse',
'baseFrequency',
'baseProfile',
'calcMode',
'clipPathUnits',
'contentScriptType',
'contentStyleType',
'diffuseConstant',
'edgeMode',
'externalResourcesRequired',
'filterRes',
'filterUnits',
'glyphRef',
'gradientTransform',
'gradientUnits',
'kernelMatrix',
'kernelUnitLength',
'keyPoints',
'keySplines',
'keyTimes',
'lengthAdjust',
'limitingConeAngle',
'markerHeight',
'markerUnits',
'markerWidth',
'maskContentUnits',
'maskUnits',
'numOctaves',
'pathLength',
'patternContentUnits',
'patternTransform',
'patternUnits',
'pointsAtX',
'pointsAtY',
'pointsAtZ',
'preserveAlpha',
'preserveAspectRatio',
'primitiveUnits',
'refX',
'refY',
'repeatCount',
'repeatDur',
'requiredExtensions',
'requiredFeatures',
'specularConstant',
'specularExponent',
'spreadMethod',
'startOffset',
'stdDeviation',
'stitchTiles',
'suppressContentEditableWarning',
'suppressHydrationWarning',
'surfaceScale',
'systemLanguage',
'tableValues',
'targetX',
'targetY',
'textLength',
'viewBox',
'viewTarget',
'xChannelSelector',
'yChannelSelector',
].reduce(
( map, attribute ) => {
// The keys are lower-cased for more robust lookup.
map[ attribute.toLowerCase() ] = attribute;
return map;
},
{} as Record< string, string >
);
/**
* This is a map of all SVG attributes that have colons.
* Keys are lower-cased and stripped of their colons for more robust lookup.
*/
const SVG_ATTRIBUTES_WITH_COLONS: Record< string, string > = [
'xlink:actuate',
'xlink:arcrole',
'xlink:href',
'xlink:role',
'xlink:show',
'xlink:title',
'xlink:type',
'xml:base',
'xml:lang',
'xml:space',
'xmlns:xlink',
].reduce(
( map, attribute ) => {
map[ attribute.replace( ':', '' ).toLowerCase() ] = attribute;
return map;
},
{} as Record< string, string >
);
/**
* Returns the normal form of the element's attribute name for HTML.
* @param attribute
*/
function getNormalAttributeName( attribute: string ): string {
switch ( attribute ) {
case 'htmlFor':
return 'for';
case 'className':
return 'class';
}
const attributeLowerCase = attribute.toLowerCase();
if ( CASE_SENSITIVE_SVG_ATTRIBUTES[ attributeLowerCase ] ) {
return CASE_SENSITIVE_SVG_ATTRIBUTES[ attributeLowerCase ];
} else if ( SVG_ATTRIBUTE_WITH_DASHES_LIST[ attributeLowerCase ] ) {
return kebabCase(
SVG_ATTRIBUTE_WITH_DASHES_LIST[ attributeLowerCase ]
);
} else if ( SVG_ATTRIBUTES_WITH_COLONS[ attributeLowerCase ] ) {
return SVG_ATTRIBUTES_WITH_COLONS[ attributeLowerCase ];
}
return attributeLowerCase;
}
/**
* Returns the normal form of the style property name for HTML.
*
* - Converts property names to kebab-case, e.g. 'backgroundColor' → 'background-color'
* - Leaves custom attributes alone, e.g. '--myBackgroundColor' → '--myBackgroundColor'
* - Converts vendor-prefixed property names to -kebab-case, e.g. 'MozTransform' → '-moz-transform'
* @param property
*/
function getNormalStylePropertyName( property: string ): string {
if ( property.startsWith( '--' ) ) {
return property;
}
if ( hasPrefix( property, [ 'ms', 'O', 'Moz', 'Webkit' ] ) ) {
return '-' + kebabCase( property );
}
return kebabCase( property );
}
/**
* Returns the normal form of the style property value for HTML. Appends a
* default pixel unit if numeric, not a unitless property, and not zero.
* @param property
* @param value
*/
function getNormalStylePropertyValue(
property: string,
value: any
): string | number {
if (
typeof value === 'number' &&
0 !== value &&
! hasPrefix( property, [ '--' ] ) &&
! CSS_PROPERTIES_SUPPORTS_UNITLESS.has( property )
) {
return value + 'px';
}
return value;
}
/**
* Serializes a React element to string.
* @param element
* @param context
* @param legacyContext
*/
export function renderElement(
element: React.ReactNode,
context?: any,
legacyContext: Record< string, any > = {}
): string {
if ( null === element || undefined === element || false === element ) {
return '';
}
if ( Array.isArray( element ) ) {
return renderChildren( element, context, legacyContext );
}
switch ( typeof element ) {
case 'string':
return escapeHTML( element );
case 'number':
return element.toString();
}
const { type, props } = element as {
type?: any;
props?: any;
};
switch ( type ) {
case StrictMode:
case Fragment:
return renderChildren( props.children, context, legacyContext );
case RawHTML:
const { children, ...wrapperProps } = props as RawHTMLProps;
return renderNativeComponent(
! Object.keys( wrapperProps ).length ? null : 'div',
{
...wrapperProps,
dangerouslySetInnerHTML: { __html: children },
},
context,
legacyContext
);
}
switch ( typeof type ) {
case 'string':
return renderNativeComponent( type, props, context, legacyContext );
case 'function':
if (
type.prototype &&
typeof type.prototype.render === 'function'
) {
return renderComponent( type, props, context, legacyContext );
}
return renderElement(
type( props, legacyContext ),
context,
legacyContext
);
}
switch ( type && type.$$typeof ) {
case Provider.$$typeof:
return renderChildren( props.children, props.value, legacyContext );
case Consumer.$$typeof:
return renderElement(
props.children( context || type._currentValue ),
context,
legacyContext
);
case ForwardRef.$$typeof:
return renderElement(
type.render( props ),
context,
legacyContext
);
}
return '';
}
/**
* Serializes a native component type to string.
* @param type
* @param props
* @param context
* @param legacyContext
*/
export function renderNativeComponent(
type: string | null,
props: HTMLProps,
context?: any,
legacyContext: Record< string, any > = {}
): string {
let content = '';
if ( type === 'textarea' && props.hasOwnProperty( 'value' ) ) {
// Textarea children can be assigned as value prop. If it is, render in
// place of children. Ensure to omit so it is not assigned as attribute
// as well.
content = renderChildren( props.value, context, legacyContext );
const { value, ...restProps } = props;
props = restProps;
} else if (
props.dangerouslySetInnerHTML &&
typeof props.dangerouslySetInnerHTML.__html === 'string'
) {
// Dangerous content is left unescaped.
content = props.dangerouslySetInnerHTML.__html;
} else if ( typeof props.children !== 'undefined' ) {
content = renderChildren( props.children, context, legacyContext );
}
if ( ! type ) {
return content;
}
const attributes = renderAttributes( props );
if ( SELF_CLOSING_TAGS.has( type ) ) {
return '<' + type + attributes + '/>';
}
return '<' + type + attributes + '>' + content + '</' + type + '>';
}
/**
* Serializes a non-native component type to string.
* @param Component
* @param props
* @param context
* @param legacyContext
*/
export function renderComponent(
Component: React.ComponentClass,
props: Record< string, any >,
context?: any,
legacyContext: Record< string, any > = {}
): string {
const instance = new Component( props, legacyContext ) as ComponentInstance;
if ( typeof instance.getChildContext === 'function' ) {
Object.assign( legacyContext, instance.getChildContext() );
}
const html = renderElement( instance.render(), context, legacyContext );
return html;
}
/**
* Serializes an array of children to string.
* @param children
* @param context
* @param legacyContext
*/
function renderChildren(
children: React.ReactNode,
context?: any,
legacyContext: Record< string, any > = {}
): string {
let result = '';
const childrenArray = Array.isArray( children ) ? children : [ children ];
for ( let i = 0; i < childrenArray.length; i++ ) {
const child = childrenArray[ i ];
result += renderElement( child, context, legacyContext );
}
return result;
}
/**
* Renders a props object as a string of HTML attributes.
* @param props
*/
export function renderAttributes( props: Record< string, any > ): string {
let result = '';
for ( const key in props ) {
const attribute = getNormalAttributeName( key );
if ( ! isValidAttributeName( attribute ) ) {
continue;
}
let value = getNormalAttributeValue( key, props[ key ] );
// If value is not of serializable type, skip.
if ( ! ATTRIBUTES_TYPES.has( typeof value ) ) {
continue;
}
// Don't render internal attribute names.
if ( isInternalAttribute( key ) ) {
continue;
}
const isBooleanAttribute = BOOLEAN_ATTRIBUTES.has( attribute );
// Boolean attribute should be omitted outright if its value is false.
if ( isBooleanAttribute && value === false ) {
continue;
}
const isMeaningfulAttribute =
isBooleanAttribute ||
hasPrefix( key, [ 'data-', 'aria-' ] ) ||
ENUMERATED_ATTRIBUTES.has( attribute );
// Only write boolean value as attribute if meaningful.
if ( typeof value === 'boolean' && ! isMeaningfulAttribute ) {
continue;
}
result += ' ' + attribute;
// Boolean attributes should write attribute name, but without value.
// Mere presence of attribute name is effective truthiness.
if ( isBooleanAttribute ) {
continue;
}
if ( typeof value === 'string' ) {
value = escapeAttribute( value );
}
result += '="' + value + '"';
}
return result;
}
/**
* Renders a style object as a string attribute value.
* @param style
*/
export function renderStyle( style: StyleObject | string ): string | undefined {
// Only generate from object, e.g. tolerate string value.
if ( ! isPlainObject( style ) ) {
return style as string;
}
let result: string | undefined;
const styleObj = style as StyleObject;
for ( const property in styleObj ) {
const value = styleObj[ property ];
if ( null === value || undefined === value ) {
continue;
}
if ( result ) {
result += ';';
} else {
result = '';
}
const normalName = getNormalStylePropertyName( property );
const normalValue = getNormalStylePropertyValue( property, value );
result += normalName + ':' + normalValue;
}
return result;
}
export default renderElement;