diff --git a/.eslintrc.js b/.eslintrc.js index ce7425f4c8892..ba7dcfb6eb87a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -90,6 +90,10 @@ module.exports = { selector: 'CallExpression[callee.name="deprecated"] Property[key.name="version"][value.value=/' + majorMinorRegExp + '/]', message: 'Deprecated functions must be removed before releasing this version.', }, + { + selector: 'CallExpression[callee.name=/^(invokeMap|get|has|hasIn|invoke|result|set|setWith|unset|update|updateWith)$/] > Literal:nth-child(2)', + message: 'Always pass an array as the path argument', + }, ], }, overrides: [ diff --git a/.travis.yml b/.travis.yml index 8e2746581a23e..62311c62d35bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,7 @@ cache: before_install: - nvm install && nvm use + - npm install npm -g branches: only: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6854f7290508c..9d41a0b83ae87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ For example, `add/gallery-block` means you're working on adding a new gallery bl You can pick among all the tickets, or some of the ones labelled Good First Issue. -The workflow is documented in greater detail in the [repository management](./docs/repository-management.md) document. +The workflow is documented in greater detail in the [repository management](./docs/reference/repository-management.md) document. ## Testing diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index f208a1de7a5db..fa308a22c359e 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -93,3 +93,4 @@ This list is manually curated to include valuable contributions by volunteers th | @thrijith | | @Cloud887 | | @hblackett | +| @vishalkakadiya | diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index 3009bff901c19..691f28b2dc007 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -91,7 +91,7 @@ rm -f gutenberg.zip php bin/generate-gutenberg-php.php > gutenberg.tmp.php mv gutenberg.tmp.php gutenberg.php -build_files=$(ls **/build/*.{js,css}) +build_files=$(ls build/*/*.{js,css}) # Generate the plugin zip file status "Creating archive..." diff --git a/bin/get-vendor-scripts.php b/bin/get-vendor-scripts.php index 681e813409299..c3248152abb72 100755 --- a/bin/get-vendor-scripts.php +++ b/bin/get-vendor-scripts.php @@ -11,11 +11,17 @@ // Hacks to get lib/client-assets.php to load. define( 'ABSPATH', dirname( dirname( __FILE__ ) ) ); + /** * Hi, phpcs */ function add_action() {} +/** + * Hi, phpcs + */ +function wp_add_inline_script() {} + // Instead of loading script files, just show how they need to be loaded. define( 'GUTENBERG_LIST_VENDOR_ASSETS', true ); diff --git a/blocks/README.md b/blocks/README.md index 8ce7772cdeb4c..77a7624ed0a06 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -263,113 +263,3 @@ Returns type definitions associated with a registered block. Returns settings associated with a registered control. -## Components - -Because many blocks share the same complex behaviors, the following components -are made available to simplify implementations of your block's `edit` function. - -### `BlockControls` - -When returned by your block's `edit` implementation, renders a toolbar of icon -buttons. This is useful for block-level modifications to be made available when -a block is selected. For example, if your block supports alignment, you may -want to display alignment options in the selected block's toolbar. - -Example: - -```js -( function( blocks, element ) { - var el = element.createElement, - BlockControls = blocks.BlockControls, - AlignmentToolbar = blocks.AlignmentToolbar; - - function edit( props ) { - return [ - // Controls: (only visible when block is selected) - el( BlockControls, { key: 'controls' }, - el( AlignmentToolbar, { - value: props.align, - onChange: function( nextAlign ) { - props.setAttributes( { align: nextAlign } ) - } - } ) - ), - - // Block content: (with alignment as attribute) - el( 'p', { key: 'text', style: { textAlign: props.align } }, - 'Hello World!' - ), - ]; - } -} )( - window.wp.blocks, - window.wp.element -); -``` - -Note in this example that we render `AlignmentToolbar` as a child of the -`BlockControls` element. This is another pre-configured component you can use -to simplify block text alignment. - -Alternatively, you can create your own toolbar controls by passing an array of -`controls` as a prop to the `BlockControls` component. Each control should be -an object with the following properties: - -- `icon: string` - Slug of the Dashicon to be shown in the control's toolbar button -- `title: string` - A human-readable localized text to be shown as the tooltip label of the control's button -- `subscript: ?string` - Optional text to be shown adjacent the button icon as subscript (for example, heading levels) -- `isActive: ?boolean` - Whether the control should be considered active / selected. Defaults to `false`. - -To create divisions between sets of controls within the same `BlockControls` -element, passing `controls` instead as a nested array (array of arrays of -objects). A divider will be shown between each set of controls. - -### `RichText` - -Render a rich -[`contenteditable` input](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content), -providing users the option to add emphasis to content or links to content. It -behaves similarly to a -[controlled component](https://facebook.github.io/react/docs/forms.html#controlled-components), -except that `onChange` is triggered less frequently than would be expected from -a traditional `input` field, usually when the user exits the field. - -The following properties (non-exhaustive list) are made available: - -- `value: string` - Markup value of the field. Only valid markup is - allowed, as determined by `inline` value and available controls. -- `onChange: Function` - Callback handler when the value of the field changes, - passing the new value as its only argument. -- `placeholder: string` - A text hint to be shown to the user when the field - value is empty, similar to the - [`input` and `textarea` attribute of the same name](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/HTML5_updates#The_placeholder_attribute). -- `multiline: String` - A tag name to use for the tag that should be inserted - when Enter is pressed. For example: `li` in a list block, and `p` for a - block that can contain multiple paragraphs. The default is that only inline - elements are allowed to be used in inserted into the text, effectively - disabling the behavior of the "Enter" key. - -Example: - -```js -( function( blocks, element ) { - var el = element.createElement, - RichText = blocks.RichText; - - function edit( props ) { - function onChange( value ) { - props.setAttributes( { text: value } ); - } - - return el( RichText, { - value: props.attributes.text, - onChange: onChange - } ); - } - - // blocks.registerBlockType( ..., { edit: edit, ... } ); -} )( - window.wp.blocks, - window.wp.element -); -``` diff --git a/blocks/api/index.js b/blocks/api/index.js index 8770b2609a85f..897aba32f3176 100644 --- a/blocks/api/index.js +++ b/blocks/api/index.js @@ -11,7 +11,7 @@ export { getBlockAttributes, parseWithAttributeSchema, } from './parser'; -export { default as rawHandler } from './raw-handling'; +export { default as rawHandler, getPhrasingContentSchema } from './raw-handling'; export { default as serialize, getBlockContent, diff --git a/blocks/api/index.native.js b/blocks/api/index.native.js new file mode 100644 index 0000000000000..3bef99c41e803 --- /dev/null +++ b/blocks/api/index.native.js @@ -0,0 +1,11 @@ +export { + createBlock, +} from './factory'; +export { + default as serialize, + getBlockContent, +} from './serializer'; +export { + registerBlockType, + getBlockType, +} from './registration'; diff --git a/blocks/api/raw-handling/blockquote-normaliser.js b/blocks/api/raw-handling/blockquote-normaliser.js index 58633866f5a84..f08e89791280b 100644 --- a/blocks/api/raw-handling/blockquote-normaliser.js +++ b/blocks/api/raw-handling/blockquote-normaliser.js @@ -3,16 +3,7 @@ */ import normaliseBlocks from './normalise-blocks'; -/** - * Browser dependencies - */ -const { ELEMENT_NODE } = window.Node; - export default function( node ) { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - if ( node.nodeName !== 'BLOCKQUOTE' ) { return; } diff --git a/blocks/api/raw-handling/comment-remover.js b/blocks/api/raw-handling/comment-remover.js deleted file mode 100644 index 93b580b5f44ea..0000000000000 --- a/blocks/api/raw-handling/comment-remover.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Browser dependencies - */ -const { COMMENT_NODE } = window.Node; - -export default function( node ) { - if ( node.nodeType !== COMMENT_NODE ) { - return; - } - - node.parentNode.removeChild( node ); -} diff --git a/blocks/api/raw-handling/create-unwrapper.js b/blocks/api/raw-handling/create-unwrapper.js deleted file mode 100644 index 3266cbe62e3d5..0000000000000 --- a/blocks/api/raw-handling/create-unwrapper.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Browser dependencies - */ -const { ELEMENT_NODE } = window.Node; - -function unwrap( node ) { - const parent = node.parentNode; - - while ( node.firstChild ) { - parent.insertBefore( node.firstChild, node ); - } - - parent.removeChild( node ); -} - -export default function( predicate, after ) { - return ( node ) => { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - - if ( ! predicate( node ) ) { - return; - } - - const afterNode = after && after( node ); - - if ( afterNode ) { - node.appendChild( afterNode ); - } - - unwrap( node ); - }; -} diff --git a/blocks/api/raw-handling/embedded-content-reducer.js b/blocks/api/raw-handling/embedded-content-reducer.js deleted file mode 100644 index b2740e8b201fb..0000000000000 --- a/blocks/api/raw-handling/embedded-content-reducer.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Internal dependencies - */ -import { isEmbedded } from './utils'; - -/** - * Browser dependencies - */ -const { ELEMENT_NODE } = window.Node; - -/** - * This filter takes embedded content out of paragraphs. - * - * @param {Node} node The node to filter. - * - * @return {void} - */ -export default function( node ) { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - - if ( ! isEmbedded( node ) ) { - return; - } - - let nodeToInsert = node; - // if the embedded is an image and its parent is an anchor with just the image - // take the anchor out instead of just the image - if ( - 'IMG' === node.nodeName && - 1 === node.parentNode.childNodes.length && - 'A' === node.parentNode.nodeName - ) { - nodeToInsert = node.parentNode; - } - - let wrapper = nodeToInsert; - - while ( wrapper && wrapper.nodeName !== 'P' ) { - wrapper = wrapper.parentElement; - } - - if ( wrapper ) { - wrapper.parentNode.insertBefore( nodeToInsert, wrapper ); - } -} diff --git a/blocks/api/raw-handling/figure-content-reducer.js b/blocks/api/raw-handling/figure-content-reducer.js new file mode 100644 index 0000000000000..1fa63b080d1c4 --- /dev/null +++ b/blocks/api/raw-handling/figure-content-reducer.js @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { has } from 'lodash'; + +/** + * Internal dependencies + */ +import { isPhrasingContent } from './utils'; + +/** + * Whether or not the given node is figure content. + * + * @param {Node} node The node to check. + * @param {Object} schema The schema to use. + * + * @return {boolean} True if figure content, false if not. + */ +function isFigureContent( node, schema ) { + const tag = node.nodeName.toLowerCase(); + + // We are looking for tags that can be a child of the figure tag, excluding + // `figcaption` and any phrasing content. + if ( tag === 'figcaption' || isPhrasingContent( node ) ) { + return false; + } + + return has( schema, [ 'figure', 'children', tag ] ); +} + +/** + * Whether or not the given node can have an anchor. + * + * @param {Node} node The node to check. + * @param {Object} schema The schema to use. + * + * @return {boolean} True if it can, false if not. + */ +function canHaveAnchor( node, schema ) { + const tag = node.nodeName.toLowerCase(); + + return has( schema, [ 'figure', 'children', 'a', 'children', tag ] ); +} + +/** + * This filter takes figure content out of paragraphs, wraps it in a figure + * element, and moves any anchors with it if needed. + * + * @param {Node} node The node to filter. + * @param {Document} doc The document of the node. + * @param {Object} schema The schema to use. + * + * @return {void} + */ +export default function( node, doc, schema ) { + if ( ! isFigureContent( node, schema ) ) { + return; + } + + let nodeToInsert = node; + const parentNode = node.parentNode; + + // If the figure content can have an anchor and its parent is an anchor with + // only the figure content, take the anchor out instead of just the content. + if ( + canHaveAnchor( node, schema ) && + parentNode.nodeName === 'A' && + parentNode.childNodes.length === 1 + ) { + nodeToInsert = node.parentNode; + } + + let wrapper = nodeToInsert; + + while ( wrapper && wrapper.nodeName !== 'P' ) { + wrapper = wrapper.parentElement; + } + + const figure = doc.createElement( 'figure' ); + + if ( wrapper ) { + wrapper.parentNode.insertBefore( figure, wrapper ); + } else { + nodeToInsert.parentNode.insertBefore( figure, nodeToInsert ); + } + + figure.appendChild( nodeToInsert ); +} diff --git a/blocks/api/raw-handling/formatting-transformer.js b/blocks/api/raw-handling/formatting-transformer.js deleted file mode 100644 index 1b12810d9d913..0000000000000 --- a/blocks/api/raw-handling/formatting-transformer.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Browser dependencies - */ -const { ELEMENT_NODE } = window.Node; - -function replace( node, tagName ) { - const newNode = document.createElement( tagName ); - - while ( node.firstChild ) { - newNode.appendChild( node.firstChild ); - } - - node.parentNode.replaceChild( newNode, node ); -} - -export default function( node ) { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - - if ( node.nodeName === 'SPAN' ) { - const fontWeight = node.style.fontWeight; - const fontStyle = node.style.fontStyle; - - if ( fontWeight === 'bold' || fontWeight === '700' ) { - replace( node, 'strong' ); - } else if ( fontStyle === 'italic' ) { - replace( node, 'em' ); - } - } - - if ( node.nodeName === 'B' ) { - replace( node, 'strong' ); - } - - if ( node.nodeName === 'I' ) { - replace( node, 'em' ); - } -} diff --git a/blocks/api/raw-handling/iframe-remover.js b/blocks/api/raw-handling/iframe-remover.js new file mode 100644 index 0000000000000..31733fa042853 --- /dev/null +++ b/blocks/api/raw-handling/iframe-remover.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { remove } from '@wordpress/utils'; + +/** + * Removes iframes. + * + * @param {Node} node The node to check. + * + * @return {void} + */ +export default function( node ) { + if ( node.nodeName === 'IFRAME' ) { + remove( node ); + } +} diff --git a/blocks/api/raw-handling/image-corrector.js b/blocks/api/raw-handling/image-corrector.js index 142cfdcdd8994..c6266c4d99db8 100644 --- a/blocks/api/raw-handling/image-corrector.js +++ b/blocks/api/raw-handling/image-corrector.js @@ -7,13 +7,8 @@ import { createBlobURL } from '@wordpress/utils'; * Browser dependencies */ const { atob, Blob } = window; -const { ELEMENT_NODE } = window.Node; export default function( node ) { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - if ( node.nodeName !== 'IMG' ) { return; } diff --git a/blocks/api/raw-handling/index.js b/blocks/api/raw-handling/index.js index ecf0c5786d63e..453327a639f9f 100644 --- a/blocks/api/raw-handling/index.js +++ b/blocks/api/raw-handling/index.js @@ -1,32 +1,69 @@ /** * External dependencies */ -import { compact } from 'lodash'; -import showdown from 'showdown'; +import { flatMap, filter, compact } from 'lodash'; +// Also polyfills Element#matches. +import 'element-closest'; /** * Internal dependencies */ import { createBlock, getBlockTransforms, findTransform } from '../factory'; -import { getBlockType, getUnknownTypeHandlerName } from '../registration'; +import { getBlockType } from '../registration'; import { getBlockAttributes, parseWithGrammar } from '../parser'; import normaliseBlocks from './normalise-blocks'; -import stripAttributes from './strip-attributes'; import specialCommentConverter from './special-comment-converter'; -import commentRemover from './comment-remover'; -import createUnwrapper from './create-unwrapper'; import isInlineContent from './is-inline-content'; -import formattingTransformer from './formatting-transformer'; +import phrasingContentReducer from './phrasing-content-reducer'; import msListConverter from './ms-list-converter'; import listReducer from './list-reducer'; import imageCorrector from './image-corrector'; import blockquoteNormaliser from './blockquote-normaliser'; -import tableNormaliser from './table-normaliser'; -import inlineContentConverter from './inline-content-converter'; -import embeddedContentReducer from './embedded-content-reducer'; -import { deepFilterHTML, isInvalidInline, isNotWhitelisted, isPlain, isInline } from './utils'; +import figureContentReducer from './figure-content-reducer'; import shortcodeConverter from './shortcode-converter'; -import slackMarkdownVariantCorrector from './slack-markdown-variant-corrector'; +import markdownConverter from './markdown-converter'; +import iframeRemover from './iframe-remover'; +import { + deepFilterHTML, + isPlain, + removeInvalidHTML, + getPhrasingContentSchema, + getBlockContentSchema, +} from './utils'; + +/** + * Browser dependencies + */ +const { log, warn } = window.console; + +export { getPhrasingContentSchema }; + +/** + * Filters HTML to only contain phrasing content. + * + * @param {string} HTML The HTML to filter. + * + * @return {string} HTML only containing phrasing content. + */ +function filterInlineHTML( HTML ) { + HTML = deepFilterHTML( HTML, [ phrasingContentReducer ] ); + HTML = removeInvalidHTML( HTML, getPhrasingContentSchema(), { inline: true } ); + + // Allows us to ask for this information when we get a report. + log( 'Processed inline HTML:\n\n', HTML ); + + return HTML; +} + +function getRawTransformations() { + return filter( getBlockTransforms( 'from' ), { type: 'raw' } ) + .map( ( transform ) => { + return transform.isMatch ? transform : { + ...transform, + isMatch: ( node ) => transform.selector && node.matches( transform.selector ), + }; + } ); +} /** * Converts an HTML string to known blocks. Strips everything else. @@ -38,7 +75,7 @@ import slackMarkdownVariantCorrector from './slack-markdown-variant-corrector'; * * 'INLINE': Always handle as inline content, and return string. * * 'BLOCKS': Always handle as blocks, and return array of blocks. * @param {Array} [options.tagName] The tag into which content will be inserted. - * @param {boolean} [options.canUserUseUnfilteredHTML] Whether or not to user can use unfiltered HTML. + * @param {boolean} [options.canUserUseUnfilteredHTML] Whether or not the user can use unfiltered HTML. * * @return {Array|string} A list of blocks or a string, depending on `handlerMode`. */ @@ -55,17 +92,7 @@ export default function rawHandler( { HTML = '', plainText = '', mode = 'AUTO', // * There is a plain text version. // * There is no HTML version, or it has no formatting. if ( plainText && ( ! HTML || isPlain( HTML ) ) ) { - const converter = new showdown.Converter(); - - converter.setOption( 'noHeaderId', true ); - converter.setOption( 'tables', true ); - converter.setOption( 'literalMidWordUnderscores', true ); - converter.setOption( 'omitExtraWLInCodeBlocks', true ); - converter.setOption( 'simpleLineBreaks', true ); - - plainText = slackMarkdownVariantCorrector( plainText ); - - HTML = converter.makeHtml( plainText ); + HTML = markdownConverter( plainText ); // Switch to inline mode if: // * The current mode is AUTO. @@ -82,101 +109,91 @@ export default function rawHandler( { HTML = '', plainText = '', mode = 'AUTO', } } - // An array of HTML strings and block objects. The blocks replace matched shortcodes. + if ( mode === 'INLINE' ) { + return filterInlineHTML( HTML ); + } + + // An array of HTML strings and block objects. The blocks replace matched + // shortcodes. const pieces = shortcodeConverter( HTML ); - // The call to shortcodeConverter will always return more than one element if shortcodes are matched. - // The reason is when shortcodes are matched empty HTML strings are included. + // The call to shortcodeConverter will always return more than one element + // if shortcodes are matched. The reason is when shortcodes are matched + // empty HTML strings are included. const hasShortcodes = pieces.length > 1; - // True if mode is auto, no shortcode is included and HTML verifies the isInlineContent condition - const isAutoModeInline = mode === 'AUTO' && isInlineContent( HTML, tagName ) && ! hasShortcodes; - - // Return filtered HTML if condition is true - if ( mode === 'INLINE' || isAutoModeInline ) { - HTML = deepFilterHTML( HTML, [ - // Add semantic formatting before attributes are stripped. - formattingTransformer, - stripAttributes, - specialCommentConverter, - commentRemover, - createUnwrapper( ( node ) => ! isInline( node, tagName ) ), - ] ); - - // Allows us to ask for this information when we get a report. - window.console.log( 'Processed inline HTML:\n\n', HTML ); - - return HTML; + if ( mode === 'AUTO' && ! hasShortcodes && isInlineContent( HTML, tagName ) ) { + return filterInlineHTML( HTML ); } - // Before we parse any HTML, extract shorcodes so they don't get messed up. - return pieces.reduce( ( accu, piece ) => { + const rawTransformations = getRawTransformations(); + const phrasingContentSchema = getPhrasingContentSchema(); + const blockContentSchema = getBlockContentSchema( rawTransformations ); + + return compact( flatMap( pieces, ( piece ) => { // Already a block from shortcode. if ( typeof piece !== 'string' ) { - return [ ...accu, piece ]; + return piece; } - // Context dependent filters. Needs to run before we remove nodes. - piece = deepFilterHTML( piece, [ + const filters = [ msListConverter, - ] ); - - piece = deepFilterHTML( piece, compact( [ listReducer, imageCorrector, - // Add semantic formatting before attributes are stripped. - formattingTransformer, - stripAttributes, + phrasingContentReducer, specialCommentConverter, - commentRemover, - ! canUserUseUnfilteredHTML && createUnwrapper( ( element ) => element.nodeName === 'IFRAME' ), - embeddedContentReducer, - createUnwrapper( isNotWhitelisted ), + figureContentReducer, blockquoteNormaliser, - tableNormaliser, - inlineContentConverter, - ] ) ); + ]; - piece = deepFilterHTML( piece, [ - createUnwrapper( isInvalidInline ), - ] ); + if ( ! canUserUseUnfilteredHTML ) { + // Should run before `figureContentReducer`. + filters.unshift( iframeRemover ); + } + + const schema = { + ...blockContentSchema, + // Keep top-level phrasing content, normalised by `normaliseBlocks`. + ...phrasingContentSchema, + }; + piece = deepFilterHTML( piece, filters, blockContentSchema ); + piece = removeInvalidHTML( piece, schema ); piece = normaliseBlocks( piece ); // Allows us to ask for this information when we get a report. - window.console.log( 'Processed HTML piece:\n\n', piece ); + log( 'Processed HTML piece:\n\n', piece ); const doc = document.implementation.createHTMLDocument( '' ); doc.body.innerHTML = piece; - const transformsFrom = getBlockTransforms( 'from' ); - - const blocks = Array.from( doc.body.children ).map( ( node ) => { - const transformation = findTransform( transformsFrom, ( transform ) => ( - transform.type === 'raw' && - transform.isMatch( node ) - ) ); - - if ( transformation ) { - if ( transformation.transform ) { - return transformation.transform( node ); - } - - return createBlock( - transformation.blockName, - getBlockAttributes( - getBlockType( transformation.blockName ), - node.outerHTML - ) + return Array.from( doc.body.children ).map( ( node ) => { + const rawTransformation = findTransform( rawTransformations, ( { isMatch } ) => isMatch( node ) ); + + if ( ! rawTransformation ) { + warn( + 'A block registered a raw transformation schema for `' + node.nodeName + '` but did not match it. ' + + 'Make sure there is a `selector` or `isMatch` property that can match the schema.\n' + + 'Sanitized HTML: `' + node.outerHTML + '`' ); + + return; } - return createBlock( getUnknownTypeHandlerName(), { - content: node.outerHTML, - } ); - } ); + const { transform, blockName } = rawTransformation; + + if ( transform ) { + return transform( node ); + } - return [ ...accu, ...blocks ]; - }, [] ); + return createBlock( + blockName, + getBlockAttributes( + getBlockType( blockName ), + node.outerHTML + ) + ); + } ); + } ) ); } diff --git a/blocks/api/raw-handling/inline-content-converter.js b/blocks/api/raw-handling/inline-content-converter.js deleted file mode 100644 index 1d36ebc8fb465..0000000000000 --- a/blocks/api/raw-handling/inline-content-converter.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Browser dependencies - */ -const { ELEMENT_NODE } = window.Node; - -/** - * Internal dependencies - */ -import { isInlineWrapper, isInline, isAllowedBlock, deepFilterNodeList } from './utils'; -import createUnwrapper from './create-unwrapper'; - -export default function( node, doc ) { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - - if ( ! isInlineWrapper( node ) ) { - return; - } - - deepFilterNodeList( node.childNodes, [ - createUnwrapper( - ( childNode ) => ! isInline( childNode ) && ! isAllowedBlock( node, childNode ), - ( childNode ) => childNode.nextElementSibling && doc.createElement( 'BR' ) - ), - ], doc ); -} diff --git a/blocks/api/raw-handling/is-inline-content.js b/blocks/api/raw-handling/is-inline-content.js index e53ab5a142417..28d9587f0ab86 100644 --- a/blocks/api/raw-handling/is-inline-content.js +++ b/blocks/api/raw-handling/is-inline-content.js @@ -1,21 +1,58 @@ +/** + * External dependencies + */ +import { difference } from 'lodash'; + /** * Internal dependencies */ -import { isInline, isDoubleBR } from './utils'; +import { isPhrasingContent } from './utils'; + +/** + * Checks if the given node should be considered inline content, optionally + * depending on a context tag. + * + * @param {Node} node Node name. + * @param {string} contextTag Tag name. + * + * @return {boolean} True if the node is inline content, false if nohe. + */ +function isInline( node, contextTag ) { + if ( isPhrasingContent( node ) ) { + return true; + } + + if ( ! contextTag ) { + return false; + } + + const tag = node.nodeName.toLowerCase(); + const inlineWhitelistTagGroups = [ + [ 'ul', 'li', 'ol' ], + [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ], + ]; -export default function( HTML, tagName ) { + return inlineWhitelistTagGroups.some( ( tagGroup ) => + difference( [ tag, contextTag ], tagGroup ).length === 0 + ); +} + +function deepCheck( nodes, contextTag ) { + return nodes.every( ( node ) => + isInline( node, contextTag ) && deepCheck( Array.from( node.children ), contextTag ) + ); +} + +function isDoubleBR( node ) { + return node.nodeName === 'BR' && node.previousSibling && node.previousSibling.nodeName === 'BR'; +} + +export default function( HTML, contextTag ) { const doc = document.implementation.createHTMLDocument( '' ); doc.body.innerHTML = HTML; const nodes = Array.from( doc.body.children ); - return ! nodes.some( isDoubleBR ) && deepCheck( nodes, tagName ); -} - -function deepCheck( nodes, tagName ) { - return nodes.every( ( node ) => { - return ( 'SPAN' === node.nodeName || isInline( node, tagName ) ) && - deepCheck( Array.from( node.children ), tagName ); - } ); + return ! nodes.some( isDoubleBR ) && deepCheck( nodes, contextTag ); } diff --git a/blocks/api/raw-handling/list-reducer.js b/blocks/api/raw-handling/list-reducer.js index 25c33792de606..3182c703eb2ab 100644 --- a/blocks/api/raw-handling/list-reducer.js +++ b/blocks/api/raw-handling/list-reducer.js @@ -1,7 +1,11 @@ /** - * Browser dependencies + * WordPress dependencies */ -const { ELEMENT_NODE } = window.Node; +import { unwrap } from '@wordpress/utils'; + +function isList( node ) { + return node.nodeName === 'OL' || node.nodeName === 'UL'; +} function shallowTextContent( element ) { return [ ...element.childNodes ] @@ -10,13 +14,7 @@ function shallowTextContent( element ) { } export default function( node ) { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - - const type = node.nodeName; - - if ( type !== 'OL' && type !== 'UL' ) { + if ( ! isList( node ) ) { return; } @@ -28,7 +26,7 @@ export default function( node ) { // * There is only one list item. if ( prevElement && - prevElement.nodeName === type && + prevElement.nodeName === node.nodeName && list.children.length === 1 ) { prevElement.appendChild( list.firstChild ); @@ -56,4 +54,15 @@ export default function( node ) { parentList.parentNode.removeChild( parentList ); } } + + // Invalid: OL/UL > OL/UL. + if ( parentElement && isList( parentElement ) ) { + const prevListItem = node.previousElementSibling; + + if ( prevListItem ) { + prevListItem.appendChild( node ); + } else { + unwrap( node ); + } + } } diff --git a/blocks/api/raw-handling/markdown-converter.js b/blocks/api/raw-handling/markdown-converter.js new file mode 100644 index 0000000000000..cd0ee5ea4ebfd --- /dev/null +++ b/blocks/api/raw-handling/markdown-converter.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import showdown from 'showdown'; + +// Reuse the same showdown converter. +const converter = new showdown.Converter( { + noHeaderId: true, + tables: true, + literalMidWordUnderscores: true, + omitExtraWLInCodeBlocks: true, + simpleLineBreaks: true, +} ); + +/** + * Corrects the Slack Markdown variant of the code block. + * If uncorrected, it will be converted to inline code. + * + * @see https://get.slack.help/hc/en-us/articles/202288908-how-can-i-add-formatting-to-my-messages-#code-blocks + * + * @param {string} text The potential Markdown text to correct. + * + * @return {string} The corrected Markdown. + */ +function slackMarkdownVariantCorrector( text ) { + return text.replace( + /((?:^|\n)```)([^\n`]+)(```(?:$|\n))/, + ( match, p1, p2, p3 ) => `${ p1 }\n${ p2 }\n${ p3 }` + ); +} + +/** + * Converts a piece of text into HTML based on any Markdown present. + * Also decodes any encoded HTML. + * + * @param {string} text The plain text to convert. + * + * @return {string} HTML. + */ +export default function( text ) { + return converter.makeHtml( slackMarkdownVariantCorrector( text ) ); +} diff --git a/blocks/api/raw-handling/ms-list-converter.js b/blocks/api/raw-handling/ms-list-converter.js index b5408e4ad6e87..0181517493f79 100644 --- a/blocks/api/raw-handling/ms-list-converter.js +++ b/blocks/api/raw-handling/ms-list-converter.js @@ -2,17 +2,12 @@ * Browser dependencies */ const { parseInt } = window; -const { ELEMENT_NODE } = window.Node; function isList( node ) { return node.nodeName === 'OL' || node.nodeName === 'UL'; } -export default function( node ) { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - +export default function( node, doc ) { if ( node.nodeName !== 'P' ) { return; } @@ -43,7 +38,7 @@ export default function( node ) { // See https://html.spec.whatwg.org/multipage/grouping-content.html#attr-ol-type. const type = node.textContent.trim().slice( 0, 1 ); const isNumeric = /[1iIaA]/.test( type ); - const newListNode = document.createElement( isNumeric ? 'ol' : 'ul' ); + const newListNode = doc.createElement( isNumeric ? 'ol' : 'ul' ); if ( isNumeric ) { newListNode.setAttribute( 'type', type ); @@ -54,7 +49,7 @@ export default function( node ) { const listNode = node.previousElementSibling; const listType = listNode.nodeName; - const listItem = document.createElement( 'li' ); + const listItem = doc.createElement( 'li' ); let receivingNode = listNode; @@ -78,7 +73,7 @@ export default function( node ) { // Make sure we append to a list. if ( ! isList( receivingNode ) ) { - receivingNode = receivingNode.appendChild( document.createElement( listType ) ); + receivingNode = receivingNode.appendChild( doc.createElement( listType ) ); } // Append the list item to the list. diff --git a/blocks/api/raw-handling/normalise-blocks.js b/blocks/api/raw-handling/normalise-blocks.js index 9d7ccac4af03b..7bb3b44f2d571 100644 --- a/blocks/api/raw-handling/normalise-blocks.js +++ b/blocks/api/raw-handling/normalise-blocks.js @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { isInline, isEmpty } from './utils'; +import { isPhrasingContent, isEmpty } from './utils'; /** * Browser dependencies @@ -26,7 +26,7 @@ export default function( HTML ) { decu.removeChild( node ); } else { if ( ! accu.lastChild || accu.lastChild.nodeName !== 'P' ) { - accu.appendChild( document.createElement( 'P' ) ); + accu.appendChild( accuDoc.createElement( 'P' ) ); } accu.lastChild.appendChild( node ); @@ -36,7 +36,7 @@ export default function( HTML ) { // BR nodes: create a new paragraph on double, or append to previous. if ( node.nodeName === 'BR' ) { if ( node.nextSibling && node.nextSibling.nodeName === 'BR' ) { - accu.appendChild( document.createElement( 'P' ) ); + accu.appendChild( accuDoc.createElement( 'P' ) ); decu.removeChild( node.nextSibling ); } @@ -57,9 +57,9 @@ export default function( HTML ) { } else { accu.appendChild( node ); } - } else if ( isInline( node ) ) { + } else if ( isPhrasingContent( node ) ) { if ( ! accu.lastChild || accu.lastChild.nodeName !== 'P' ) { - accu.appendChild( document.createElement( 'P' ) ); + accu.appendChild( accuDoc.createElement( 'P' ) ); } accu.lastChild.appendChild( node ); } else { diff --git a/blocks/api/raw-handling/phrasing-content-reducer.js b/blocks/api/raw-handling/phrasing-content-reducer.js new file mode 100644 index 0000000000000..1f60c8d1ee823 --- /dev/null +++ b/blocks/api/raw-handling/phrasing-content-reducer.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { unwrap, replaceTag } from '@wordpress/utils'; + +/** + * Internal dependencies + */ +import { isPhrasingContent } from './utils'; + +function isBlockContent( node, schema = {} ) { + return schema.hasOwnProperty( node.nodeName.toLowerCase() ); +} + +export default function( node, doc, schema ) { + if ( node.nodeName === 'SPAN' ) { + const { fontWeight, fontStyle } = node.style; + + if ( fontWeight === 'bold' || fontWeight === '700' ) { + node = replaceTag( node, 'strong', doc ); + } else if ( fontStyle === 'italic' ) { + node = replaceTag( node, 'em', doc ); + } + } else if ( node.nodeName === 'B' ) { + node = replaceTag( node, 'strong', doc ); + } else if ( node.nodeName === 'I' ) { + node = replaceTag( node, 'em', doc ); + } + + if ( + isPhrasingContent( node ) && + node.hasChildNodes() && + Array.from( node.childNodes ).some( ( child ) => isBlockContent( child, schema ) ) + ) { + unwrap( node ); + } +} diff --git a/blocks/api/raw-handling/shortcode-converter.js b/blocks/api/raw-handling/shortcode-converter.js index 11a9491326f2f..013615a3a6c49 100644 --- a/blocks/api/raw-handling/shortcode-converter.js +++ b/blocks/api/raw-handling/shortcode-converter.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { some, castArray, first, mapValues, pickBy } from 'lodash'; +import { some, castArray, first, mapValues, pickBy, includes } from 'lodash'; /** * Internal dependencies @@ -15,7 +15,7 @@ import { getBlockAttributes } from '../parser'; */ const { shortcode } = window.wp; -function segmentHTMLToShortcodeBlock( HTML ) { +function segmentHTMLToShortcodeBlock( HTML, lastIndex = 0 ) { // Get all matches. const transformsFrom = getBlockTransforms( 'from' ); @@ -32,11 +32,23 @@ function segmentHTMLToShortcodeBlock( HTML ) { const transformTag = first( transformTags ); let match; - let lastIndex = 0; if ( ( match = shortcode.next( transformTag, HTML, lastIndex ) ) ) { + const beforeHTML = HTML.substr( 0, match.index ); + lastIndex = match.index + match.content.length; + // If the shortcode content does not contain HTML and the shortcode is + // not on a new line (or in paragraph from Markdown converter), + // consider the shortcode as inline text, and thus skip conversion for + // this segment. + if ( + ! includes( match.shortcode.content || '', '<' ) && + ! /(\n|
)\s*$/.test( beforeHTML ) + ) { + return segmentHTMLToShortcodeBlock( HTML, lastIndex ); + } + const attributes = mapValues( pickBy( transformation.attributes, ( schema ) => schema.shortcode ), // Passing all of `match` as second argument is intentionally broad @@ -59,9 +71,9 @@ function segmentHTMLToShortcodeBlock( HTML ) { ); return [ - HTML.substr( 0, match.index ), + beforeHTML, block, - ...segmentHTMLToShortcodeBlock( HTML.substr( match.index + match.content.length ) ), + ...segmentHTMLToShortcodeBlock( HTML.substr( lastIndex ) ), ]; } diff --git a/blocks/api/raw-handling/slack-markdown-variant-corrector.js b/blocks/api/raw-handling/slack-markdown-variant-corrector.js deleted file mode 100644 index d3ae3bea7c1d4..0000000000000 --- a/blocks/api/raw-handling/slack-markdown-variant-corrector.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Corrects the Slack Markdown variant of the code block. - * If uncorrected, it will be converted to inline code. - * - * @see https://get.slack.help/hc/en-us/articles/202288908-how-can-i-add-formatting-to-my-messages-#code-blocks - * - * @param {string} text The potential Markdown text to correct. - * - * @return {string} The corrected Markdown. - */ -export default function( text ) { - return text.replace( - /((?:^|\n)```)([^\n`]+)(```(?:$|\n))/, - ( match, p1, p2, p3 ) => `${ p1 }\n${ p2 }\n${ p3 }` - ); -} diff --git a/blocks/api/raw-handling/special-comment-converter.js b/blocks/api/raw-handling/special-comment-converter.js index 8146d671ce639..cbf4dd320821e 100644 --- a/blocks/api/raw-handling/special-comment-converter.js +++ b/blocks/api/raw-handling/special-comment-converter.js @@ -20,16 +20,17 @@ const { COMMENT_NODE } = window.Node; * The custom element is then expected to be recognized by any registered * block's `raw` transform. * - * @param {Node} node The node to be processed. + * @param {Node} node The node to be processed. + * @param {Document} doc The document of the node. * @return {void} */ -export default function( node ) { +export default function( node, doc ) { if ( node.nodeType !== COMMENT_NODE ) { return; } if ( node.nodeValue === 'nextpage' ) { - replace( node, createNextpage() ); + replace( node, createNextpage( doc ) ); return; } @@ -55,12 +56,12 @@ export default function( node ) { } } - replace( node, createMore( customText, noTeaser ) ); + replace( node, createMore( customText, noTeaser, doc ) ); } } -function createMore( customText, noTeaser ) { - const node = document.createElement( 'wp-block' ); +function createMore( customText, noTeaser, doc ) { + const node = doc.createElement( 'wp-block' ); node.dataset.block = 'core/more'; if ( customText ) { node.dataset.customText = customText; @@ -72,8 +73,8 @@ function createMore( customText, noTeaser ) { return node; } -function createNextpage() { - const node = document.createElement( 'wp-block' ); +function createNextpage( doc ) { + const node = doc.createElement( 'wp-block' ); node.dataset.block = 'core/nextpage'; return node; diff --git a/blocks/api/raw-handling/strip-attributes.js b/blocks/api/raw-handling/strip-attributes.js deleted file mode 100644 index 832f71c9e36d5..0000000000000 --- a/blocks/api/raw-handling/strip-attributes.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Browser dependencies - */ -const { ELEMENT_NODE } = window.Node; - -/** - * Internal dependencies - */ -import { isAttributeWhitelisted, isClassWhitelisted } from './utils'; - -export default function( node ) { - if ( node.nodeType !== ELEMENT_NODE ) { - return; - } - - if ( ! node.hasAttributes() ) { - return; - } - - const tag = node.nodeName.toLowerCase(); - - Array.from( node.attributes ).forEach( ( { name } ) => { - if ( name === 'class' || isAttributeWhitelisted( tag, name ) ) { - return; - } - - node.removeAttribute( name ); - } ); - - const oldClasses = node.getAttribute( 'class' ); - - if ( ! oldClasses ) { - return; - } - - const newClasses = oldClasses - .split( ' ' ) - .filter( ( name ) => name && isClassWhitelisted( tag, name ) ) - .join( ' ' ); - - if ( newClasses.length ) { - node.setAttribute( 'class', newClasses ); - } else { - node.removeAttribute( 'class' ); - } -} diff --git a/blocks/api/raw-handling/table-normaliser.js b/blocks/api/raw-handling/table-normaliser.js deleted file mode 100644 index 34ee6ed666faf..0000000000000 --- a/blocks/api/raw-handling/table-normaliser.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Browser dependencies - */ -const { TEXT_NODE } = window.Node; - -export default function( node ) { - if ( node.nodeType !== TEXT_NODE ) { - return; - } - - const parentNode = node.parentNode; - - if ( [ 'TR', 'TBODY', 'THEAD', 'TFOOT', 'TABLE' ].indexOf( parentNode.nodeName ) === -1 ) { - return; - } - - parentNode.removeChild( node ); -} diff --git a/blocks/api/raw-handling/test/comment-remover.js b/blocks/api/raw-handling/test/comment-remover.js deleted file mode 100644 index 91222acfdd8a7..0000000000000 --- a/blocks/api/raw-handling/test/comment-remover.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * External dependencies - */ -import { equal } from 'assert'; - -/** - * Internal dependencies - */ -import commentRemover from '../comment-remover'; -import { deepFilterHTML } from '../utils'; - -describe( 'commentRemover', () => { - it( 'should remove comments', () => { - equal( deepFilterHTML( '', [ commentRemover ] ), '' ); - } ); - - it( 'should deep remove comments', () => { - equal( deepFilterHTML( '
test
', [ commentRemover ] ), 'test
' ); - } ); -} ); diff --git a/blocks/api/raw-handling/test/create-unwrapper.js b/blocks/api/raw-handling/test/create-unwrapper.js deleted file mode 100644 index 772ccff0d2c9e..0000000000000 --- a/blocks/api/raw-handling/test/create-unwrapper.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * External dependencies - */ -import { equal } from 'assert'; - -/** - * Internal dependencies - */ -import createUnwrapper from '../create-unwrapper'; -import { deepFilterHTML } from '../utils'; - -const unwrapper = createUnwrapper( ( node ) => node.nodeName === 'SPAN' ); -const unwrapperWithAfter = createUnwrapper( - ( node ) => node.nodeName === 'P', - () => document.createElement( 'BR' ) -); - -describe( 'createUnwrapper', () => { - it( 'should remove spans', () => { - equal( deepFilterHTML( 'test', [ unwrapper ] ), 'test' ); - } ); - - it( 'should remove wrapped spans', () => { - equal( deepFilterHTML( 'test
', [ unwrapper ] ), 'test
' ); - } ); - - it( 'should remove spans with attributes', () => { - equal( deepFilterHTML( 'test
', [ unwrapper ] ), 'test
' ); - } ); - - it( 'should remove nested spans', () => { - equal( deepFilterHTML( 'test
', [ unwrapper ] ), 'test
' ); - } ); - - it( 'should remove spans, but preserve nested structure', () => { - equal( deepFilterHTML( 'test test
', [ unwrapper ] ), 'test test
' ); - } ); - - it( 'should remove paragraphs and insert line break', () => { - equal( deepFilterHTML( 'test
', [ unwrapperWithAfter ] ), 'testtest
', [ embeddedContentReducer ] ) ) - .toEqual( 'test
' ); - } ); - - it( 'should move an anchor with just an image from paragraph', () => { - expect( deepFilterHTML( '', [ embeddedContentReducer ] ) ) - .toEqual( 'test
' ); - } ); - - it( 'should move multiple images', () => { - expect( deepFilterHTML( '', [ embeddedContentReducer ] ) ) - .toEqual( 'test
' ); - } ); -} ); diff --git a/blocks/api/raw-handling/test/figure-content-reducer.js b/blocks/api/raw-handling/test/figure-content-reducer.js new file mode 100644 index 0000000000000..d8346b81fb9a2 --- /dev/null +++ b/blocks/api/raw-handling/test/figure-content-reducer.js @@ -0,0 +1,41 @@ +/** + * Internal dependencies + */ +import figureContentReducer from '../figure-content-reducer'; +import { deepFilterHTML } from '../utils'; + +describe( 'figureContentReducer', () => { + const schema = { + figure: { + children: { + img: {}, + a: { + children: { + img: {}, + }, + }, + }, + }, + }; + + it( 'should move embedded content from paragraph', () => { + const input = 'test
'; + const output = 'test
'; + + expect( deepFilterHTML( input, [ figureContentReducer ], schema ) ).toEqual( output ); + } ); + + it( 'should move an anchor with just an image from paragraph', () => { + const input = ''; + const output = 'test
'; + + expect( deepFilterHTML( input, [ figureContentReducer ], schema ) ).toEqual( output ); + } ); + + it( 'should move multiple images', () => { + const input = ''; + const output = 'test
'; + + expect( deepFilterHTML( input, [ figureContentReducer ], schema ) ).toEqual( output ); + } ); +} ); diff --git a/blocks/api/raw-handling/test/index.js b/blocks/api/raw-handling/test/index.js index 8e41a4e838170..49809d0ae394e 100644 --- a/blocks/api/raw-handling/test/index.js +++ b/blocks/api/raw-handling/test/index.js @@ -1,99 +1,20 @@ /** * External dependencies */ -import { equal, deepEqual } from 'assert'; +import { equal } from 'assert'; /** * Internal dependencies */ import rawHandler from '../index'; -import { registerBlockType, unregisterBlockType, setUnknownTypeHandlerName } from '../../registration'; -import { createBlock } from '../../factory'; import { getBlockContent } from '../../serializer'; +import { registerCoreBlocks } from '../../../../core-blocks'; describe( 'rawHandler', () => { - it( 'should convert recognised raw content', () => { - registerBlockType( 'test/figure', { - category: 'common', - title: 'test figure', - attributes: { - content: { - type: 'array', - source: 'children', - selector: 'figure', - }, - }, - transforms: { - from: [ - { - type: 'raw', - isMatch: ( node ) => node.nodeName === 'FIGURE', - }, - ], - }, - save: () => {}, - } ); - - const block = rawHandler( { HTML: '' } )[ 0 ]; - const { name, attributes } = createBlock( 'test/figure', { content: [ 'test' ] } ); - - equal( block.name, name ); - deepEqual( block.attributes, attributes ); - - unregisterBlockType( 'test/figure' ); - } ); - - it( 'should handle unknown raw content', () => { - registerBlockType( 'test/unknown', { - category: 'common', - title: 'test unknown', - attributes: { - content: { - type: 'string', - source: 'property', - property: 'innerHTML', - }, - }, - save: () => {}, - } ); - setUnknownTypeHandlerName( 'test/unknown' ); - - const block = rawHandler( { HTML: 'test
test
', + mode: 'INLINE', + } ); + + equal( filtered, 'testtest
test
This is a paragraph with a link and bold.
+ + + +Preserve
line breaks please.
First Header | +Second Header | +
---|---|
Content from cell 1 | +Content from cell 2 | +
Content in the first column | +Content in the second column | +
++ + + +First
+Second
+
Inline code
tags should work.
This is a code block.
+
diff --git a/blocks/api/raw-handling/test/integration/wordpress-in.html b/blocks/api/raw-handling/test/integration/wordpress-in.html
new file mode 100644
index 0000000000000..3336bcf194042
--- /dev/null
+++ b/blocks/api/raw-handling/test/integration/wordpress-in.html
@@ -0,0 +1,4 @@
+This is a paragraph.
+This is a paragraph.
+ + + +test
';
+ equal( markdownConverter( input ), output );
+ } );
+
+ it( 'should correct Slack variant on own line', () => {
+ const input = 'test\n```test```\ntest';
+ const output = 'test
\ntest
\ntest
'; + equal( markdownConverter( input ), output ); + } ); + + it( 'should not correct inline code', () => { + const input = 'test ```test``` test'; + const output = 'test test
test
test
';
+ equal( markdownConverter( input ), output );
+ } );
+} );
diff --git a/blocks/api/raw-handling/test/formatting-transformer.js b/blocks/api/raw-handling/test/phrasing-content-reducer.js
similarity index 50%
rename from blocks/api/raw-handling/test/formatting-transformer.js
rename to blocks/api/raw-handling/test/phrasing-content-reducer.js
index 4389a3ba26176..c9ea0c8c90280 100644
--- a/blocks/api/raw-handling/test/formatting-transformer.js
+++ b/blocks/api/raw-handling/test/phrasing-content-reducer.js
@@ -6,19 +6,23 @@ import { equal } from 'assert';
/**
* Internal dependencies
*/
-import formattingTransformer from '../formatting-transformer';
+import phrasingContentReducer from '../phrasing-content-reducer';
import { deepFilterHTML } from '../utils';
-describe( 'formattingTransformer', () => {
+describe( 'phrasingContentReducer', () => {
it( 'should transform font weight', () => {
- equal( deepFilterHTML( 'test', [ formattingTransformer ] ), 'test' );
+ equal( deepFilterHTML( 'test', [ phrasingContentReducer ], {} ), 'test' );
} );
it( 'should transform numeric font weight', () => {
- equal( deepFilterHTML( 'test', [ formattingTransformer ] ), 'test' );
+ equal( deepFilterHTML( 'test', [ phrasingContentReducer ], {} ), 'test' );
} );
it( 'should transform font style', () => {
- equal( deepFilterHTML( 'test', [ formattingTransformer ] ), 'test' );
+ equal( deepFilterHTML( 'test', [ phrasingContentReducer ], {} ), 'test' );
+ } );
+
+ it( 'should remove invalid phrasing content', () => {
+ equal( deepFilterHTML( 'test
', [ phrasingContentReducer ], { p: {} } ), 'test
' ); } ); } ); diff --git a/blocks/api/raw-handling/test/slack-markdown-variant-corrector.js b/blocks/api/raw-handling/test/slack-markdown-variant-corrector.js deleted file mode 100644 index 610595bb634df..0000000000000 --- a/blocks/api/raw-handling/test/slack-markdown-variant-corrector.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * External dependencies - */ -import { equal } from 'assert'; - -/** - * Internal dependencies - */ -import slackMarkdownVariantCorrector from '../slack-markdown-variant-corrector'; - -describe( 'slackMarkdownVariantCorrector', () => { - it( 'should correct Slack variant', () => { - equal( slackMarkdownVariantCorrector( '```test```' ), '```\ntest\n```' ); - } ); - - it( 'should correct Slack variant on own line', () => { - equal( slackMarkdownVariantCorrector( 'test\n```test```\ntest' ), 'test\n```\ntest\n```\ntest' ); - } ); - - it( 'should not correct inline code', () => { - const text = 'test ```test``` test'; - equal( slackMarkdownVariantCorrector( text ), text ); - } ); - - it( 'should not correct code with line breaks', () => { - const text = '```js\ntest\n```'; - equal( slackMarkdownVariantCorrector( text ), text ); - } ); -} ); diff --git a/blocks/api/raw-handling/test/strip-attributes.js b/blocks/api/raw-handling/test/strip-attributes.js deleted file mode 100644 index a68469cb202f4..0000000000000 --- a/blocks/api/raw-handling/test/strip-attributes.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * External dependencies - */ -import { equal } from 'assert'; - -/** - * Internal dependencies - */ -import stripAttributes from '../strip-attributes'; -import { deepFilterHTML } from '../utils'; - -describe( 'stripAttributes', () => { - it( 'should remove attributes', () => { - equal( deepFilterHTML( 'test
', [ stripAttributes ] ), 'test
' ); - } ); - - it( 'should remove multiple attributes', () => { - equal( deepFilterHTML( 'test
', [ stripAttributes ] ), 'test
' ); - } ); - - it( 'should deep remove attributes', () => { - equal( deepFilterHTML( 'test test
', [ stripAttributes ] ), 'test test
' ); - } ); - - it( 'should remove data-* attributes', () => { - equal( deepFilterHTML( 'test
', [ stripAttributes ] ), 'test
' ); - } ); - - it( 'should keep some attributes', () => { - equal( deepFilterHTML( 'test', [ stripAttributes ] ), 'test' ); - } ); - - it( 'should keep some classes', () => { - equal( deepFilterHTML( '', [ stripAttributes ] ), '' ); - } ); -} ); diff --git a/blocks/api/raw-handling/test/table-normaliser.js b/blocks/api/raw-handling/test/table-normaliser.js deleted file mode 100644 index 27295ba22bcc4..0000000000000 --- a/blocks/api/raw-handling/test/table-normaliser.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * External dependencies - */ -import { equal } from 'assert'; - -/** - * Internal dependencies - */ -import tableNormaliser from '../table-normaliser'; -import { deepFilterHTML } from '../utils'; - -describe( 'tableNormaliser', () => { - it( 'should remove invalid text nodes in table', () => { - equal( - deepFilterHTML( '\n\n | \n
\n |
test
'; + const output = 'test
'; + equal( removeInvalidHTML( input, schema ), output ); } ); - it( 'should return false for formatted text', () => { - equal( isPlain( 'test' ), false ); + it( 'should remove multiple attributes', () => { + const input = 'test
'; + const output = 'test
'; + equal( removeInvalidHTML( input, schema ), output ); + } ); + + it( 'should deep remove attributes', () => { + const input = 'test test
'; + const output = 'test test
'; + equal( removeInvalidHTML( input, schema ), output ); + } ); + + it( 'should remove data-* attributes', () => { + const input = 'test
'; + const output = 'test
'; + equal( removeInvalidHTML( input, schema ), output ); + } ); + + it( 'should keep some attributes', () => { + const input = 'test'; + const output = 'test'; + equal( removeInvalidHTML( input, schema ), output ); + } ); + + it( 'should keep some classes', () => { + const input = ''; + const output = ''; + equal( removeInvalidHTML( input, schema ), output ); + } ); + + it( 'should remove empty nodes that should have children', () => { + const input = ''; + const output = ''; + equal( removeInvalidHTML( input, schema ), output ); + } ); + + it( 'should break up block content with phrasing schema', () => { + const input = 'test
test
'; + const output = 'testtest
test'; + equal( removeInvalidHTML( input, schema ), output ); } ); } ); diff --git a/blocks/api/raw-handling/utils.js b/blocks/api/raw-handling/utils.js index 3d9961c2b6dd0..046b77ddd4268 100644 --- a/blocks/api/raw-handling/utils.js +++ b/blocks/api/raw-handling/utils.js @@ -1,24 +1,19 @@ /** * External dependencies */ -import { includes } from 'lodash'; +import { omit, mergeWith, includes } from 'lodash'; /** - * Browser dependencies + * WordPress dependencies */ -const { ELEMENT_NODE, TEXT_NODE } = window.Node; +import { unwrap, insertAfter, remove } from '@wordpress/utils'; /** - * An array of tag groups used by isInlineForTag function. - * If tagName and nodeName are present in the same group, the node should be treated as inline. - * @type {Array} + * Browser dependencies */ -const inlineWhitelistTagGroups = [ - [ 'ul', 'li', 'ol' ], - [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ], -]; +const { ELEMENT_NODE, TEXT_NODE } = window.Node; -const inlineWhitelist = { +const phrasingContentSchema = { strong: {}, em: {}, del: {}, @@ -29,146 +24,72 @@ const inlineWhitelist = { sub: {}, sup: {}, br: {}, + '#text': {}, }; -const embeddedWhiteList = { - img: { attributes: [ 'src', 'alt' ], classes: [ 'alignleft', 'aligncenter', 'alignright', 'alignnone' ] }, - iframe: { attributes: [ 'src', 'allowfullscreen', 'height', 'width' ] }, -}; - -const inlineWrapperWhiteList = { - figcaption: {}, - h1: {}, - h2: {}, - h3: {}, - h4: {}, - h5: {}, - h6: {}, - p: {}, - li: { children: [ 'ul', 'ol', 'li' ] }, - pre: {}, - td: {}, - th: {}, -}; - -const whitelist = { - ...inlineWhitelist, - ...inlineWrapperWhiteList, - ...embeddedWhiteList, - figure: {}, - blockquote: {}, - hr: {}, - ul: {}, - ol: { attributes: [ 'type' ] }, - table: {}, - thead: {}, - tfoot: {}, - tbody: {}, - tr: {}, -}; - -export function isWhitelisted( element ) { - return whitelist.hasOwnProperty( element.nodeName.toLowerCase() ); -} - -export function isNotWhitelisted( element ) { - return ! isWhitelisted( element ); -} - -export function isAttributeWhitelisted( tag, attribute ) { - return ( - whitelist[ tag ] && - whitelist[ tag ].attributes && - whitelist[ tag ].attributes.indexOf( attribute ) !== -1 - ); -} +// Recursion is needed. +// Possible: strong > em > strong. +// Impossible: strong > strong. +[ 'strong', 'em', 'del', 'ins', 'a', 'code', 'abbr', 'sub', 'sup' ].forEach( ( tag ) => { + phrasingContentSchema[ tag ].children = omit( phrasingContentSchema, tag ); +} ); /** - * Checks if nodeName should be treated as inline when being added to tagName. - * This happens if nodeName and tagName are in the same group defined in inlineWhitelistTagGroups. + * Get schema of possible paths for phrasing content. * - * @param {string} nodeName Node name. - * @param {string} tagName Tag name. + * @see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content * - * @return {boolean} True if nodeName is inline in the context of tagName and - * false otherwise. + * @return {Object} Schema. */ -function isInlineForTag( nodeName, tagName ) { - if ( ! tagName || ! nodeName ) { - return false; - } - return inlineWhitelistTagGroups.some( ( tagGroup ) => - includes( tagGroup, nodeName ) && includes( tagGroup, tagName ) - ); -} - -export function isInline( node, tagName ) { - const nodeName = node.nodeName.toLowerCase(); - return inlineWhitelist.hasOwnProperty( nodeName ) || isInlineForTag( nodeName, tagName ); -} - -export function isClassWhitelisted( tag, name ) { - return ( - whitelist[ tag ] && - whitelist[ tag ].classes && - whitelist[ tag ].classes.indexOf( name ) !== -1 - ); +export function getPhrasingContentSchema() { + return phrasingContentSchema; } /** - * Whether or not the given node is embedded content. + * Find out whether or not the given node is phrasing content. * - * @see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Embedded_content + * @see https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content * - * @param {Node} node The node to check. + * @param {Element} node The node to test. * - * @return {boolean} True if embedded content, false if not. + * @return {boolean} True if phrasing content, false if not. */ -export function isEmbedded( node ) { - return embeddedWhiteList.hasOwnProperty( node.nodeName.toLowerCase() ); +export function isPhrasingContent( node ) { + const tag = node.nodeName.toLowerCase(); + return getPhrasingContentSchema().hasOwnProperty( tag ) || tag === 'span'; } -export function isInlineWrapper( node ) { - return inlineWrapperWhiteList.hasOwnProperty( node.nodeName.toLowerCase() ); -} - -export function isAllowedBlock( parentNode, node ) { - const parentNodeTag = parentNode.nodeName.toLowerCase(); - const nodeTag = node.nodeName.toLowerCase(); - - return ( - whitelist[ parentNodeTag ] && - whitelist[ parentNodeTag ].children && - whitelist[ parentNodeTag ].children.indexOf( nodeTag ) !== -1 - ); -} - -export function isInvalidInline( element ) { - if ( ! isInline( element ) ) { - return false; - } - - if ( ! element.hasChildNodes() ) { - return false; - } +/** + * Given raw transforms from blocks, merges all schemas into one. + * + * @param {Array} transforms Block transforms, of the `raw` type. + * + * @return {Object} A complete block content schema. + */ +export function getBlockContentSchema( transforms ) { + const schemas = transforms.map( ( { schema } ) => schema ); - return Array.from( element.childNodes ).some( ( node ) => { - if ( node.nodeType === ELEMENT_NODE ) { - if ( ! isInline( node ) ) { - return true; + return mergeWith( {}, ...schemas, ( objValue, srcValue, key ) => { + if ( key === 'children' ) { + if ( objValue === '*' || srcValue === '*' ) { + return '*'; } - return isInvalidInline( node ); + return { ...objValue, ...srcValue }; + } else if ( key === 'attributes' || key === 'require' ) { + return [ ...( objValue || [] ), ...( srcValue || [] ) ]; } - - return false; } ); } -export function isDoubleBR( node ) { - return node.nodeName === 'BR' && node.previousSibling && node.previousSibling.nodeName === 'BR'; -} - +/** + * Recursively checks if an element is empty. An element is not empty if it + * contains text or contains elements with attributes such as images. + * + * @param {Element} element The element to check. + * + * @return {boolean} Wether or not the element is empty. + */ export function isEmpty( element ) { if ( ! element.hasChildNodes() ) { return true; @@ -193,23 +114,16 @@ export function isEmpty( element ) { } ); } +/** + * Checks wether HTML can be considered plain text. That is, it does not contain + * any elements that are not line breaks. + * + * @param {string} HTML The HTML to check. + * + * @return {boolean} Wether the HTML can be considered plain text. + */ export function isPlain( HTML ) { - const doc = document.implementation.createHTMLDocument( '' ); - - doc.body.innerHTML = HTML; - - const brs = doc.querySelectorAll( 'br' ); - - // Remove all BR nodes. - Array.from( brs ).forEach( ( node ) => { - node.parentNode.replaceChild( document.createTextNode( '\n' ), node ); - } ); - - // Merge all text nodes. - doc.body.normalize(); - - // If it's plain text, there should only be one node left. - return doc.body.childNodes.length === 1 && doc.body.firstChild.nodeType === TEXT_NODE; + return ! /<(?!br[ />])/i.test( HTML ); } /** @@ -218,36 +132,144 @@ export function isPlain( HTML ) { * @param {NodeList} nodeList The nodeList to filter. * @param {Array} filters An array of functions that can mutate with the provided node. * @param {Document} doc The document of the nodeList. + * @param {Object} schema The schema to use. */ -export function deepFilterNodeList( nodeList, filters, doc ) { +export function deepFilterNodeList( nodeList, filters, doc, schema ) { Array.from( nodeList ).forEach( ( node ) => { - deepFilterNodeList( node.childNodes, filters, doc ); + deepFilterNodeList( node.childNodes, filters, doc, schema ); - filters.forEach( ( filter ) => { + filters.forEach( ( item ) => { // Make sure the node is still attached to the document. if ( ! doc.contains( node ) ) { return; } - filter( node, doc ); + item( node, doc, schema ); } ); } ); } /** * Given node filters, deeply filters HTML tags. + * Filters from the deepest nodes to the top. * * @param {string} HTML The HTML to filter. * @param {Array} filters An array of functions that can mutate with the provided node. + * @param {Object} schema The schema to use. * * @return {string} The filtered HTML. */ -export function deepFilterHTML( HTML, filters = [] ) { +export function deepFilterHTML( HTML, filters = [], schema ) { + const doc = document.implementation.createHTMLDocument( '' ); + + doc.body.innerHTML = HTML; + + deepFilterNodeList( doc.body.childNodes, filters, doc, schema ); + + return doc.body.innerHTML; +} + +/** + * Given a schema, unwraps or removes nodes, attributes and classes on a node + * list. + * + * @param {NodeList} nodeList The nodeList to filter. + * @param {Document} doc The document of the nodeList. + * @param {Object} schema An array of functions that can mutate with the provided node. + * @param {Object} inline Whether to clean for inline mode. + */ +function cleanNodeList( nodeList, doc, schema, inline ) { + Array.from( nodeList ).forEach( ( node ) => { + const tag = node.nodeName.toLowerCase(); + + // It's a valid child. + if ( schema.hasOwnProperty( tag ) ) { + if ( node.nodeType === ELEMENT_NODE ) { + const { attributes = [], classes = [], children, require = [] } = schema[ tag ]; + + // If the node is empty and it's supposed to have children, + // remove the node. + if ( isEmpty( node ) && children ) { + remove( node ); + return; + } + + if ( node.hasAttributes() ) { + // Strip invalid attributes. + Array.from( node.attributes ).forEach( ( { name } ) => { + if ( name !== 'class' && ! includes( attributes, name ) ) { + node.removeAttribute( name ); + } + } ); + + // Strip invalid classes. + if ( node.classList.length ) { + const newClasses = classes.filter( ( name ) => + node.classList.contains( name ) + ); + + if ( newClasses.length ) { + node.setAttribute( 'class', newClasses.join( ' ' ) ); + } else { + node.removeAttribute( 'class' ); + } + } + } + + if ( node.hasChildNodes() ) { + // Do not filter any content. + if ( children === '*' ) { + return; + } + + // Continue if the node is supposed to have children. + if ( children ) { + // If a parent requires certain children, but it does + // not have them, drop the parent and continue. + if ( require.length && ! node.querySelector( require.join( ',' ) ) ) { + cleanNodeList( node.childNodes, doc, schema, inline ); + unwrap( node ); + } + + cleanNodeList( node.childNodes, doc, children, inline ); + // Remove children if the node is not supposed to have any. + } else { + while ( node.firstChild ) { + remove( node.firstChild ); + } + } + } + } + // Invalid child. Continue with schema at the same place and unwrap. + } else { + cleanNodeList( node.childNodes, doc, schema, inline ); + + // For inline mode, insert a line break when unwrapping nodes that + // are not phrasing content. + if ( inline && ! isPhrasingContent( node ) && node.nextElementSibling ) { + insertAfter( doc.createElement( 'br' ), node ); + } + + unwrap( node ); + } + } ); +} + +/** + * Given a schema, unwraps or removes nodes, attributes and classes on HTML. + * + * @param {string} HTML The HTML to clean up. + * @param {Object} schema Schema for the HTML. + * @param {Object} inline Whether to clean for inline mode. + * + * @return {string} The cleaned up HTML. + */ +export function removeInvalidHTML( HTML, schema, inline ) { const doc = document.implementation.createHTMLDocument( '' ); doc.body.innerHTML = HTML; - deepFilterNodeList( doc.body.childNodes, filters, doc ); + cleanNodeList( doc.body.childNodes, doc, schema, inline ); return doc.body.innerHTML; } diff --git a/blocks/api/serializer.js b/blocks/api/serializer.js index cb9fe63535f43..b206e472627e9 100644 --- a/blocks/api/serializer.js +++ b/blocks/api/serializer.js @@ -3,13 +3,13 @@ */ import { isEmpty, reduce, isObject, castArray, startsWith } from 'lodash'; import { html as beautifyHtml } from 'js-beautify'; -import isShallowEqual from 'shallowequal'; /** * WordPress dependencies */ import { Component, cloneElement, renderToString } from '@wordpress/element'; import { hasFilter, applyFilters } from '@wordpress/hooks'; +import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies diff --git a/blocks/api/test/factory.js b/blocks/api/test/factory.js index 98f44734cd379..553d337245fd0 100644 --- a/blocks/api/test/factory.js +++ b/blocks/api/test/factory.js @@ -31,7 +31,7 @@ describe( 'block factory', () => { beforeAll( () => { // Load all hooks that modify blocks - require( 'blocks/hooks' ); + require( 'editor/hooks' ); } ); afterEach( () => { diff --git a/blocks/api/test/parser.js b/blocks/api/test/parser.js index 36208909138aa..1eacdc317e37f 100644 --- a/blocks/api/test/parser.js +++ b/blocks/api/test/parser.js @@ -43,7 +43,7 @@ describe( 'block parser', () => { beforeAll( () => { // Load all hooks that modify blocks - require( 'blocks/hooks' ); + require( 'editor/hooks' ); } ); afterEach( () => { diff --git a/blocks/api/test/registration.js b/blocks/api/test/registration.js index e68e8471c3220..0c6317ba11c96 100644 --- a/blocks/api/test/registration.js +++ b/blocks/api/test/registration.js @@ -30,7 +30,7 @@ describe( 'blocks', () => { beforeAll( () => { // Load all hooks that modify blocks - require( 'blocks/hooks' ); + require( 'editor/hooks' ); } ); afterEach( () => { diff --git a/blocks/api/test/serializer.js b/blocks/api/test/serializer.js index 78a8394e6e41a..345c3ef3cc2f7 100644 --- a/blocks/api/test/serializer.js +++ b/blocks/api/test/serializer.js @@ -23,12 +23,14 @@ import { setUnknownTypeHandlerName, } from '../registration'; import { createBlock } from '../'; -import InnerBlocks from '../../inner-blocks'; + +// Todo: move the test to the inner-blocks folder +import InnerBlocks from '../../../editor/components/inner-blocks'; describe( 'block serializer', () => { beforeAll( () => { // Load all hooks that modify blocks - require( 'blocks/hooks' ); + require( 'editor/hooks' ); } ); afterEach( () => { diff --git a/blocks/color-palette/index.js b/blocks/color-palette/index.js deleted file mode 100644 index cb1d9417d1377..0000000000000 --- a/blocks/color-palette/index.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * External dependencies - */ -import classnames from 'classnames'; -import { ChromePicker } from 'react-color'; -import { map } from 'lodash'; - -/** - * WordPress dependencies - */ -import { Dropdown } from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import './style.scss'; -import { withEditorSettings } from '../editor-settings'; - -export function ColorPalette( { colors, disableCustomColors = false, value, onChange } ) { - function applyOrUnset( color ) { - return () => onChange( value === color ? undefined : color ); - } - - return ( -