' . $html . ''; $flags = 0; if ( defined( 'LIBXML_HTML_NOIMPLIED' ) ) { $flags |= LIBXML_HTML_NOIMPLIED; } if ( defined( 'LIBXML_HTML_NODEFDTD' ) ) { $flags |= LIBXML_HTML_NODEFDTD; } $loaded = $dom->loadHTML( '' . $wrapper, $flags ); if ( ! $loaded ) { libxml_clear_errors(); libxml_use_internal_errors( $previous ); return trim( wp_strip_all_tags( $html, true ) ); } $xpath = new DOMXPath( $dom ); $remove_queries = array( '//script', '//style', '//noscript', '//form', '//button', '//svg', '//canvas', '//template', '//nav', '//*[@aria-hidden="true"]', '//*[@hidden]', '//*[contains(concat(" ", normalize-space(@class), " "), " screen-reader-text ")]', '//*[contains(concat(" ", normalize-space(@class), " "), " agentic-markdown-exclude ")]', '//*[@data-agentic-markdown="exclude"]', ); $custom_class_conditions = array(); foreach ( (array) $exclude_classes as $class ) { $class = preg_replace( '/[^A-Za-z0-9_-]/', '', ltrim( trim( (string) $class ), '.' ) ); if ( '' !== $class ) { $custom_class_conditions[] = 'contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")'; } } if ( $custom_class_conditions ) { $remove_queries[] = '//*[' . implode( ' or ', array_values( array_unique( $custom_class_conditions ) ) ) . ']'; } foreach ( $remove_queries as $query ) { $nodes = $xpath->query( $query ); if ( ! $nodes ) { continue; } $to_remove = array(); foreach ( $nodes as $node ) { $to_remove[] = $node; } foreach ( $to_remove as $node ) { if ( $node->parentNode ) { $node->parentNode->removeChild( $node ); } } } $root = $dom->getElementById( 'agentic-markdown-bridge-root' ); if ( ! $root ) { libxml_clear_errors(); libxml_use_internal_errors( $previous ); return ''; } $markdown = self::render_children( $root, $base_url ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); $markdown = html_entity_decode( $markdown, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $markdown = str_replace( "\xC2\xA0", ' ', $markdown ); $markdown = (string) preg_replace( "/[ ]+\n/", "\n", $markdown ); $markdown = (string) preg_replace( "/\n[ ]+/", "\n", $markdown ); $markdown = (string) preg_replace( "/\n{3,}/", "\n\n", $markdown ); $markdown = (string) preg_replace( '/ {2,}/u', ' ', $markdown ); return trim( $markdown ); } /** * Convert basic document structure when the PHP DOM extension is unavailable. * * This intentionally remains conservative, but preserves headings and common * block boundaries so title detection and pre-H1 controls still behave * predictably on hosts without DOMDocument. * * @param string $html Rendered HTML. * @return string */ private static function convert_without_dom( $html ) { $html = (string) $html; // Remove content that should never appear in machine-readable output. $html = (string) preg_replace( '#<(script|style|noscript|form|button|svg|canvas|template|nav)\\b[^>]*>.*?#is', '', $html ); // Preserve heading levels before stripping the remaining markup. $html = (string) preg_replace_callback( '#]*>(.*?)#is', static function ( $matches ) { $level = absint( $matches[1] ); $text = trim( wp_strip_all_tags( $matches[2], true ) ); return $text ? "\n\n" . str_repeat( '#', $level ) . ' ' . $text . "\n\n" : ''; }, $html ); // Preserve readable block boundaries and simple list structure. $html = (string) preg_replace( '##i', "\n", $html ); $html = (string) preg_replace( '#]*>#i', "\n- ", $html ); $html = (string) preg_replace( '##i', "\n\n", $html ); $text = wp_strip_all_tags( $html, true ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = str_replace( "\xC2\xA0", ' ', $text ); $text = (string) preg_replace( "/[ \t]+\n/", "\n", $text ); $text = (string) preg_replace( "/\n[ \t]+/", "\n", $text ); $text = (string) preg_replace( "/\n{3,}/", "\n\n", $text ); return trim( $text ); } /** * Render all children. * * @param DOMNode $node Node. * @param string $base_url Base URL. * @return string */ private static function render_children( DOMNode $node, $base_url = '' ) { $output = ''; $previous = null; foreach ( $node->childNodes as $child ) { $piece = self::render_node( $child, $base_url ); if ( '' === $piece ) { continue; } $output = self::append_rendered_piece( $output, $piece, $previous, $child ); $previous = $child; } return $output; } /** * Render inline children with spacing between adjacent inline elements when * the source markup contains no literal whitespace. This keeps constructs * such as Experience10+ Years readable. * * @param DOMNode $node Node. * @param string $base_url Base URL. * @return string */ private static function render_inline_children( DOMNode $node, $base_url = '' ) { return self::render_children( $node, $base_url ); } /** * Append a rendered child while preserving readable separation between * adjacent inline elements. HTML does not insert visual whitespace between * inline siblings, but page builders commonly use adjacent wrappers for * label/value pairs. Without a separator those become invalid/ambiguous * Markdown such as *Global Reach***8+ Countries**. * * Text-node boundaries are intentionally left untouched so legitimate * constructs such as H2O or words keep their * author-defined spacing. * * @param string $output Existing rendered output. * @param string $piece Newly rendered piece. * @param DOMNode|null $previous_node Previous non-empty source node. * @param DOMNode $current_node Current source node. * @return string */ private static function append_rendered_piece( $output, $piece, $previous_node, DOMNode $current_node ) { if ( '' === $output || null === $previous_node ) { return $output . $piece; } if ( self::needs_inline_separator( $output, $piece, $previous_node, $current_node ) ) { $output .= ' '; } return $output . $piece; } /** * Determine whether adjacent source elements need one Markdown space. * * @param string $output Existing output. * @param string $piece Current rendered piece. * @param DOMNode $previous_node Previous source node. * @param DOMNode $current_node Current source node. * @return bool */ private static function needs_inline_separator( $output, $piece, DOMNode $previous_node, DOMNode $current_node ) { if ( XML_ELEMENT_NODE !== $previous_node->nodeType || XML_ELEMENT_NODE !== $current_node->nodeType ) { return false; } if ( ! self::is_inline_element( $previous_node ) || ! self::is_inline_element( $current_node ) ) { return false; } // Superscript/subscript are commonly attached directly to preceding text. $current_tag = strtolower( $current_node->nodeName ); if ( in_array( $current_tag, array( 'sub', 'sup' ), true ) ) { return false; } if ( preg_match( '/\s$/u', $output ) || preg_match( '/^\s/u', $piece ) ) { return false; } $current_text = trim( (string) $current_node->textContent ); if ( '' !== $current_text && preg_match( '/^[\p{P}]/u', $current_text ) ) { return false; } $previous_text = trim( (string) $previous_node->textContent ); if ( '' !== $previous_text && preg_match( '/[\(\[\{\/\-]$/u', $previous_text ) ) { return false; } return true; } /** * Whether a DOM element behaves as inline content for spacing purposes. * * @param DOMNode $node Node. * @return bool */ private static function is_inline_element( DOMNode $node ) { if ( XML_ELEMENT_NODE !== $node->nodeType ) { return false; } return in_array( strtolower( $node->nodeName ), array( 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'code', 'del', 'em', 'i', 'img', 'kbd', 'mark', 'q', 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', ), true ); } /** * Render one DOM node. * * @param DOMNode $node Node. * @param string $base_url Base URL. * @return string */ private static function render_node( DOMNode $node, $base_url = '' ) { if ( XML_TEXT_NODE === $node->nodeType ) { $text = str_replace( "\xC2\xA0", ' ', (string) $node->nodeValue ); return (string) preg_replace( '/[ \r\n ]+/u', ' ', $text ); } if ( XML_COMMENT_NODE === $node->nodeType || XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $tag = strtolower( $node->nodeName ); if ( preg_match( '/^h([1-6])$/', $tag, $matches ) ) { $level = (int) $matches[1]; $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? "\n\n" . str_repeat( '#', $level ) . ' ' . $text . "\n\n" : ''; } switch ( $tag ) { case 'p': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? "\n\n" . $text . "\n\n" : ''; case 'br': return " \n"; case 'strong': case 'b': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? '**' . $text . '**' : ''; case 'em': case 'i': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? '*' . $text . '*' : ''; case 'del': case 's': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? '~~' . $text . '~~' : ''; case 'mark': case 'small': case 'cite': case 'time': case 'abbr': return self::render_inline_children( $node, $base_url ); case 'sup': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? '^' . $text . '^' : ''; case 'sub': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? '~' . $text . '~' : ''; case 'kbd': $text = trim( $node->textContent ); return $text ? '`' . str_replace( '`', '\\`', $text ) . '`' : ''; case 'a': $text = trim( self::render_inline_children( $node, $base_url ) ); $href = $node instanceof DOMElement ? trim( $node->getAttribute( 'href' ) ) : ''; if ( ! $href ) { return $text; } $href = self::absolute_url( $href, $base_url ); if ( ! $text ) { $text = $href; } $title = $node instanceof DOMElement ? trim( $node->getAttribute( 'title' ) ) : ''; $dest = self::escape_link_destination( $href ); if ( $title ) { $dest .= ' "' . str_replace( '"', '\\"', $title ) . '"'; } return '[' . self::escape_link_text( $text ) . '](' . $dest . ')'; case 'img': if ( ! $node instanceof DOMElement ) { return ''; } $src = trim( $node->getAttribute( 'src' ) ); if ( ! $src ) { $src = trim( $node->getAttribute( 'data-src' ) ); } if ( ! $src ) { return ''; } $alt = trim( $node->getAttribute( 'alt' ) ); $title = trim( $node->getAttribute( 'title' ) ); $src = self::absolute_url( $src, $base_url ); $dest = self::escape_link_destination( $src ); if ( $title ) { $dest .= ' "' . str_replace( '"', '\\"', $title ) . '"'; } return '![' . self::escape_link_text( $alt ) . '](' . $dest . ')'; case 'ul': return "\n\n" . self::render_list( $node, false, 0, $base_url ) . "\n\n"; case 'ol': return "\n\n" . self::render_list( $node, true, 0, $base_url ) . "\n\n"; case 'blockquote': $text = trim( self::render_children( $node, $base_url ) ); if ( ! $text ) { return ''; } $text = (string) preg_replace( '/^/m', '> ', $text ); return "\n\n" . $text . "\n\n"; case 'pre': $text = rtrim( (string) $node->textContent, "\r\n" ); $lang = ''; if ( $node instanceof DOMElement ) { $class = $node->getAttribute( 'class' ); if ( preg_match( '/(?:language|lang)-([a-z0-9_+-]+)/i', $class, $m ) ) { $lang = $m[1]; } } $fence = false !== strpos( $text, '```' ) ? '````' : '```'; return "\n\n" . $fence . $lang . "\n" . $text . "\n" . $fence . "\n\n"; case 'code': $text = trim( (string) $node->textContent ); if ( ! $text ) { return ''; } $fence = false !== strpos( $text, '`' ) ? '``' : '`'; return $fence . $text . $fence; case 'hr': return "\n\n---\n\n"; case 'table': return "\n\n" . self::render_table( $node, $base_url ) . "\n\n"; case 'details': return self::render_details( $node, $base_url ); case 'figure': return self::render_figure( $node, $base_url ); case 'figcaption': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? "\n\n_" . $text . "_\n\n" : ''; case 'dl': return self::render_definition_list( $node, $base_url ); case 'dt': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? "\n\n**" . $text . "**\n" : ''; case 'dd': $text = trim( self::render_children( $node, $base_url ) ); return $text ? $text . "\n\n" : ''; case 'iframe': case 'video': case 'audio': if ( $node instanceof DOMElement ) { $src = trim( $node->getAttribute( 'src' ) ); if ( ! $src ) { $source = $node->getElementsByTagName( 'source' )->item( 0 ); if ( $source instanceof DOMElement ) { $src = trim( $source->getAttribute( 'src' ) ); } } if ( $src ) { $src = self::absolute_url( $src, $base_url ); return "\n\n[Embedded media](" . self::escape_link_destination( $src ) . ")\n\n"; } } return ''; case 'address': $text = trim( self::render_inline_children( $node, $base_url ) ); return $text ? "\n\n" . $text . "\n\n" : ''; case 'div': case 'section': case 'article': case 'main': case 'header': case 'footer': case 'span': default: return self::render_children( $node, $base_url ); } } /** * Render lists. */ private static function render_list( DOMNode $list, $ordered, $depth, $base_url ) { $lines = array(); $index = 1; foreach ( $list->childNodes as $child ) { if ( XML_ELEMENT_NODE !== $child->nodeType || 'li' !== strtolower( $child->nodeName ) ) { continue; } $inline = ''; $nested = array(); $previous_inline = null; foreach ( $child->childNodes as $li_child ) { if ( XML_ELEMENT_NODE === $li_child->nodeType && in_array( strtolower( $li_child->nodeName ), array( 'ul', 'ol' ), true ) ) { $nested[] = array( 'node' => $li_child, 'ordered' => 'ol' === strtolower( $li_child->nodeName ), ); continue; } $piece = self::render_node( $li_child, $base_url ); if ( '' === $piece ) { continue; } $inline = self::append_rendered_piece( $inline, $piece, $previous_inline, $li_child ); $previous_inline = $li_child; } $inline = trim( (string) preg_replace( '/\s+/u', ' ', $inline ) ); $marker = $ordered ? $index . '.' : '-'; $lines[] = str_repeat( ' ', $depth ) . $marker . ( $inline ? ' ' . $inline : '' ); foreach ( $nested as $nested_list ) { $nested_rendered = self::render_list( $nested_list['node'], $nested_list['ordered'], $depth + 1, $base_url ); if ( $nested_rendered ) { $lines[] = $nested_rendered; } } ++$index; } return implode( "\n", $lines ); } /** * Render a table into GitHub-style Markdown. */ private static function render_table( DOMNode $table, $base_url ) { $rows = array(); foreach ( $table->childNodes as $section_or_row ) { if ( XML_ELEMENT_NODE !== $section_or_row->nodeType ) { continue; } $name = strtolower( $section_or_row->nodeName ); if ( 'tr' === $name ) { $rows[] = self::extract_table_row( $section_or_row, $base_url ); } elseif ( in_array( $name, array( 'thead', 'tbody', 'tfoot' ), true ) ) { foreach ( $section_or_row->childNodes as $row ) { if ( XML_ELEMENT_NODE === $row->nodeType && 'tr' === strtolower( $row->nodeName ) ) { $rows[] = self::extract_table_row( $row, $base_url ); } } } } $rows = array_values( array_filter( $rows ) ); if ( ! $rows ) { return ''; } $columns = 0; foreach ( $rows as $row ) { $columns = max( $columns, count( $row ) ); } if ( 0 === $columns ) { return ''; } foreach ( $rows as &$row ) { $row = array_pad( $row, $columns, '' ); } unset( $row ); $lines = array(); $lines[] = '| ' . implode( ' | ', $rows[0] ) . ' |'; $lines[] = '| ' . implode( ' | ', array_fill( 0, $columns, '---' ) ) . ' |'; foreach ( array_slice( $rows, 1 ) as $row ) { $lines[] = '| ' . implode( ' | ', $row ) . ' |'; } return implode( "\n", $lines ); } /** Extract table row cells. */ private static function extract_table_row( DOMNode $row, $base_url ) { $cells = array(); foreach ( $row->childNodes as $cell ) { if ( XML_ELEMENT_NODE !== $cell->nodeType || ! in_array( strtolower( $cell->nodeName ), array( 'td', 'th' ), true ) ) { continue; } $text = trim( (string) preg_replace( '/\s+/u', ' ', self::render_inline_children( $cell, $base_url ) ) ); $text = str_replace( '|', '\\|', $text ); $cells[] = $text; } return $cells; } /** Render details/summary blocks. */ private static function render_details( DOMNode $details, $base_url ) { $summary = ''; $body = ''; foreach ( $details->childNodes as $child ) { if ( XML_ELEMENT_NODE === $child->nodeType && 'summary' === strtolower( $child->nodeName ) ) { $summary = trim( self::render_inline_children( $child, $base_url ) ); } else { $body .= self::render_node( $child, $base_url ); } } $body = trim( $body ); if ( ! $summary && ! $body ) { return ''; } $output = ''; if ( $summary ) { $output .= "\n\n### " . $summary . "\n\n"; } if ( $body ) { $output .= $body . "\n\n"; } return $output; } /** Render figure with caption. */ private static function render_figure( DOMNode $figure, $base_url ) { $content = ''; $caption = ''; foreach ( $figure->childNodes as $child ) { if ( XML_ELEMENT_NODE === $child->nodeType && 'figcaption' === strtolower( $child->nodeName ) ) { $caption = trim( self::render_inline_children( $child, $base_url ) ); } else { $content .= self::render_node( $child, $base_url ); } } $content = trim( $content ); $output = $content ? "\n\n" . $content . "\n" : ''; if ( $caption ) { $output .= '_' . $caption . '_'; } return $output ? $output . "\n\n" : ''; } /** Render definition list. */ private static function render_definition_list( DOMNode $dl, $base_url ) { $output = ''; foreach ( $dl->childNodes as $child ) { if ( XML_ELEMENT_NODE !== $child->nodeType ) { continue; } $name = strtolower( $child->nodeName ); if ( 'dt' === $name ) { $text = trim( self::render_inline_children( $child, $base_url ) ); if ( $text ) { $output .= "\n\n**" . $text . "**\n"; } } elseif ( 'dd' === $name ) { $text = trim( self::render_children( $child, $base_url ) ); if ( $text ) { $output .= $text . "\n"; } } } return $output ? $output . "\n" : ''; } /** * Make a relative URL absolute while preserving already-absolute URLs. * * Some rendered WordPress content can contain a malformed destination where * an absolute URL has accidentally been prefixed with the current permalink, * for example: https://example.com/page/https://example.com/services/. * Repair only that exact base-prefix pattern; do not rewrite legitimate URLs * that merely contain another URL in a query string or path. * * @param string $url Link or media URL. * @param string $base_url Current canonical page URL. * @return string */ private static function absolute_url( $url, $base_url ) { $url = trim( html_entity_decode( (string) $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); if ( '' === $url ) { return ''; } $base_url = $base_url ? trim( (string) $base_url ) : home_url( '/' ); $url = self::repair_prefixed_absolute_url( $url, $base_url ); // Preserve absolute schemes, anchors, mailto:, tel:, data:, etc. if ( preg_match( '~^(?:[a-z][a-z0-9+.-]*:|//|#)~i', $url ) ) { if ( 0 === strpos( $url, '//' ) ) { $scheme = wp_parse_url( $base_url, PHP_URL_SCHEME ); if ( ! $scheme ) { $scheme = wp_parse_url( home_url( '/' ), PHP_URL_SCHEME ); } return ( $scheme ? $scheme : 'https' ) . ':' . $url; } return $url; } // Root-relative URLs should use the current page origin when available. if ( 0 === strpos( $url, '/' ) ) { $origin = self::url_origin( $base_url ); if ( '' === $origin ) { $origin = self::url_origin( home_url( '/' ) ); } return $origin . $url; } return trailingslashit( $base_url ) . ltrim( $url, '/' ); } /** * Repair an accidentally duplicated absolute URL after the current permalink. * * @param string $url Candidate URL. * @param string $base_url Current canonical page URL. * @return string */ private static function repair_prefixed_absolute_url( $url, $base_url ) { if ( ! preg_match( '#^https?://#i', $url ) || '' === $base_url ) { return $url; } $prefix = trailingslashit( $base_url ); if ( 0 !== stripos( $url, $prefix ) ) { return $url; } $remainder = substr( $url, strlen( $prefix ) ); if ( preg_match( '#^https?://#i', $remainder ) ) { return $remainder; } return $url; } /** * Return scheme + host + optional port for a URL. * * @param string $url URL. * @return string */ private static function url_origin( $url ) { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return ''; } $origin = ( ! empty( $parts['scheme'] ) ? $parts['scheme'] : 'https' ) . '://' . $parts['host']; if ( ! empty( $parts['port'] ) ) { $origin .= ':' . (int) $parts['port']; } return $origin; } /** Escape Markdown link text. */ private static function escape_link_text( $text ) { return str_replace( array( '\\', '[', ']' ), array( '\\\\', '\\[', '\\]' ), (string) $text ); } /** Escape Markdown destination. */ private static function escape_link_destination( $url ) { return str_replace( array( ' ', '(', ')' ), array( '%20', '\\(', '\\)' ), (string) $url ); } }