/** * WordPress dependencies */ import { isPhrasingContent, getPhrasingContentSchema } from '@wordpress/dom'; /** * Internal dependencies */ import { hasBlockSupport } from '..'; import { getRawTransforms } from './get-raw-transforms'; import type { NodeFilterFunction } from './types'; export function getBlockContentSchemaFromTransforms( transforms: any[], context?: string ) { const phrasingContentSchema = getPhrasingContentSchema( context ); const schemaArgs = { phrasingContentSchema, isPaste: context === 'paste' }; const schemas = transforms.map( ( { isMatch, blockName, schema } ) => { const hasAnchorSupport = hasBlockSupport( blockName, 'anchor' ); schema = typeof schema === 'function' ? schema( schemaArgs ) : schema; // If the block does not has anchor support and the transform does not // provides an isMatch we can return the schema right away. if ( ! hasAnchorSupport && ! isMatch ) { return schema; } if ( ! schema ) { return {}; } return Object.fromEntries( Object.entries( schema ).map( ( [ key, value ]: any[] ) => { let attributes = ( value as any ).attributes || []; // If the block supports the "anchor" functionality, it needs to keep its ID attribute. if ( hasAnchorSupport ) { attributes = [ ...attributes, 'id' ]; } return [ key, { ...value, attributes, isMatch: isMatch ? isMatch : undefined, }, ]; } ) ); } ); function mergeTagNameSchemaProperties( objValue: any, srcValue: any, key: string ) { switch ( key ) { case 'children': { if ( objValue === '*' || srcValue === '*' ) { return '*'; } return { ...objValue, ...srcValue }; } case 'attributes': case 'require': { return [ ...( objValue || [] ), ...( srcValue || [] ) ]; } case 'isMatch': { // If one of the values being merge is undefined (matches everything), // the result of the merge will be undefined. if ( ! objValue || ! srcValue ) { return undefined; } // When merging two isMatch functions, the result is a new function // that returns if one of the source functions returns true. return ( ...args: any[] ) => { return objValue( ...args ) || srcValue( ...args ); }; } } } // A tagName schema is an object with children, attributes, require, and // isMatch properties. function mergeTagNameSchemas( a: any, b: any ) { for ( const key in b ) { a[ key ] = a[ key ] ? mergeTagNameSchemaProperties( a[ key ], b[ key ], key ) : { ...b[ key ] }; } return a; } // A schema is an object with tagName schemas by tag name. function mergeSchemas( a: any, b: any ) { for ( const key in b ) { a[ key ] = a[ key ] ? mergeTagNameSchemas( a[ key ], b[ key ] ) : { ...b[ key ] }; } return a; } return schemas.reduce( mergeSchemas, {} ); } /** * Gets the block content schema, which is extracted and merged from all * registered blocks with raw transforms. * * @param context Set to "paste" when in paste context, where the * schema is more strict. * * @return A complete block content schema. */ export function getBlockContentSchema( context?: string ) { return getBlockContentSchemaFromTransforms( getRawTransforms(), context ); } /** * Checks whether HTML can be considered plain text. That is, it does not contain * any elements that are not line breaks, or it only contains a single non-semantic * wrapper element (span) with no semantic child elements. * * @param HTML The HTML to check. * * @return Whether the HTML can be considered plain text. */ export function isPlain( HTML: string ) { if ( ! /<(?!br[ />])/i.test( HTML ) ) { return true; } const doc = document.implementation.createHTMLDocument( '' ); doc.body.innerHTML = HTML; if ( doc.body.children.length !== 1 ) { return false; } const wrapper = doc.body.children.item( 0 )!; const descendants = wrapper.getElementsByTagName( '*' ); for ( let i = 0; i < descendants.length; i++ ) { if ( descendants.item( i )!.tagName !== 'BR' ) { return false; } } if ( wrapper.tagName !== 'SPAN' ) { return false; } return true; } export type { NodeFilterFunction } from './types'; /** * Given node filters, deeply filters and mutates a NodeList. * * @param nodeList The nodeList to filter. * @param filters An array of functions that can mutate with the provided node. * @param doc The document of the nodeList. * @param schema The schema to use. */ export function deepFilterNodeList( nodeList: NodeList, filters: NodeFilterFunction[], doc: Document, schema?: Record< string, unknown > ) { Array.from( nodeList ).forEach( ( node ) => { deepFilterNodeList( node.childNodes, filters, doc, schema ); filters.forEach( ( item ) => { // Make sure the node is still attached to the document. if ( ! doc.contains( node ) ) { return; } item( node, doc, schema ); } ); } ); } /** * Given node filters, deeply filters HTML tags. * Filters from the deepest nodes to the top. * * @param HTML The HTML to filter. * @param filters An array of functions that can mutate with the provided node. * @param schema The schema to use. * * @return The filtered HTML. */ export function deepFilterHTML( HTML: string, filters: NodeFilterFunction[] = [], schema?: Record< string, unknown > ) { const doc = document.implementation.createHTMLDocument( '' ); doc.body.innerHTML = HTML; deepFilterNodeList( doc.body.childNodes, filters, doc, schema ); return doc.body.innerHTML; } /** * Gets a sibling within text-level context. * * @param node The subject node. * @param which "next" or "previous". */ export function getSibling( node: Node, which: string ): Node | undefined { const sibling = ( node as any )[ `${ which }Sibling` ] as Node | undefined; if ( sibling && isPhrasingContent( sibling ) ) { return sibling; } const { parentNode } = node; if ( ! parentNode || ! isPhrasingContent( parentNode ) ) { return; } return getSibling( parentNode, which ); }