true, 'remove_nodes' => 'script style noscript', 'hard_break' => true, ) ); return $this->normalize( $converter->convert( $html ) ); } catch ( \Throwable $e ) { // Fall back to the built-in converter. } } return $this->normalize( $this->convert_native( $html ) ); } /** * Built-in converter on DOMDocument. * * @param string $html HTML. * @return string */ private function convert_native( $html ) { $dom = $this->load_dom( $html ); if ( ! $dom ) { // Last-resort fallback — plain text. return wp_strip_all_tags( $html ); } $this->strip_junk( $dom ); // With LIBXML_HTML_NOIMPLIED no tag is created — our wrapper becomes the root. $root = null; $xpath = new DOMXPath( $dom ); $found = $xpath->query( '//*[@id="adg-aim-root"]' ); if ( $found->length > 0 ) { $root = $found->item( 0 ); } elseif ( $dom->documentElement ) { $root = $dom->documentElement; } if ( ! $root ) { return wp_strip_all_tags( $html ); } return $this->render_children( $root ); } /** * Load HTML into DOMDocument with correct UTF-8 handling. * * @param string $html HTML. * @return DOMDocument|false */ private function load_dom( $html ) { if ( ! class_exists( 'DOMDocument' ) ) { return false; } $dom = new DOMDocument( '1.0', 'UTF-8' ); $prev = libxml_use_internal_errors( true ); // Wrap to set the encoding and not lose non-ASCII characters. $wrapped = '
' . $html . '
'; $ok = $dom->loadHTML( $wrapped, LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); return $ok ? $dom : false; } /** * Remove service nodes: script, style, noscript, svg, comments. * * @param DOMDocument $dom DOM. */ private function strip_junk( DOMDocument $dom ) { $xpath = new DOMXPath( $dom ); foreach ( array( 'script', 'style', 'noscript', 'svg', 'iframe', 'form', 'button', 'input', 'select', 'textarea' ) as $tag ) { $nodes = $xpath->query( '//' . $tag ); for ( $i = $nodes->length - 1; $i >= 0; $i-- ) { $n = $nodes->item( $i ); $n->parentNode->removeChild( $n ); } } // Comments. $comments = $xpath->query( '//comment()' ); for ( $i = $comments->length - 1; $i >= 0; $i-- ) { $c = $comments->item( $i ); $c->parentNode->removeChild( $c ); } // Elements hidden via aria-hidden / display:none (builders' screen-reader navigation). $hidden = $xpath->query( '//*[@aria-hidden="true"]' ); for ( $i = $hidden->length - 1; $i >= 0; $i-- ) { $h = $hidden->item( $i ); $h->parentNode->removeChild( $h ); } } /** * Render a block's child nodes to Markdown. * * @param DOMNode $node Parent. * @return string */ private function render_children( DOMNode $node ) { $out = ''; foreach ( iterator_to_array( $node->childNodes ) as $child ) { $out .= $this->render_block( $child ); } return $out; } /** * Render a block node. * * @param DOMNode $node Node. * @return string */ private function render_block( DOMNode $node ) { if ( XML_TEXT_NODE === $node->nodeType ) { $text = $this->clean_text( $node->textContent ); return '' === trim( $text ) ? '' : $text; } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $tag = strtolower( $node->nodeName ); switch ( $tag ) { case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': $level = (int) substr( $tag, 1 ); return "\n" . str_repeat( '#', $level ) . ' ' . trim( $this->render_inline( $node ) ) . "\n\n"; case 'p': $txt = trim( $this->render_inline( $node ) ); return '' === $txt ? '' : $txt . "\n\n"; case 'br': return " \n"; case 'hr': return "\n---\n\n"; case 'ul': case 'ol': return $this->render_list( $node, $tag, 0 ) . "\n"; case 'blockquote': $inner = trim( $this->render_children( $node ) ); $lines = preg_split( '/\n/', $inner ); $q = ''; foreach ( $lines as $line ) { $q .= '> ' . $line . "\n"; } return $q . "\n"; case 'pre': $code = $node->textContent; return "\n```\n" . rtrim( $code ) . "\n```\n\n"; case 'img': return $this->render_img( $node ); case 'figure': // Usually img + figcaption. return $this->render_children( $node ); case 'figcaption': $cap = trim( $this->render_inline( $node ) ); return '' === $cap ? '' : '*' . $cap . "*\n\n"; case 'table': return $this->render_table( $node ) . "\n"; // Inline tags that landed at block level. case 'a': case 'strong': case 'b': case 'em': case 'i': case 'code': case 'span': case 'small': case 'sup': case 'sub': case 'mark': case 'u': $txt = trim( $this->render_inline( $node ) ); return '' === $txt ? '' : $txt . "\n\n"; // Presentational wrappers (Elementor/Gutenberg div wrapping): unwrap them. default: return $this->render_children( $node ); } } /** * Render a list (with nesting support). * * @param DOMNode $node ul/ol. * @param string $type ul|ol. * @param int $depth Nesting depth. * @return string */ private function render_list( DOMNode $node, $type, $depth ) { $out = ''; $index = 1; $indent = str_repeat( ' ', $depth ); foreach ( $node->childNodes as $li ) { if ( XML_ELEMENT_NODE !== $li->nodeType || 'li' !== strtolower( $li->nodeName ) ) { continue; } $marker = ( 'ol' === $type ) ? ( $index . '. ' ) : '- '; // Separate the li's inline content from nested lists. $inline_txt = ''; $nested = ''; foreach ( $li->childNodes as $c ) { if ( XML_ELEMENT_NODE === $c->nodeType && in_array( strtolower( $c->nodeName ), array( 'ul', 'ol' ), true ) ) { $nested .= $this->render_list( $c, strtolower( $c->nodeName ), $depth + 1 ); } else { $inline_txt .= $this->render_inline_node( $c ); } } $out .= $indent . $marker . trim( $inline_txt ) . "\n"; if ( '' !== $nested ) { $out .= $nested; } $index++; } return $out; } /** * Render a node's inline content. * * @param DOMNode $node Node. * @return string */ private function render_inline( DOMNode $node ) { $out = ''; foreach ( $node->childNodes as $child ) { $out .= $this->render_inline_node( $child ); } return $out; } /** * Render a single node (text or element) as inline Markdown. * * Unlike render_inline() — which walks a parent's children — this handles a * node passed directly, so bare text nodes and inline elements (e.g. the * text inside a list item) are rendered correctly. * * @param DOMNode $node Node. * @return string */ private function render_inline_node( DOMNode $node ) { if ( XML_TEXT_NODE === $node->nodeType ) { return $this->clean_text( $node->textContent ); } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $tag = strtolower( $node->nodeName ); switch ( $tag ) { case 'a': $href = $node->getAttribute( 'href' ); $text = trim( $this->render_inline( $node ) ); if ( '' === $text ) { $text = $href; } if ( '' === $href || '#' === $href || 0 === strpos( $href, 'javascript:' ) ) { return $text; } return '[' . $text . '](' . esc_url_raw( $href ) . ')'; case 'strong': case 'b': return '**' . trim( $this->render_inline( $node ) ) . '**'; case 'em': case 'i': return '*' . trim( $this->render_inline( $node ) ) . '*'; case 'code': return '`' . $node->textContent . '`'; case 'br': return " \n"; case 'img': return trim( $this->render_img( $node ) ); default: return $this->render_inline( $node ); } } /** * Render an image. * * @param DOMNode $node img. * @return string */ private function render_img( DOMNode $node ) { $src = $node->getAttribute( 'src' ); if ( '' === $src ) { // Lazy-load attributes. $src = $node->getAttribute( 'data-src' ); } if ( '' === $src ) { return ''; } $alt = $node->getAttribute( 'alt' ); return '![' . $this->clean_text( $alt ) . '](' . esc_url_raw( $src ) . ")\n\n"; } /** * Render a table (basic, GFM pipe table). * * @param DOMNode $node table. * @return string */ private function render_table( DOMNode $node ) { $xpath = new DOMXPath( $node->ownerDocument ); $rows = $xpath->query( './/tr', $node ); if ( 0 === $rows->length ) { return ''; } $out = "\n"; $first = true; $col_count = 0; foreach ( $rows as $row ) { $cells = array(); foreach ( $row->childNodes as $cell ) { if ( XML_ELEMENT_NODE !== $cell->nodeType ) { continue; } $name = strtolower( $cell->nodeName ); if ( 'td' !== $name && 'th' !== $name ) { continue; } $cells[] = str_replace( '|', '\\|', trim( $this->render_inline( $cell ) ) ); } if ( empty( $cells ) ) { continue; } $out .= '| ' . implode( ' | ', $cells ) . " |\n"; if ( $first ) { $col_count = count( $cells ); $out .= '| ' . implode( ' | ', array_fill( 0, $col_count, '---' ) ) . " |\n"; $first = false; } } return $out; } /** * Normalize a text node: collapse whitespace, remove nbsp. * * @param string $text Text. * @return string */ private function clean_text( $text ) { $text = str_replace( "\xC2\xA0", ' ', (string) $text ); // nbsp → space. $text = preg_replace( '/\s+/u', ' ', $text ); return $text; } /** * Final Markdown normalization: remove extra blank lines. * * @param string $md Markdown. * @return string */ private function normalize( $md ) { $md = preg_replace( "/[ \t]+\n/", "\n", $md ); // Trailing spaces (except double for br — not touched below). $md = preg_replace( "/\n{3,}/", "\n\n", $md ); // Max 2 newlines. return trim( $md ) . "\n"; } }