diff --git a/blocks/api/raw-handling/test/integration/evernote-out.html b/blocks/api/raw-handling/test/integration/evernote-out.html index 2b52f5d7e413e..70d1649dba5b1 100644 --- a/blocks/api/raw-handling/test/integration/evernote-out.html +++ b/blocks/api/raw-handling/test/integration/evernote-out.html @@ -56,5 +56,5 @@ -
+
diff --git a/blocks/api/raw-handling/test/integration/google-docs-out.html b/blocks/api/raw-handling/test/integration/google-docs-out.html index d8242c4779198..8cb94dc1bbcb8 100644 --- a/blocks/api/raw-handling/test/integration/google-docs-out.html +++ b/blocks/api/raw-handling/test/integration/google-docs-out.html @@ -60,6 +60,6 @@

This is a heading

-
+
diff --git a/blocks/api/raw-handling/test/integration/ms-word-online-out.html b/blocks/api/raw-handling/test/integration/ms-word-online-out.html index d7d6370dfc3ba..afb5993ecff3f 100644 --- a/blocks/api/raw-handling/test/integration/ms-word-online-out.html +++ b/blocks/api/raw-handling/test/integration/ms-word-online-out.html @@ -50,5 +50,5 @@ -
+
diff --git a/blocks/api/raw-handling/test/integration/ms-word-out.html b/blocks/api/raw-handling/test/integration/ms-word-out.html index b5d2e6ba1ca54..02fbf89801c1b 100644 --- a/blocks/api/raw-handling/test/integration/ms-word-out.html +++ b/blocks/api/raw-handling/test/integration/ms-word-out.html @@ -85,5 +85,5 @@

This is a heading level 2

-
+
diff --git a/blocks/api/raw-handling/test/integration/one-image-out.html b/blocks/api/raw-handling/test/integration/one-image-out.html index a346512ebb253..c21d50b34c57d 100644 --- a/blocks/api/raw-handling/test/integration/one-image-out.html +++ b/blocks/api/raw-handling/test/integration/one-image-out.html @@ -1,3 +1,3 @@ -
+
diff --git a/blocks/api/raw-handling/test/integration/two-images-out.html b/blocks/api/raw-handling/test/integration/two-images-out.html index 8d0d93c44843f..03cb3052b1f22 100644 --- a/blocks/api/raw-handling/test/integration/two-images-out.html +++ b/blocks/api/raw-handling/test/integration/two-images-out.html @@ -1,7 +1,7 @@ -
+
-
+
diff --git a/blocks/test/fixtures/core__audio.serialized.html b/blocks/test/fixtures/core__audio.serialized.html index d851452c2d2b2..f7b69332aa882 100644 --- a/blocks/test/fixtures/core__audio.serialized.html +++ b/blocks/test/fixtures/core__audio.serialized.html @@ -1,3 +1,3 @@ -
+
diff --git a/blocks/test/fixtures/core__code.serialized.html b/blocks/test/fixtures/core__code.serialized.html index 3a45062483ea3..651254a7cab49 100644 --- a/blocks/test/fixtures/core__code.serialized.html +++ b/blocks/test/fixtures/core__code.serialized.html @@ -1,5 +1,5 @@
export default function MyButton() {
-	return <Button>Click Me!</Button>;
+	return <Button>Click Me!</Button>;
 }
