load_fragment_dom( $html );
if ( ! $dom instanceof \DOMDocument ) {
return $html;
}
$root = $dom->getElementById( 'aisignal-markdown-converter-root' );
if ( ! $root instanceof \DOMElement ) {
return $html;
}
$this->remove_comment_nodes( $root );
$this->remove_attribute_echo_text_nodes( $root );
$this->normalize_details_elements( $dom, $root );
$this->remove_tag_names( $root, [ 'script', 'style', 'noscript', 'nav', 'aside', 'footer', 'form', 'button', 'input', 'select', 'textarea', 'canvas', 'svg' ] );
$this->remove_noise_elements( $root, $post );
$this->strip_disallowed_attributes( $root );
$this->remove_empty_elements( $root );
return $this->get_inner_html( $root );
}
/**
* Load a fragment into a DOM document.
*
* @param string $html HTML fragment.
*
* @return \DOMDocument|null
*/
protected function load_fragment_dom( string $html ) {
if ( ! class_exists( '\DOMDocument' ) ) {
return null;
}
$dom = new \DOMDocument( '1.0', 'UTF-8' );
$libxml_previous = libxml_use_internal_errors( true );
$wrapped = '
' . $html . '
';
$loaded = $dom->loadHTML( '' . $wrapped );
libxml_clear_errors();
libxml_use_internal_errors( $libxml_previous );
return $loaded ? $dom : null;
}
/**
* Convert details and summary elements into markdown-friendly structure.
*
* @param \DOMDocument $dom DOM document.
* @param \DOMElement $root Root element.
*
* @return void
*/
protected function normalize_details_elements( \DOMDocument $dom, \DOMElement $root ) {
$xpath = new \DOMXPath( $dom );
$summary_nodes = [];
$details_nodes = [];
$summary_list = $xpath->query( './/summary', $root );
if ( $summary_list instanceof \DOMNodeList ) {
foreach ( $summary_list as $summary ) {
if ( $summary instanceof \DOMElement ) {
$summary_nodes[] = $summary;
}
}
}
foreach ( $summary_nodes as $summary ) {
$heading = $dom->createElement( 'h3' );
$this->move_children( $summary, $heading );
$this->replace_node( $summary, $heading );
}
$details_list = $xpath->query( './/details', $root );
if ( $details_list instanceof \DOMNodeList ) {
foreach ( $details_list as $details ) {
if ( $details instanceof \DOMElement ) {
$details_nodes[] = $details;
}
}
}
foreach ( $details_nodes as $details ) {
$section = $dom->createElement( 'section' );
$this->move_children( $details, $section );
$this->replace_node( $details, $section );
}
}
/**
* Remove nodes by tag name.
*
* @param \DOMElement $root Root element.
* @param array $tags Tag names.
*
* @return void
*/
protected function remove_tag_names( \DOMElement $root, array $tags ) {
foreach ( $tags as $tag ) {
$nodes = [];
$list = $root->getElementsByTagName( $tag );
foreach ( $list as $node ) {
$nodes[] = $node;
}
foreach ( $nodes as $node ) {
$this->remove_node( $node );
}
}
}
/**
* Remove HTML comment nodes from a fragment tree.
*
* @param \DOMElement $root Root element.
*
* @return void
*/
protected function remove_comment_nodes( \DOMElement $root ) {
$nodes = [];
$owner_document = $this->get_owner_document( $root );
if ( ! $owner_document instanceof \DOMDocument ) {
return;
}
$xpath = new \DOMXPath( $owner_document );
$list = $xpath->query( './/comment()', $root );
if ( ! $list instanceof \DOMNodeList ) {
return;
}
foreach ( $list as $node ) {
$nodes[] = $node;
}
foreach ( $nodes as $node ) {
$this->remove_node( $node );
}
}
/**
* Remove text nodes that merely repeat nearby class or ID tokens.
*
* @param \DOMElement $root Root element.
*
* @return void
*/
protected function remove_attribute_echo_text_nodes( \DOMElement $root ) {
$nodes = [];
$owner_document = $this->get_owner_document( $root );
if ( ! $owner_document instanceof \DOMDocument ) {
return;
}
$xpath = new \DOMXPath( $owner_document );
$list = $xpath->query( './/text()', $root );
if ( ! $list instanceof \DOMNodeList ) {
return;
}
foreach ( $list as $node ) {
$nodes[] = $node;
}
foreach ( $nodes as $node ) {
if ( ! $node instanceof \DOMText ) {
continue;
}
$text = $this->get_text_node_content( $node );
if ( '' === $text || ! $this->is_machine_token( $text ) ) {
continue;
}
$parent = $this->get_parent_element( $node );
if ( ! $parent instanceof \DOMElement ) {
continue;
}
if ( $this->matches_ancestor_attribute_token( $text, $parent ) ) {
$parent->removeChild( $node );
}
}
}
/**
* Check whether a token matches a nearby class or ID value.
*
* @param string $text Candidate token.
* @param \DOMElement $element Starting element.
*
* @return bool
*/
protected function matches_ancestor_attribute_token( string $text, \DOMElement $element ) {
$needle = strtolower( trim( $text ) );
$current = $element;
$max_hops = 2;
for ( $hop = 0; $hop <= $max_hops && $current instanceof \DOMElement; $hop++ ) {
$tokens = preg_split(
'/\s+/',
trim( implode( ' ', array_filter( [ $current->getAttribute( 'class' ), $current->getAttribute( 'id' ) ] ) ) )
);
foreach ( $tokens as $token ) {
if ( strtolower( trim( $token ) ) === $needle ) {
return true;
}
}
$current = $this->get_parent_element( $current );
}
return false;
}
/**
* Determine whether a string looks like a machine token rather than prose.
*
* @param string $value Candidate value.
*
* @return bool
*/
protected function is_machine_token( string $value ) {
if ( strlen( $value ) < 5 || strlen( $value ) > 48 ) {
return false;
}
if ( preg_match( '/\s/', $value ) || preg_match( '/[A-Z]/', $value ) ) {
return false;
}
if ( false === strpos( $value, '-' ) && false === strpos( $value, '_' ) ) {
return false;
}
return preg_match( '/^[a-z0-9_-]+$/', $value ) === 1;
}
/**
* Remove generic boilerplate blocks.
*
* @param \DOMElement $root Root element.
* @param \WP_Post|null $post Optional post object.
*
* @return void
*/
protected function remove_noise_elements( \DOMElement $root, ?\WP_Post $post = null ) {
$tokens = $this->get_excluded_container_tokens( $post );
$phrases = apply_filters(
'aisignal_markdown_converter_remove_node_phrases',
[
'author-box',
],
$post
);
$nodes = [];
$all = $root->getElementsByTagName( '*' );
foreach ( $all as $node ) {
if ( ! $node instanceof \DOMElement ) {
continue;
}
if ( $this->element_matches_noise_patterns( $node, $tokens, $phrases ) ) {
$nodes[] = $node;
}
}
foreach ( $nodes as $node ) {
$this->remove_node( $node );
}
}
/**
* Check whether an element matches noise patterns.
*
* @param \DOMElement $node Element node.
* @param array $tokens Token patterns.
* @param array $phrases Phrase patterns.
*
* @return bool
*/
protected function element_matches_noise_patterns( \DOMElement $node, array $tokens, array $phrases ) {
$attrs = $this->get_attribute_string( $node );
$matches_token = $this->element_matches_excluded_tokens( $node, $tokens );
if ( $matches_token && $this->should_remove_excluded_element( $node ) ) {
return true;
}
foreach ( $phrases as $phrase ) {
if ( false !== strpos( $attrs, strtolower( (string) $phrase ) ) ) {
return true;
}
}
return false;
}
/**
* Get the shared excluded container tokens.
*
* @param \WP_Post|null $post Optional post object.
*
* @return array
*/
protected function get_excluded_container_tokens( ?\WP_Post $post = null ) {
return apply_filters(
'aisignal_markdown_converter_excluded_container_tokens',
[
'nav',
'menu',
'sidebar',
'header',
'footer',
'breadcrumb',
'breadcrumbs',
'pagination',
'pager',
'comment',
'comments',
'cookie',
'cookies',
'modal',
'popup',
'dialog',
],
$post
);
}
/**
* Check whether an element matches any excluded token.
*
* @param \DOMElement $node Element node.
* @param array $tokens Excluded tokens.
*
* @return bool
*/
protected function element_matches_excluded_tokens( \DOMElement $node, array $tokens ) {
$element_tokens = $this->get_element_tokens( $node );
foreach ( $tokens as $token ) {
if ( in_array( strtolower( (string) $token ), $element_tokens, true ) ) {
return true;
}
}
return false;
}
/**
* Determine whether an excluded element should be removed.
*
* @param \DOMElement $node Element node.
*
* @return bool
*/
protected function should_remove_excluded_element( \DOMElement $node ) {
$always_remove_tokens = [
'nav',
'menu',
'sidebar',
'breadcrumb',
'breadcrumbs',
'pagination',
'pager',
'comment',
'comments',
'cookie',
'cookies',
'modal',
'popup',
'dialog',
];
$element_tokens = $this->get_element_tokens( $node );
foreach ( $always_remove_tokens as $token ) {
if ( in_array( $token, $element_tokens, true ) ) {
return true;
}
}
return $this->looks_like_chrome_container( $node );
}
/**
* Detect whether an element looks like page chrome rather than content.
*
* @param \DOMElement $node Element node.
*
* @return bool
*/
protected function looks_like_chrome_container( \DOMElement $node ) {
$tag_name = strtolower( $this->get_tag_name( $node ) );
if ( in_array( $tag_name, [ 'nav', 'aside', 'footer' ], true ) ) {
return true;
}
$role = strtolower( (string) $node->getAttribute( 'role' ) );
if ( in_array( $role, [ 'navigation', 'complementary', 'search' ], true ) ) {
return true;
}
$text_length = strlen( $this->get_node_text_content( $node ) );
if ( 0 === $text_length ) {
return true;
}
$link_text_length = 0;
foreach ( $node->getElementsByTagName( 'a' ) as $link ) {
$link_text_length += strlen( $this->get_node_text_content( $link ) );
}
$link_density = $link_text_length / max( 1, $text_length );
if ( $link_density >= 0.45 ) {
return true;
}
$heading_count = 0;
foreach ( [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ] as $heading_tag ) {
$heading_count += $node->getElementsByTagName( $heading_tag )->length;
}
$paragraph_count = $node->getElementsByTagName( 'p' )->length;
return 0 === $heading_count && $paragraph_count <= 1 && $text_length < 120;
}
/**
* Get a lowercased attribute string for a node.
*
* @param \DOMElement $node Element node.
*
* @return string
*/
protected function get_attribute_string( \DOMElement $node ) {
return strtolower(
implode(
' ',
array_filter(
[
$node->getAttribute( 'id' ),
$node->getAttribute( 'class' ),
$node->getAttribute( 'role' ),
$node->getAttribute( 'aria-label' ),
]
)
)
);
}
/**
* Get normalized attribute tokens for matching.
*
* @param \DOMElement $node Element node.
*
* @return array
*/
protected function get_attribute_tokens( \DOMElement $node ) {
$attrs = $this->get_attribute_string( $node );
$tokens = preg_split( '/[^a-z0-9]+/', $attrs );
if ( ! is_array( $tokens ) ) {
return [];
}
return array_values(
array_unique(
array_filter(
$tokens,
static function ( $token ) {
return is_string( $token ) && strlen( $token ) >= 3;
}
)
)
);
}
/**
* Get element tokens including tag name.
*
* @param \DOMElement $node Element node.
*
* @return array
*/
protected function get_element_tokens( \DOMElement $node ) {
$tokens = $this->get_attribute_tokens( $node );
$role = strtolower( trim( $node->getAttribute( 'role' ) ) );
$tag_name = strtolower( $this->get_tag_name( $node ) );
if ( 'navigation' === $role ) {
$tokens[] = 'nav';
} elseif ( 'complementary' === $role ) {
$tokens[] = 'sidebar';
} elseif ( 'banner' === $role ) {
$tokens[] = 'header';
} elseif ( 'contentinfo' === $role ) {
$tokens[] = 'footer';
}
if ( '' !== $tag_name ) {
$tokens[] = $tag_name;
}
return array_values( array_unique( $tokens ) );
}
/**
* Strip disallowed attributes from elements.
*
* @param \DOMElement $root Root element.
*
* @return void
*/
protected function strip_disallowed_attributes( \DOMElement $root ) {
$all = $root->getElementsByTagName( '*' );
foreach ( $all as $node ) {
if ( ! $node instanceof \DOMElement || ! $node->hasAttributes() ) {
continue;
}
$remove = [];
foreach ( $node->attributes as $attribute ) {
if ( ! in_array( $attribute->name, $this->allowed_attributes, true ) ) {
$remove[] = $attribute->name;
}
}
foreach ( $remove as $attribute_name ) {
$node->removeAttribute( $attribute_name );
}
}
}
/**
* Remove empty presentational elements.
*
* @param \DOMElement $root Root element.
*
* @return void
*/
protected function remove_empty_elements( \DOMElement $root ) {
$keep_empty_tags = [ 'img', 'br', 'hr', 'td', 'th' ];
$nodes = [];
$all = $root->getElementsByTagName( '*' );
foreach ( $all as $node ) {
if ( $node instanceof \DOMElement ) {
$nodes[] = $node;
}
}
$nodes = array_reverse( $nodes );
foreach ( $nodes as $node ) {
if ( in_array( strtolower( $this->get_tag_name( $node ) ), $keep_empty_tags, true ) ) {
continue;
}
$text = $this->get_node_text_content( $node );
if ( '' === $text && ! $node->getElementsByTagName( 'img' )->length && $this->get_parent_element( $node ) ) {
$this->remove_node( $node );
}
}
}
/**
* Get inner HTML for a root element.
*
* @param \DOMElement $root Root element.
*
* @return string
*/
protected function get_inner_html( \DOMElement $root ) {
$html = '';
$owner_document = $this->get_owner_document( $root );
$child_nodes = $this->get_child_nodes( $root );
if ( ! $owner_document instanceof \DOMDocument || ! $child_nodes instanceof \DOMNodeList ) {
return $html;
}
foreach ( $child_nodes as $child ) {
$html .= $owner_document->saveHTML( $child );
}
return $html;
}
/**
* Get the first child node when present.
*
* @param \DOMNode $node Node.
*
* @return \DOMNode|null
*/
protected function get_first_child( \DOMNode $node ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM property.
return $node->firstChild instanceof \DOMNode ? $node->firstChild : null;
}
/**
* Get the parent element when present.
*
* @param \DOMNode $node Node.
*
* @return \DOMElement|null
*/
protected function get_parent_element( \DOMNode $node ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM property.
$parent = $node->parentNode;
return $parent instanceof \DOMElement ? $parent : null;
}
/**
* Get the owner document when present.
*
* @param \DOMNode $node Node.
*
* @return \DOMDocument|null
*/
protected function get_owner_document( \DOMNode $node ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM property.
return $node->ownerDocument instanceof \DOMDocument ? $node->ownerDocument : null;
}
/**
* Get child nodes for a node.
*
* @param \DOMNode $node Node.
*
* @return \DOMNodeList|null
*/
protected function get_child_nodes( \DOMNode $node ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM property.
return $node->childNodes instanceof \DOMNodeList ? $node->childNodes : null;
}
/**
* Get normalized text content from a DOM node.
*
* @param \DOMNode $node Node.
*
* @return string
*/
protected function get_node_text_content( \DOMNode $node ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM property.
return trim( preg_replace( '/\s+/', ' ', $node->textContent ?? '' ) );
}
/**
* Get normalized text content from a text node.
*
* @param \DOMText $node Text node.
*
* @return string
*/
protected function get_text_node_content( \DOMText $node ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM property.
return trim( $node->wholeText );
}
/**
* Get the tag name for an element.
*
* @param \DOMElement $node Element node.
*
* @return string
*/
protected function get_tag_name( \DOMElement $node ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Native DOM property.
return (string) $node->tagName;
}
/**
* Move all child nodes from one node to another.
*
* @param \DOMNode $source Source node.
* @param \DOMNode $target Target node.
*
* @return void
*/
protected function move_children( \DOMNode $source, \DOMNode $target ) {
while ( $this->get_first_child( $source ) instanceof \DOMNode ) {
$target->appendChild( $this->get_first_child( $source ) );
}
}
/**
* Replace a node with another node when it has a parent.
*
* @param \DOMNode $old_node Existing node.
* @param \DOMNode $new_node Replacement node.
*
* @return void
*/
protected function replace_node( \DOMNode $old_node, \DOMNode $new_node ) {
$parent = $this->get_parent_element( $old_node );
if ( $parent instanceof \DOMElement ) {
$parent->replaceChild( $new_node, $old_node );
}
}
/**
* Remove a node when it has a parent element.
*
* @param \DOMNode $node Node to remove.
*
* @return void
*/
protected function remove_node( \DOMNode $node ) {
$parent = $this->get_parent_element( $node );
if ( $parent instanceof \DOMElement ) {
$parent->removeChild( $node );
}
}
}