. */ const CLOSES_P = array( 'address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul' ); /** * Gutenberg blocks whose single element may be split into several blocks. */ const SPLITTABLE_BLOCKS = array( 'core/paragraph', 'core/heading' ); /** * Returns the HTML that Microedit operates on for a post. * * Classic-editor posts without block markup store paragraphs as blank * lines; wpautop() makes those explicit so they can be located. Saving * such a post therefore stores the explicit
markup it renders as. * * @param string $content Raw post_content. * @return string */ public static function source( $content ) { if ( has_blocks( $content ) || '' === trim( $content ) ) { return $content; } return wpautop( $content ); } /** * Tokenizes HTML and returns every element with its byte offsets. * * @param string $html HTML fragment. * @return array[] Elements in document order. Keys: tag, start, inner_start, * inner_end, end, depth, parent, closed, has_block_child, * children. */ public static function scan( $html ) { $pattern = '/||]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"\']|"[^"]*"|\'[^\']*\')*)>/s'; preg_match_all( $pattern, $html, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ); $elements = array(); $stack = array(); $skip_until = -1; $close = static function ( $index, $end, $inner_end, $explicit ) use ( &$elements ) { $elements[ $index ]['end'] = $end; $elements[ $index ]['inner_end'] = $inner_end; $elements[ $index ]['closed'] = $explicit; }; foreach ( $tokens as $token ) { $offset = $token[0][1]; $length = strlen( $token[0][0] ); if ( $offset < $skip_until || ! isset( $token[2] ) || '' === $token[2][0] ) { continue; } $is_end = '/' === $token[1][0]; $tag = strtolower( $token[2][0] ); if ( ! $is_end ) { if ( in_array( $tag, self::CLOSES_P, true ) && $stack && 'p' === $elements[ end( $stack ) ]['tag'] ) { $close( array_pop( $stack ), $offset, $offset, false ); } if ( 'li' === $tag ) { // A new list item closes an open sibling list item. for ( $i = count( $stack ) - 1; $i >= 0; $i-- ) { $open = $elements[ $stack[ $i ] ]['tag']; if ( in_array( $open, array( 'ul', 'ol' ), true ) ) { break; } if ( 'li' === $open ) { while ( count( $stack ) > $i ) { $close( array_pop( $stack ), $offset, $offset, false ); } break; } } } $parent = $stack ? end( $stack ) : null; $index = count( $elements ); $element = array( 'tag' => $tag, 'start' => $offset, 'inner_start' => $offset + $length, 'inner_end' => null, 'end' => null, 'depth' => count( $stack ), 'parent' => $parent, 'closed' => false, 'has_block_child' => false, 'children' => array(), ); $elements[] = $element; if ( null !== $parent ) { $elements[ $parent ]['children'][] = $index; } if ( in_array( $tag, self::EDITABLE_TAGS, true ) ) { foreach ( $stack as $ancestor ) { $elements[ $ancestor ]['has_block_child'] = true; } } $self_closing = '/' === substr( rtrim( $token[3][0] ), -1 ); if ( in_array( $tag, self::VOID_TAGS, true ) || $self_closing ) { $close( $index, $offset + $length, $offset + $length, true ); continue; } if ( in_array( $tag, self::RAW_TEXT_TAGS, true ) ) { $end_position = stripos( $html, '' . $tag, $offset + $length ); if ( false === $end_position ) { $close( $index, strlen( $html ), strlen( $html ), false ); $skip_until = strlen( $html ); continue; } $end_tag_end = strpos( $html, '>', $end_position ); $end_tag_end = false === $end_tag_end ? strlen( $html ) : $end_tag_end + 1; $close( $index, $end_tag_end, $end_position, true ); $skip_until = $end_tag_end; continue; } $stack[] = $index; continue; } // End tag: close the nearest matching open element. for ( $i = count( $stack ) - 1; $i >= 0; $i-- ) { if ( $elements[ $stack[ $i ] ]['tag'] === $tag ) { while ( count( $stack ) > $i + 1 ) { $close( array_pop( $stack ), $offset, $offset, false ); } $close( array_pop( $stack ), $offset + $length, $offset, true ); break; } } } while ( $stack ) { $close( array_pop( $stack ), strlen( $html ), strlen( $html ), false ); } return $elements; } /** * Checks whether a scanned element is a minimal replaceable block. * * @param array $element Scanned element. * @return bool */ private static function is_editable( array $element ) { if ( ! in_array( $element['tag'], self::EDITABLE_TAGS, true ) || ! $element['closed'] ) { return false; } // Divs, list items and quotes wrapping other blocks are containers. return ! ( in_array( $element['tag'], self::CONTAINER_TAGS, true ) && $element['has_block_child'] ); } /** * Applies the text-changing parts of the_content to a stored fragment. * * The browser selection comes from rendered text, which WordPress has * texturized (curly quotes, dashes) and capital_P_dangit()-ed. The same * transforms are applied to the raw block so the two can be compared. * * @param string $html Stored HTML fragment. * @return string Plain, whitespace-normalized text. */ public static function display_text( $html ) { $html = capital_P_dangit( wptexturize( convert_chars( $html ) ) ); $text = html_entity_decode( wp_strip_all_tags( $html, false ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return self::fold( $text ); } /** * Normalizes whitespace and typographic variants. Mirrors fold() in JS. * * @param string $text Text. * @return string */ public static function fold( $text ) { $text = strtr( $text, array( "\u{201C}" => '"', "\u{201D}" => '"', "\u{201E}" => '"', "\u{2033}" => '"', "\u{00AB}" => '"', "\u{00BB}" => '"', "\u{2018}" => "'", "\u{2019}" => "'", "\u{201A}" => "'", "\u{2032}" => "'", "\u{2013}" => '-', "\u{2014}" => '-', "\u{2026}" => '.', "\u{00D7}" => 'x', "\u{200B}" => '', "\u{FEFF}" => '', ) ); return trim( (string) preg_replace( '/[\s\x{00A0}]+/u', ' ', $text ) ); } /** * Lists the editable blocks of stored content in document order. * * @param string $source HTML returned by source(). * @return array[] Keys: tag, text, element (scan index). */ public static function blocks( $source ) { $elements = self::scan( $source ); $blocks = array(); foreach ( $elements as $index => $element ) { if ( ! self::is_editable( $element ) ) { continue; } $text = self::display_text( substr( $source, $element['inner_start'], $element['inner_end'] - $element['inner_start'] ) ); if ( '' === $text ) { continue; } $blocks[] = array( 'tag' => $element['tag'], 'text' => $text, 'element' => $index, ); } return $blocks; } /** * Finds a block by its client-supplied index and verifies its identity. * * The caller must already have verified the content hash, so the index * refers to the same list the browser matched against. * * @param string $source Source HTML. * @param int $index Block index. * @param string $tag Expected tag. * @param string $selected Selected rendered text that must occur in the block. * @return array|null Keys: block, element, elements. */ public static function find( $source, $index, $tag, $selected ) { $elements = self::scan( $source ); $blocks = self::blocks( $source ); if ( ! isset( $blocks[ $index ] ) || $blocks[ $index ]['tag'] !== $tag ) { return null; } $needle = self::fold( $selected ); if ( '' === $needle || false === strpos( $blocks[ $index ]['text'], $needle ) ) { return null; } return array( 'block' => $blocks[ $index ], 'element' => $elements[ $blocks[ $index ]['element'] ], 'elements' => $elements, ); } /** * Builds the HTML loaded into the editor for a located block. * * @param string $source Source HTML. * @param array $found Result of find(). * @return string */ public static function editor_html( $source, array $found ) { $element = $found['element']; $outer = substr( $source, $element['start'], $element['end'] - $element['start'] ); switch ( $element['tag'] ) { case 'li': // A lone
' . substr( $source, $element['inner_start'], $element['inner_end'] - $element['inner_start'] ) . '
'; } return $outer; } /** * Returns the Gutenberg block delimiters directly wrapping a span. * * @param string $source Source HTML. * @param int $start Span start. * @param int $end Span end. * @return array|null Keys: name, opener, closer, start, end. */ private static function block_wrapper( $source, $start, $end ) { $before = rtrim( substr( $source, 0, $start ) ); if ( '-->' !== substr( $before, -3 ) ) { return null; } $open_at = strrpos( $before, '$/s', $opener, $m ) ) { return null; } $name = ( '' !== $m[1] ? $m[1] : 'core/' ) . $m[2]; $after = substr( $source, $end ); $plain = ltrim( $after ); if ( ! preg_match( '/^/', $plain, $c ) ) { // Delimiters may omit the "core/" namespace or spell it out. if ( '' !== $m[1] || ! preg_match( '/^/', $plain, $c ) ) { return null; } } $closer_start = $end + ( strlen( $after ) - strlen( $plain ) ); return array( 'name' => $name, 'opener' => $opener, 'closer' => $c[0], 'start' => $open_at, 'end' => $closer_start + strlen( $c[0] ), ); } /** * Splits edited HTML into top-level element fragments. * * @param string $html Edited HTML. * @return array[]|null Keys: tag, html, element, elements. NULL when there * is loose top-level text or an unclosed element. */ private static function top_level( $html ) { $elements = self::scan( $html ); $result = array(); $cursor = 0; foreach ( $elements as $index => $element ) { if ( 0 !== $element['depth'] ) { continue; } $between = substr( $html, $cursor, $element['start'] - $cursor ); if ( '' !== trim( preg_replace( '//s', '', $between ) ) || ( ! $element['closed'] && ! in_array( $element['tag'], array( 'p', 'li' ), true ) ) ) { return null; } $result[] = array( 'tag' => $element['tag'], 'html' => trim( substr( $html, $element['start'], $element['end'] - $element['start'] ) ), 'element' => $element, 'elements' => $elements, 'source' => $html, ); $cursor = $element['end']; } if ( '' !== trim( preg_replace( '//s', '', substr( $html, $cursor ) ) ) ) { return null; } return $result; } /** * Adds a class to the first tag of an HTML fragment. * * @param string $html Fragment. * @param string $class Class name. * @return string */ private static function add_class( $html, $class ) { $processor = new WP_HTML_Tag_Processor( $html ); if ( $processor->next_tag() ) { $processor->add_class( $class ); } return $processor->get_updated_html(); } /** * Returns the direct