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( '
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 );
}
}