Markdown converter. * * Used when the Strauss-prefixed league/html-to-markdown vendor build is not * present (i.e. on most installs of the unbuilt plugin). Deliberately free of * any WordPress dependencies so it can be unit-tested with plain PHPUnit. * * @package AEL42A */ namespace AEL42A; // No ABSPATH guard on purpose: this file must be loadable outside WordPress // for the standalone converter tests. It declares a class and has no side // effects, so direct access discloses nothing. /** * DOMDocument-based HTML -> Markdown converter. * * Handles: h1-h6, p, a, img, ul/ol/li (nested), blockquote, pre/code, * strong/b, em/i, del/s, br, hr, simple pipe tables, figure/figcaption. * Strips: script, style, iframe, form, noscript, svg, button, canvas. */ class Html_To_Markdown_Fallback { /** * Tags removed entirely (including their content). * * @var string[] */ private $strip_tags = array( 'script', 'style', 'iframe', 'form', 'noscript', 'svg', 'button', 'canvas', 'template', 'object', 'embed' ); /** * Container tags whose children are treated as top-level blocks. * * @var string[] */ private $container_tags = array( 'div', 'section', 'article', 'main', 'aside', 'header', 'footer', 'nav', 'body', 'html', 'span-block', 'details', 'summary', 'address', 'fieldset' ); /** * Convert an HTML fragment to Markdown. * * @param string $html HTML fragment (typically post content run through the_content filters). * @return string Markdown. */ public function convert( $html ) { $html = trim( (string) $html ); if ( '' === $html ) { return ''; } $previous = libxml_use_internal_errors( true ); $doc = new \DOMDocument( '1.0', 'UTF-8' ); // The XML prolog trick forces DOMDocument to treat the input as UTF-8. $doc->loadHTML( '
' . $html . '
', LIBXML_NONET ); libxml_clear_errors(); libxml_use_internal_errors( $previous ); foreach ( $this->strip_tags as $tag ) { $nodes = $doc->getElementsByTagName( $tag ); // Live NodeList - remove back-to-front. for ( $i = $nodes->length - 1; $i >= 0; $i-- ) { $node = $nodes->item( $i ); if ( $node && $node->parentNode ) { $node->parentNode->removeChild( $node ); } } } $root = $doc->getElementById( 'ael-42a-root' ); if ( ! $root ) { return ''; } $markdown = $this->convert_blocks( $root, 0 ); // Normalise: no trailing spaces, max one blank line between blocks. $markdown = preg_replace( '/[ \t]+$/m', '', $markdown ); $markdown = preg_replace( "/\n{3,}/", "\n\n", $markdown ); return trim( $markdown ) . "\n"; } /** * Convert the children of a node as a sequence of block elements. * * Consecutive inline nodes are grouped into an implicit paragraph. * * @param \DOMNode $node Parent node. * @param int $list_depth Current list nesting depth. * @return string */ private function convert_blocks( $node, $list_depth ) { $blocks = array(); $inline = ''; foreach ( $node->childNodes as $child ) { if ( $this->is_block_node( $child ) ) { if ( '' !== trim( $inline ) ) { $blocks[] = $this->tidy_inline( $inline ); } $inline = ''; $block = $this->convert_block_node( $child, $list_depth ); if ( '' !== trim( $block ) ) { $blocks[] = rtrim( $block ); } } else { $inline .= $this->convert_inline_node( $child ); } } if ( '' !== trim( $inline ) ) { $blocks[] = $this->tidy_inline( $inline ); } return implode( "\n\n", $blocks ); } /** * Whether a DOM node renders as a block. * * @param \DOMNode $node Node. * @return bool */ private function is_block_node( $node ) { if ( XML_ELEMENT_NODE !== $node->nodeType ) { return false; } $tag = strtolower( $node->nodeName ); $blocks = array( 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr', 'table', 'figure', 'figcaption', 'dl', 'dt', 'dd', 'video', 'audio', ); return in_array( $tag, $blocks, true ) || in_array( $tag, $this->container_tags, true ); } /** * Convert one block-level element. * * @param \DOMElement $node Element. * @param int $list_depth Current list nesting depth. * @return string */ private function convert_block_node( $node, $list_depth ) { $tag = strtolower( $node->nodeName ); switch ( $tag ) { case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': $level = (int) substr( $tag, 1 ); $text = $this->tidy_inline( $this->convert_inline_children( $node ) ); return '' === $text ? '' : str_repeat( '#', $level ) . ' ' . $text; case 'p': case 'dt': case 'dd': case 'figcaption': $text = $this->tidy_inline( $this->convert_inline_children( $node ) ); if ( 'figcaption' === $tag && '' !== $text ) { return '*' . $text . '*'; } return $text; case 'ul': return $this->convert_list( $node, $list_depth, false ); case 'ol': return $this->convert_list( $node, $list_depth, true ); case 'blockquote': $inner = $this->convert_blocks( $node, $list_depth ); if ( '' === trim( $inner ) ) { return ''; } $lines = explode( "\n", $inner ); foreach ( $lines as $i => $line ) { $lines[ $i ] = '' === trim( $line ) ? '>' : '> ' . $line; } return implode( "\n", $lines ); case 'pre': return $this->convert_pre( $node ); case 'hr': return '---'; case 'table': return $this->convert_table( $node ); case 'figure': return $this->convert_blocks( $node, $list_depth ); case 'li': // Stray
  • outside a list - render as a single bullet. return '- ' . $this->tidy_inline( $this->convert_inline_children( $node ) ); default: // Generic containers: recurse. return $this->convert_blocks( $node, $list_depth ); } } /** * Convert a ul/ol element to a markdown list, supporting nesting. * * @param \DOMElement $node List element. * @param int $depth Nesting depth (0 = top level). * @param bool $ordered Ordered list? * @return string */ private function convert_list( $node, $depth, $ordered ) { $lines = array(); $index = 1; $indent = str_repeat( ' ', $depth ); foreach ( $node->childNodes as $child ) { if ( XML_ELEMENT_NODE !== $child->nodeType || 'li' !== strtolower( $child->nodeName ) ) { continue; } $marker = $ordered ? $index . '. ' : '- '; $index++; $text_parts = array(); $sub_blocks = array(); foreach ( $child->childNodes as $li_child ) { $li_tag = XML_ELEMENT_NODE === $li_child->nodeType ? strtolower( $li_child->nodeName ) : ''; if ( 'ul' === $li_tag || 'ol' === $li_tag ) { $sub = $this->convert_list( $li_child, $depth + 1, 'ol' === $li_tag ); if ( '' !== trim( $sub ) ) { $sub_blocks[] = $sub; } } elseif ( $this->is_block_node( $li_child ) ) { $block = $this->convert_block_node( $li_child, $depth + 1 ); if ( '' !== trim( $block ) ) { // Keep nested block content on the item's line where // possible (paragraphs), otherwise indent it below. if ( false === strpos( $block, "\n" ) ) { $text_parts[] = trim( $block ); } else { $sub_blocks[] = preg_replace( '/^/m', $indent . ' ', $block ); } } } else { $text_parts[] = $this->convert_inline_node( $li_child ); } } $text = $this->tidy_inline( implode( ' ', $text_parts ) ); $lines[] = $indent . $marker . $text; foreach ( $sub_blocks as $sub ) { $lines[] = $sub; } } return implode( "\n", $lines ); } /** * Convert a
     element to a fenced code block.
    	 *
    	 * @param \DOMElement $node Pre element.
    	 * @return string
    	 */
    	private function convert_pre( $node ) {
    		$language = '';
    		$code     = $node;
    
    		// 
     pattern.
    		foreach ( $node->childNodes as $child ) {
    			if ( XML_ELEMENT_NODE === $child->nodeType && 'code' === strtolower( $child->nodeName ) ) {
    				$code  = $child;
    				$class = $child->getAttribute( 'class' );
    				if ( preg_match( '/(?:language|lang)-([\w+-]+)/', $class, $m ) ) {
    					$language = $m[1];
    				}
    				break;
    			}
    		}
    
    		$text = rtrim( $code->textContent );
    		if ( '' === trim( $text ) ) {
    			return '';
    		}
    
    		// Widen the fence if the code itself contains backtick fences.
    		$fence = '```';
    		while ( false !== strpos( $text, $fence ) ) {
    			$fence .= '`';
    		}
    
    		return $fence . $language . "\n" . $text . "\n" . $fence;
    	}
    
    	/**
    	 * Convert a  element to a simple pipe table.
    	 *
    	 * @param \DOMElement $node Table element.
    	 * @return string
    	 */
    	private function convert_table( $node ) {
    		$rows       = array();
    		$header_row = null;
    
    		foreach ( $this->find_descendants( $node, 'tr' ) as $tr ) {
    			$cells  = array();
    			$is_thead = false;
    			foreach ( $tr->childNodes as $cell ) {
    				if ( XML_ELEMENT_NODE !== $cell->nodeType ) {
    					continue;
    				}
    				$cell_tag = strtolower( $cell->nodeName );
    				if ( 'th' !== $cell_tag && 'td' !== $cell_tag ) {
    					continue;
    				}
    				if ( 'th' === $cell_tag ) {
    					$is_thead = true;
    				}
    				$text    = $this->tidy_inline( $this->convert_inline_children( $cell ) );
    				$cells[] = str_replace( array( "\n", '|' ), array( ' ', '\\|' ), $text );
    			}
    			if ( empty( $cells ) ) {
    				continue;
    			}
    			if ( $is_thead && null === $header_row && empty( $rows ) ) {
    				$header_row = $cells;
    			} else {
    				$rows[] = $cells;
    			}
    		}
    
    		if ( null === $header_row && empty( $rows ) ) {
    			return '';
    		}
    
    		if ( null === $header_row ) {
    			$header_row = array_shift( $rows );
    		}
    
    		$columns = count( $header_row );
    		$lines   = array();
    		$lines[] = '| ' . implode( ' | ', $header_row ) . ' |';
    		$lines[] = '|' . str_repeat( ' --- |', $columns );
    
    		foreach ( $rows as $cells ) {
    			// Pad/truncate to the header column count.
    			$cells   = array_pad( array_slice( $cells, 0, $columns ), $columns, '' );
    			$lines[] = '| ' . implode( ' | ', $cells ) . ' |';
    		}
    
    		return implode( "\n", $lines );
    	}
    
    	/**
    	 * Collect all descendant elements of a tag name.
    	 *
    	 * @param \DOMElement $node Root element.
    	 * @param string      $tag  Tag name.
    	 * @return \DOMElement[]
    	 */
    	private function find_descendants( $node, $tag ) {
    		$found = array();
    		$list  = $node->getElementsByTagName( $tag );
    		foreach ( $list as $item ) {
    			$found[] = $item;
    		}
    		return $found;
    	}
    
    	/**
    	 * Convert all children of a node as inline content.
    	 *
    	 * @param \DOMNode $node Parent node.
    	 * @return string
    	 */
    	private function convert_inline_children( $node ) {
    		$out = '';
    		foreach ( $node->childNodes as $child ) {
    			$out .= $this->convert_inline_node( $child );
    		}
    		return $out;
    	}
    
    	/**
    	 * Convert a single inline node.
    	 *
    	 * @param \DOMNode $node Node.
    	 * @return string
    	 */
    	private function convert_inline_node( $node ) {
    		if ( XML_TEXT_NODE === $node->nodeType || XML_CDATA_SECTION_NODE === $node->nodeType ) {
    			return preg_replace( '/\s+/u', ' ', $node->nodeValue );
    		}
    
    		if ( XML_ELEMENT_NODE !== $node->nodeType ) {
    			return '';
    		}
    
    		$tag = strtolower( $node->nodeName );
    
    		switch ( $tag ) {
    			case 'a':
    				$text = $this->tidy_inline( $this->convert_inline_children( $node ) );
    				$href = trim( $node->getAttribute( 'href' ) );
    				if ( '' === $href || 0 === strpos( $href, 'javascript:' ) ) {
    					return $text;
    				}
    				if ( '' === $text ) {
    					$text = $href;
    				}
    				return '[' . $text . '](' . $href . ')';
    
    			case 'img':
    				$src = trim( $node->getAttribute( 'src' ) );
    				if ( '' === $src || 0 === strpos( $src, 'data:' ) ) {
    					return '';
    				}
    				$alt = trim( preg_replace( '/\s+/u', ' ', $node->getAttribute( 'alt' ) ) );
    				return '![' . str_replace( array( '[', ']' ), '', $alt ) . '](' . $src . ')';
    
    			case 'strong':
    			case 'b':
    				$text = $this->tidy_inline( $this->convert_inline_children( $node ) );
    				return '' === $text ? '' : '**' . $text . '**';
    
    			case 'em':
    			case 'i':
    				$text = $this->tidy_inline( $this->convert_inline_children( $node ) );
    				return '' === $text ? '' : '*' . $text . '*';
    
    			case 'del':
    			case 's':
    			case 'strike':
    				$text = $this->tidy_inline( $this->convert_inline_children( $node ) );
    				return '' === $text ? '' : '~~' . $text . '~~';
    
    			case 'code':
    			case 'kbd':
    			case 'samp':
    				$text = trim( preg_replace( '/\s+/u', ' ', $node->textContent ) );
    				if ( '' === $text ) {
    					return '';
    				}
    				$delim = false !== strpos( $text, '`' ) ? '``' : '`';
    				return $delim . $text . $delim;
    
    			case 'br':
    				return "\n";
    
    			default:
    				// Unknown inline element (span, sub, sup, abbr...): recurse.
    				return $this->convert_inline_children( $node );
    		}
    	}
    
    	/**
    	 * Collapse runs of spaces and trim an inline fragment, preserving
    	 * intentional hard line breaks from 
    . * * @param string $text Inline text. * @return string */ private function tidy_inline( $text ) { // Collapse horizontal whitespace but keep newlines from
    . $text = preg_replace( '/[ \t]+/u', ' ', $text ); $text = preg_replace( '/ ?\n ?/u', "\n", $text ); return trim( $text ); } }