loadHTML( '
' . $content . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET ); libxml_clear_errors(); if ( ! $ok || ! $dom->documentElement ) { return $content; } self::heal_loose_inlines( $dom->documentElement ); $blocks = array(); foreach ( $dom->documentElement->childNodes as $node ) { $block = self::node_to_block( $node ); if ( '' !== $block ) { $blocks[] = $block; } } return $blocks ? implode( "\n\n", $blocks ) : $content; } /** * Re-home LOOSE inline nodes — a bare link/emphasis/text sitting between * block elements. The model sometimes writes * `

…hook into WordPress at the

plugins_loaded

action…

`, * which renders as one sentence broken across three lines (and, before this, * shipped the bare tag through the custom-HTML fallback). The repair follows * the sentence: a loose run continues the previous paragraph when that * paragraph is plainly unfinished (no terminal punctuation), and pulls the * following paragraph in when IT plainly continues the sentence (starts * lowercase). A run that is NOT mid-sentence just gains its own paragraph — * the same rendering the browser's anonymous block gave it, but as a real, * editable block. Mutates the parsed DOM in place, before block mapping. * * @param \DOMElement $root The parsed content's wrapping element. */ private static function heal_loose_inlines( \DOMElement $root ) { $doc = $root->ownerDocument; $nodes = array(); foreach ( $root->childNodes as $node ) { $nodes[] = $node; // Snapshot: the loop moves nodes around. } $open = null; // The paragraph currently absorbing a mid-sentence run. $last = null; // The previous significant top-level node. foreach ( $nodes as $node ) { if ( XML_TEXT_NODE === $node->nodeType && '' === trim( $node->textContent ) ) { continue; // Formatting whitespace between blocks. } $tag = XML_ELEMENT_NODE === $node->nodeType ? strtolower( $node->nodeName ) : ''; $loose = XML_TEXT_NODE === $node->nodeType || in_array( $tag, self::INLINE_TAGS, true ); if ( $loose ) { if ( ! $open ) { if ( $last instanceof \DOMElement && 'p' === strtolower( $last->nodeName ) && self::sentence_open( $last->textContent ) ) { $open = $last; // The run continues the unfinished sentence. } else { $open = $doc->createElement( 'p' ); // Standalone run: its own paragraph. $root->insertBefore( $open, $node ); $last = $open; } } if ( $open->hasChildNodes() ) { $open->appendChild( $doc->createTextNode( ' ' ) ); // The seam the source line break stood for. } $open->appendChild( $node ); continue; } if ( $open && 'p' === $tag && self::sentence_open( $open->textContent ) && preg_match( '/^\p{Ll}/u', trim( $node->textContent ) ) ) { // The sentence runs on into this paragraph — fold it in. $open->appendChild( $doc->createTextNode( ' ' ) ); while ( $node->firstChild ) { $open->appendChild( $node->firstChild ); } $root->removeChild( $node ); $open = null; continue; } $open = null; $last = $node; } } /** * PURE: whether a paragraph's text ends mid-sentence — no terminal * punctuation (closing quotes/brackets may trail it). Colons and * semicolons count as terminal: "the following:" introduces a block, * it doesn't continue into one. * * @param string $text The paragraph's plain text. * @return bool */ private static function sentence_open( $text ) { $text = trim( (string) $text ); return '' !== $text && ! preg_match( '/[.!?…:;]["\'\x{201D}\x{2019})\]]*$/u', $text ); } /** * One top-level DOM node → one serialised core block. * * @param \DOMNode $node A child of the parsed content root. * @return string Block markup, or '' for ignorable nodes. */ private static function node_to_block( \DOMNode $node ) { if ( XML_TEXT_NODE === $node->nodeType ) { $text = trim( $node->textContent ); return '' === $text ? '' : "\n

" . esc_html( $text ) . "

\n"; } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $tag = strtolower( $node->nodeName ); switch ( $tag ) { case 'p': return "\n

" . self::dom_inner_html( $node ) . "

\n"; case 'h2': return "\n

" . self::dom_inner_html( $node ) . "

\n"; case 'h3': return "\n

" . self::dom_inner_html( $node ) . "

\n"; case 'ul': case 'ol': $items = ''; foreach ( $node->childNodes as $child ) { if ( XML_ELEMENT_NODE === $child->nodeType && 'li' === strtolower( $child->nodeName ) ) { $items .= "\n
  • " . self::dom_inner_html( $child ) . "
  • \n\n"; } } $attrs = 'ol' === $tag ? ' {"ordered":true}' : ''; return '\n<" . $tag . " class=\"wp-block-list\">\n" . $items . '\n"; case 'blockquote': // The quote block nests paragraph blocks; loose inline content // (a bare-text quote) collects into one. $inner = ''; $loose = ''; foreach ( $node->childNodes as $child ) { if ( XML_ELEMENT_NODE === $child->nodeType && 'p' === strtolower( $child->nodeName ) ) { if ( '' !== trim( $loose ) ) { $inner .= "\n

    " . trim( $loose ) . "

    \n"; $loose = ''; } $inner .= "\n

    " . self::dom_inner_html( $child ) . "

    \n"; } else { $loose .= $node->ownerDocument->saveHTML( $child ); } } if ( '' !== trim( $loose ) ) { $inner .= "\n

    " . trim( $loose ) . "

    \n"; } return "\n
    " . $inner . "
    \n"; case 'figure': // Our injected image figure — recover the attachment id from the // wp-image-N class the editor itself uses. $img = $node->getElementsByTagName( 'img' )->item( 0 ); $id = 0; if ( $img && preg_match( '/wp-image-(\d+)/', (string) $img->getAttribute( 'class' ), $m ) ) { $id = (int) $m[1]; } $attrs = $id > 0 ? sprintf( ' {"id":%d,"sizeSlug":"large","linkDestination":"none"}', $id ) : ''; return '\n" . $node->ownerDocument->saveHTML( $node ) . "\n"; default: // Outside the contract's vocabulary: render verbatim through the // custom-HTML block — displays identically, still valid blocks. return "\n" . $node->ownerDocument->saveHTML( $node ) . "\n"; } } /** * The inner HTML of a DOM node, serialised by its own document. * * @param \DOMNode $node Node. * @return string */ private static function dom_inner_html( \DOMNode $node ) { $html = ''; foreach ( $node->childNodes as $child ) { $html .= $node->ownerDocument->saveHTML( $child ); } return $html; } }