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 ' . ")\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";
}
}