. */ 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, '', $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

  • is not a document; edit it inside its own list type. $parent = null === $element['parent'] ? '' : $found['elements'][ $element['parent'] ]['tag']; $list = in_array( $parent, array( 'ul', 'ol' ), true ) ? $parent : 'ul'; return '<' . $list . '>' . $outer . ''; case 'dt': case 'dd': return '
    ' . $outer . '
    '; case 'figcaption': // TinyMCE cannot hold a bare caption; edit its inline content. return '

    ' . 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
  • children of a top-level list fragment. * * @param array $item Entry from top_level(). * @return string[]|null Outer HTML of each item, or NULL for nested lists. */ private static function list_items( array $item ) { $items = array(); foreach ( $item['element']['children'] as $child_index ) { $child = $item['elements'][ $child_index ]; if ( 'li' !== $child['tag'] ) { return null; } foreach ( $item['elements'] as $descendant ) { if ( $descendant['start'] > $child['start'] && $descendant['start'] < $child['end'] && in_array( $descendant['tag'], array( 'ul', 'ol' ), true ) ) { return null; } } $items[] = trim( substr( $item['source'], $child['start'], $child['end'] - $child['start'] ) ); } return $items; } /** * Serializes one top-level element as a Gutenberg block. * * @param array $item Entry from top_level(). * @return string */ private static function blockify( array $item ) { $tag = $item['tag']; $html = $item['html']; if ( 'p' === $tag ) { return "\n" . $html . "\n"; } if ( preg_match( '/^h([1-6])$/', $tag, $m ) ) { $attrs = '2' === $m[1] ? '' : ' {"level":' . $m[1] . '}'; return '\n" . self::add_class( $html, 'wp-block-heading' ) . "\n"; } if ( 'pre' === $tag ) { return "\n" . self::add_class( $html, 'wp-block-preformatted' ) . "\n"; } if ( in_array( $tag, array( 'ul', 'ol' ), true ) ) { $items = self::list_items( $item ); if ( null !== $items && $items ) { $open = substr( $item['source'], $item['element']['start'], $item['element']['inner_start'] - $item['element']['start'] ); $open = self::add_class( $open, 'wp-block-list' ); $inner = array(); foreach ( $items as $li ) { $inner[] = "\n" . $li . "\n"; } $attrs = 'ol' === $tag ? ' {"ordered":true}' : ''; return '\n" . $open . implode( "\n\n", $inner ) . '\n"; } } if ( 'hr' === $tag ) { return "\n" . self::add_class( $html, 'wp-block-separator' ) . "\n"; } return "\n" . $html . "\n"; } /** * Replaces a located block with edited HTML. * * @param string $source Source HTML. * @param array $found Result of find(). * @param string $replacement Edited HTML from the editor. * @return string|WP_Error Updated source. */ public static function replace( $source, array $found, $replacement ) { $element = $found['element']; $tag = $element['tag']; $items = self::top_level( trim( $replacement ) ); if ( null === $items ) { return new WP_Error( 'microedit_parse', __( 'The replacement HTML could not be parsed.', 'microedit' ) ); } if ( ! $items ) { return new WP_Error( 'microedit_empty', __( 'Enter some replacement text.', 'microedit' ) ); } $wrapper = has_blocks( $source ) ? self::block_wrapper( $source, $element['start'], $element['end'] ) : null; $parent_tag = null === $element['parent'] ? '' : $found['elements'][ $element['parent'] ]['tag']; // List items: one list containing one or more items. if ( 'li' === $tag ) { $lists = count( $items ) === 1 && in_array( $items[0]['tag'], array( 'ul', 'ol' ), true ) ? self::list_items( $items[0] ) : null; if ( ! $lists ) { return new WP_Error( 'microedit_structure', __( 'A list item must remain a single list. Nested lists cannot be edited here.', 'microedit' ) ); } if ( $wrapper && 'core/list-item' === $wrapper['name'] ) { $parts = array(); foreach ( $lists as $i => $li ) { $parts[] = ( 0 === $i ? $wrapper['opener'] : '' ) . "\n" . $li . "\n" . ( 0 === $i ? $wrapper['closer'] : '' ); } return self::splice( $source, $wrapper['start'], $wrapper['end'], implode( "\n\n", $parts ) ); } return self::splice( $source, $element['start'], $element['end'], implode( "\n", $lists ) ); } // Definition list entries: exactly one entry of the same kind. if ( in_array( $tag, array( 'dt', 'dd' ), true ) ) { $entries = count( $items ) === 1 && 'dl' === $items[0]['tag'] ? $items[0]['element']['children'] : array(); if ( count( $entries ) !== 1 || $items[0]['elements'][ $entries[0] ]['tag'] !== $tag ) { return new WP_Error( 'microedit_structure', __( 'This definition list entry must remain a single entry.', 'microedit' ) ); } $entry = $items[0]['elements'][ $entries[0] ]; return self::splice( $source, $element['start'], $element['end'], substr( $items[0]['source'], $entry['start'], $entry['end'] - $entry['start'] ) ); } // Captions: inline content only; paragraphs become line breaks. if ( 'figcaption' === $tag ) { $lines = array(); foreach ( $items as $item ) { if ( 'p' !== $item['tag'] ) { return new WP_Error( 'microedit_structure', __( 'A caption can only contain text.', 'microedit' ) ); } $lines[] = trim( substr( $item['source'], $item['element']['inner_start'], $item['element']['inner_end'] - $item['element']['inner_start'] ) ); } return self::splice( $source, $element['inner_start'], $element['inner_end'], implode( '
    ', $lines ) ); } // Structural parents cannot acquire arbitrary siblings. if ( count( $items ) > 1 && in_array( $parent_tag, array( 'ul', 'ol', 'dl', 'table', 'tbody', 'thead', 'tfoot', 'tr', 'figure' ), true ) ) { return new WP_Error( 'microedit_structure', __( 'This text block cannot be split into several blocks.', 'microedit' ) ); } $joined = implode( "\n", wp_list_pluck( $items, 'html' ) ); if ( ! $wrapper ) { return self::splice( $source, $element['start'], $element['end'], $joined ); } // Gutenberg paragraph/heading: each new element becomes its own block. // The first keeps the original delimiter (and its attributes) when // its tag is unchanged. if ( in_array( $wrapper['name'], self::SPLITTABLE_BLOCKS, true ) ) { $parts = array(); foreach ( $items as $i => $item ) { if ( 0 === $i && $item['tag'] === $tag ) { $parts[] = $wrapper['opener'] . "\n" . $item['html'] . "\n" . $wrapper['closer']; } else { $parts[] = self::blockify( $item ); } } return self::splice( $source, $wrapper['start'], $wrapper['end'], implode( "\n\n", $parts ) ); } // Other blocks own their markup: keep one element of the same kind. if ( count( $items ) !== 1 || $items[0]['tag'] !== $tag ) { return new WP_Error( 'microedit_structure', /* translators: %s: HTML tag name. */ sprintf( __( 'This block must remain a single <%s> element.', 'microedit' ), $tag ) ); } return self::splice( $source, $element['start'], $element['end'], $items[0]['html'] ); } /** * Replaces a byte range. * * @param string $source Source. * @param int $start Start offset. * @param int $end End offset. * @param string $html Replacement. * @return string */ private static function splice( $source, $start, $end, $html ) { return substr( $source, 0, $start ) . $html . substr( $source, $end ); } }