diff --git a/blocks/test/fixtures/core__image.serialized.html b/blocks/test/fixtures/core__image.serialized.html index bbe320d2bc140..fb41212fe367e 100644 --- a/blocks/test/fixtures/core__image.serialized.html +++ b/blocks/test/fixtures/core__image.serialized.html @@ -1,3 +1,3 @@ -
+
diff --git a/blocks/test/fixtures/core__image__center-caption.serialized.html b/blocks/test/fixtures/core__image__center-caption.serialized.html index 7b7acfcd4d09d..29875b3cf7759 100644 --- a/blocks/test/fixtures/core__image__center-caption.serialized.html +++ b/blocks/test/fixtures/core__image__center-caption.serialized.html @@ -1,5 +1,5 @@ -
-
Give it a try. Press the "really wide" button on the image toolbar.
+
+
Give it a try. Press the "really wide" button on the image toolbar.
diff --git a/blocks/test/fixtures/core__video.serialized.html b/blocks/test/fixtures/core__video.serialized.html index 99abb6e0cf882..48dca62aecd32 100644 --- a/blocks/test/fixtures/core__video.serialized.html +++ b/blocks/test/fixtures/core__video.serialized.html @@ -1,3 +1,3 @@ -
+
diff --git a/element/index.js b/element/index.js index e35c715b07738..35168d88a83cd 100644 --- a/element/index.js +++ b/element/index.js @@ -3,7 +3,6 @@ */ import { createElement, Component, cloneElement, Children, Fragment } from 'react'; import { render, findDOMNode, createPortal, unmountComponentAtNode } from 'react-dom'; -import { renderToStaticMarkup } from 'react-dom/server'; import { camelCase, flowRight, @@ -12,6 +11,11 @@ import { isEmpty, } from 'lodash'; +/** + * Internal dependencies + */ +import serialize from './serialize'; + /** * Returns a new element of given type. Type can be either a string tag name or * another function which itself returns an element. @@ -89,7 +93,7 @@ export { createPortal }; * @return {string} HTML. */ export function renderToString( element ) { - let rendered = renderToStaticMarkup( element ); + let rendered = serialize( element ); // Drop raw HTML wrappers (support dangerous inner HTML without wrapper) rendered = rendered.replace( /<\/?wp-raw-html>/g, '' ); diff --git a/element/serialize.js b/element/serialize.js new file mode 100644 index 0000000000000..708c563dafc99 --- /dev/null +++ b/element/serialize.js @@ -0,0 +1,547 @@ +/** + * 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 { flow, castArray, omit, kebabCase } from 'lodash'; + +/** + * Internal dependencies + */ +import { Fragment } from './'; + +/** + * Valid attribute types. + * + * @type {Set} + */ +const ATTRIBUTES_TYPES = new Set( [ + 'string', + 'boolean', + 'number', +] ); + +/** + * Element tags which can be self-closing. + * + * @type {Set} + */ +const SELF_CLOSING_TAGS = new Set( [ + '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(); + * + * @type {Set} + */ +const BOOLEAN_ATTRIBUTES = new Set( [ + 'allowfullscreen', + 'allowpaymentrequest', + 'allowusermedia', + 'async', + 'autofocus', + 'autoplay', + 'checked', + 'controls', + 'default', + 'defer', + 'disabled', + '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 + * + * @type {Set} + */ +const ENUMERATED_ATTRIBUTES = new Set( [ + '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(); + * + * @type {Set} + */ +const CSS_PROPERTIES_SUPPORTS_UNITLESS = new Set( [ + '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 an escaped attribute value. + * + * @link https://w3c.github.io/html/syntax.html#elements-attributes + * + * "[...] the text cannot contain an ambiguous ampersand [...] must not contain + * any literal U+0022 QUOTATION MARK characters (")" + * + * @param {string} value Attribute value. + * + * @return {string} Escaped attribute value. + */ +function escapeAttribute( value ) { + return value.replace( /&/g, '&' ).replace( /"/g, '"' ); +} + +/** + * Returns an escaped HTML element value. + * + * @link https://w3c.github.io/html/syntax.html#writing-html-documents-elements + * @link https://w3c.github.io/html/syntax.html#ambiguous-ampersand + * + * "the text must not contain the character U+003C LESS-THAN SIGN (<) or an + * ambiguous ampersand." + * + * @param {string} value Element value. + * + * @return {string} Escaped HTML element value. + */ +function escapeHTML( value ) { + return value.replace( /&/g, '&' ).replace( / string.indexOf( prefix ) === 0 ); +} + +/** + * Returns true if the given prop name should be ignored in attributes + * serialization, or false otherwise. + * + * @param {string} attribute Attribute to check. + * + * @return {boolean} Whether attribute should be ignored. + */ +function isInternalAttribute( attribute ) { + return 'key' === attribute || 'children' === attribute; +} + +/** + * Returns the normal form of the element's attribute value for HTML. + * + * @param {string} attribute Attribute name. + * @param {*} value Non-normalized attribute value. + * + * @return {string} Normalized attribute value. + */ +function getNormalAttributeValue( attribute, value ) { + switch ( attribute ) { + case 'style': + return renderStyle( value ); + } + + return value; +} + +/** + * Returns the normal form of the element's attribute name for HTML. + * + * @param {string} attribute Non-normalized attribute name. + * + * @return {string} Normalized attribute name. + */ +function getNormalAttributeName( attribute ) { + switch ( attribute ) { + case 'htmlFor': + return 'for'; + + case 'className': + return 'class'; + } + + return attribute.toLowerCase(); +} + +/** + * 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 {string} property Property name. + * @param {*} value Non-normalized property value. + * + * @return {*} Normalized property value. + */ +function getNormalStyleValue( property, value ) { + if ( typeof value === 'number' && 0 !== value && + ! CSS_PROPERTIES_SUPPORTS_UNITLESS.has( property ) ) { + return value + 'px'; + } + + return value; +} + +/** + * Serializes a React element to string. + * + * @param {WPElement} element Element to serialize. + * @param {?Object} context Context object. + * + * @return {string} Serialized element. + */ +export function renderElement( element, context = {} ) { + if ( null === element || undefined === element || false === element ) { + return ''; + } + + if ( Array.isArray( element ) ) { + return renderChildren( element, context ); + } + + if ( typeof element === 'string' ) { + return escapeHTML( element ); + } + + if ( typeof element === 'number' ) { + return element.toString(); + } + + const { type: tagName, props } = element; + + if ( tagName === Fragment ) { + return renderChildren( props.children, context ); + } + + if ( typeof tagName === 'string' ) { + return renderNativeComponent( tagName, props, context ); + } else if ( typeof tagName === 'function' ) { + if ( tagName.prototype && typeof tagName.prototype.render === 'function' ) { + return renderComponent( tagName, props, context ); + } + + return renderElement( tagName( props, context ), context ); + } + + return ''; +} + +/** + * Serializes a native component type to string. + * + * @param {string} type Native component type to serialize. + * @param {Object} props Props object. + * @param {?Object} context Context object. + * + * @return {string} Serialized element. + */ +export function renderNativeComponent( type, props, context = {} ) { + 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 ); + props = omit( props, 'value' ); + } else if ( props.dangerouslySetInnerHTML ) { + // Dangerous content is left unescaped. + content = props.dangerouslySetInnerHTML.__html; + } else if ( typeof props.children !== 'undefined' ) { + content = renderChildren( castArray( props.children ), context ); + } + + const attributes = renderAttributes( props ); + + if ( SELF_CLOSING_TAGS.has( type ) ) { + return '<' + type + attributes + '/>'; + } + + return '<' + type + attributes + '>' + content + ''; +} + +/** + * Serializes a non-native component type to string. + * + * @param {Function} Component Component type to serialize. + * @param {Object} props Props object. + * @param {?Object} context Context object. + * + * @return {string} Serialized element + */ +export function renderComponent( Component, props, context = {} ) { + const instance = new Component( props, context ); + + if ( typeof instance.componentWillMount === 'function' ) { + instance.componentWillMount(); + } + + if ( typeof instance.getChildContext === 'function' ) { + Object.assign( context, instance.getChildContext() ); + } + + const html = renderElement( instance.render(), context ); + + return html; +} + +/** + * Serializes an array of children to string. + * + * @param {Array} children Children to serialize. + * @param {?Object} context Context object. + * + * @return {string} Serialized children. + */ +function renderChildren( children, context = {} ) { + let result = ''; + + for ( let i = 0; i < children.length; i++ ) { + const child = children[ i ]; + + result += renderElement( child, context ); + } + + return result; +} + +/** + * Renders a props object as a string of HTML attributes. + * + * @param {Object} props Props object. + * + * @return {string} Attributes string. + */ +export function renderAttributes( props ) { + let result = ''; + + for ( const key in props ) { + const attribute = getNormalAttributeName( key ); + let value = getNormalAttributeValue( key, props[ key ] ); + + // If value is not of serializeable 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; + } + + // Empty values are only rendered if for a meaningful attribute. + if ( ! value && ! 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 {Object} style Style object. + * + * @return {string} Style attribute value. + */ +export function renderStyle( style ) { + let result = ''; + + for ( const property in style ) { + const value = style[ property ]; + if ( null === value || undefined === value ) { + continue; + } + + if ( result ) { + result += ';'; + } + + result += kebabCase( property ) + ':' + getNormalStyleValue( property, value ); + } + + return result; +} + +export default renderElement; diff --git a/element/test/index.js b/element/test/index.js index af182d573c765..cc5c0b119306c 100644 --- a/element/test/index.js +++ b/element/test/index.js @@ -51,6 +51,21 @@ describe( 'element', () => { ) ).toBe( 'Courgette' ); } ); + it( 'should escape attributes and html', () => { + const result = renderToString( createElement( 'a', { + href: '/index.php?foo=bar&qux=<"scary">', + style: { + backgroundColor: 'red', + }, + }, '© (sic) © © 2018 <"WordPress" & Friends>' ) ); + + expect( result ).toBe( + '' + + '&copy (sic) © © 2018 <"WordPress" & Friends>' + + '' + ); + } ); + it( 'strips raw html wrapper', () => { const html = '

So scary!

'; diff --git a/element/test/serialize.js b/element/test/serialize.js new file mode 100644 index 0000000000000..4f506d2707cc3 --- /dev/null +++ b/element/test/serialize.js @@ -0,0 +1,488 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { Component, Fragment } from '../'; +import serialize, { + hasPrefix, + renderElement, + renderNativeComponent, + renderComponent, + renderAttributes, + renderStyle, +} from '../serialize'; + +describe( 'serialize()', () => { + it( 'should render with context', () => { + class Provider extends Component { + getChildContext() { + return { + greeting: 'Hello!', + }; + } + + render() { + return this.props.children; + } + } + + Provider.childContextTypes = { + greeting: noop, + }; + + // NOTE: Technically, a component should only receive context if it + // explicitly defines `contextTypes`. This requirement is ignored in + // our implementation. + + function FunctionComponent( props, context ) { + return 'FunctionComponent: ' + context.greeting; + } + + class ClassComponent extends Component { + render() { + return 'ClassComponent: ' + this.context.greeting; + } + } + + const result = serialize( + + + + + ); + + expect( result ).toBe( + 'FunctionComponent: Hello!' + + 'ClassComponent: Hello!' + ); + } ); + + describe( 'empty attributes', () => { + it( 'should not render a null attribute value', () => { + const result = serialize(