` produces.
*
* @var string
*/
private const BREAK_MARK = "\x02";
/**
* Element whose subtree is dropped, text included.
*
* @var array
*/
private const DROPPED = [ 'script', 'style', 'noscript', 'template', 'iframe', 'svg' ];
/**
* Element that only groups content: rendered as a block, kept as its children.
*
* @var array
*/
private const CONTAINERS = [ 'div', 'section', 'article', 'aside', 'header', 'footer', 'main', 'nav', 'address', 'dl', 'dt', 'dd' ];
/**
* Element that opens a list.
*
* @var array
*/
private const LISTS = [ 'ul', 'ol' ];
/**
* Converts an HTML fragment to Markdown.
*
* @param string $html HTML fragment, typically the output of `the_content`.
* @return string Markdown with a single trailing newline, or an empty string
* when the fragment carries no text.
*/
public function convert( string $html ): string {
if ( '' === trim( $html ) ) {
return '';
}
$document = $this->document( $html );
if ( null === $document ) {
return '';
}
return $this->normalize( $this->children( $document ) );
}
/**
* Parses the fragment into a document.
*
* The XML declaration pins the encoding to UTF-8 and, being the document's
* first child, stops libxml from adopting every following top level element
* into the first one. The wrapper element keeps the fragment's own top level
* nodes together; it is rendered as an ordinary container.
*
* @param string $html HTML fragment.
* @return DOMDocument|null Parsed document, or null when libxml refused the fragment.
*/
private function document( string $html ): ?DOMDocument {
$document = new DOMDocument();
$previous = libxml_use_internal_errors( true );
$loaded = $document->loadHTML(
'' . $html . '
',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors( $previous );
return $loaded ? $document : null;
}
/**
* The children of a node.
*
* Reading the DOM properties happens here and in the two accessors below, so
* the camel cased names the extension exposes stay in one place.
*
* @param DOMNode $node Node to read.
* @return DOMNodeList Child nodes, in document order.
*/
private function child_nodes( DOMNode $node ): DOMNodeList {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property of the DOM extension.
return $node->childNodes;
}
/**
* The lower cased tag name of an element.
*
* @param DOMElement $element Element to read.
* @return string Tag name, e.g. `blockquote`.
*/
private function tag( DOMElement $element ): string {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property of the DOM extension.
return strtolower( $element->tagName );
}
/**
* The text a node carries, its markup removed.
*
* @param DOMNode $node Node to read.
* @return string Text content.
*/
private function content( DOMNode $node ): string {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Property of the DOM extension.
return (string) $node->textContent;
}
/**
* Renders every child of a node, in document order.
*
* @param DOMNode $node Parent node.
* @return string Markdown.
*/
private function children( DOMNode $node ): string {
$out = '';
foreach ( $this->child_nodes( $node ) as $child ) {
$out .= $this->node( $child );
}
return $out;
}
/**
* Renders one node.
*
* Comments, processing instructions and everything else that is neither text
* nor an element render as nothing.
*
* @param DOMNode $node Node to render.
* @return string Markdown.
*/
private function node( DOMNode $node ): string {
if ( $node instanceof DOMText ) {
return $this->text( $this->content( $node ) );
}
if ( $node instanceof DOMElement ) {
return $this->element( $node );
}
return '';
}
/**
* Renders a text node with its whitespace collapsed.
*
* @param string $value Raw text.
* @return string Text with every whitespace run reduced to one space.
*/
private function text( string $value ): string {
return (string) preg_replace( '/\s+/', ' ', str_replace( self::BREAK_MARK, '', $value ) );
}
/**
* Renders one element according to its tag name.
*
* @param DOMElement $element Element to render.
* @return string Markdown.
*/
private function element( DOMElement $element ): string {
$tag = $this->tag( $element );
if ( in_array( $tag, self::DROPPED, true ) ) {
return '';
}
if ( in_array( $tag, self::CONTAINERS, true ) ) {
return $this->block( $this->children( $element ) );
}
if ( in_array( $tag, self::LISTS, true ) ) {
return $this->block( $this->list_block( $element, '' ) );
}
if ( preg_match( '/^h([1-6])$/', $tag, $matches ) ) {
return $this->block( $this->heading( $element, (int) $matches[1] ) );
}
switch ( $tag ) {
case 'p':
return $this->block( $this->children( $element ) );
case 'br':
return self::BREAK_MARK . "\n";
case 'hr':
return $this->block( '---' );
case 'strong':
case 'b':
return $this->emphasis( $element, '**' );
case 'em':
case 'i':
return $this->emphasis( $element, '*' );
case 'a':
return $this->link( $element );
case 'img':
return $this->image( $element );
case 'code':
return $this->inline_code( $element );
case 'pre':
return $this->block( $this->fence( $element ) );
case 'blockquote':
return $this->block( $this->quote( $element ) );
case 'li':
return $this->block( $this->children( $element ) );
case 'table':
return $this->block( $this->table( $element ) );
case 'figure':
return $this->block( $this->children( $element ) );
case 'figcaption':
return $this->wrapped( $this->single_line( $this->content( $element ) ), '*' );
case 'details':
return $this->block( $this->details( $element ) );
case 'summary':
return $this->wrapped( $this->single_line( $this->content( $element ) ), '**' );
default:
return $this->children( $element );
}
}
/**
* Wraps rendered content as a block, separated from its neighbours.
*
* @param string $content Rendered content.
* @return string Block, or an empty string when there is nothing to show.
*/
private function block( string $content ): string {
$content = $this->collapse( $content );
return '' !== $content ? "\n\n" . $content . "\n\n" : '';
}
/**
* A block whose whole content is wrapped in one marker, such as a caption.
*
* Takes plain text, never rendered Markdown: a marker wrapped around content
* that carries markers of its own reads as two overlapping spans rather than
* one, which no parser resolves the way the caption meant it.
*
* @param string $content Single line plain text.
* @param string $marker Markdown marker, e.g. `*`.
* @return string Block, or an empty string when the content is empty.
*/
private function wrapped( string $content, string $marker ): string {
return '' !== $content ? $this->block( $marker . $content . $marker ) : '';
}
/**
* Splits rendered inline content from the whitespace that surrounds it.
*
* Inline markers have to sit tight against the text they mark, so the space
* an element carries inside its tags is re-emitted outside them instead of
* being trimmed away, which would glue the element to its neighbours.
*
* @param string $content Rendered content.
* @return array Leading space, the content itself, trailing space.
*/
private function edges( string $content ): array {
if ( 1 !== preg_match( '/^(\s*)(.*?)(\s*)$/su', $content, $matches ) ) {
return [ '', trim( $content ), '' ];
}
return [ '' !== $matches[1] ? ' ' : '', $matches[2], '' !== $matches[3] ? ' ' : '' ];
}
/**
* Renders a heading.
*
* @param DOMElement $element Heading element.
* @param int $level Heading level, 1 to 6.
* @return string Heading line, or an empty string when it has no text.
*/
private function heading( DOMElement $element, int $level ): string {
$text = $this->single_line( $this->children( $element ) );
return '' !== $text ? str_repeat( '#', $level ) . ' ' . $text : '';
}
/**
* Renders bold or italic inline markup.
*
* @param DOMElement $element Element to render.
* @param string $marker Markdown marker, `**` or `*`.
* @return string Markdown, or an empty string when the element has no text.
*/
private function emphasis( DOMElement $element, string $marker ): string {
list( $before, $content, $after ) = $this->edges( $this->children( $element ) );
return '' !== $content ? $before . $marker . $content . $marker . $after : '';
}
/**
* Renders a link, or just its text when it has no target.
*
* @param DOMElement $element Anchor element.
* @return string Markdown.
*/
private function link( DOMElement $element ): string {
list( $before, $text, $after ) = $this->edges( $this->children( $element ) );
$text = $this->single_line( $text );
$href = trim( (string) $element->getAttribute( 'href' ) );
if ( '' === $text ) {
return '';
}
if ( '' === $href ) {
return $before . $text . $after;
}
return $before . '[' . $this->escape_brackets( $text ) . '](' . $this->target( $href ) . ')' . $after;
}
/**
* Renders an image.
*
* @param DOMElement $element Image element.
* @return string Markdown, or an empty string when the image has no source.
*/
private function image( DOMElement $element ): string {
$src = trim( (string) $element->getAttribute( 'src' ) );
if ( '' === $src ) {
return '';
}
$alt = $this->escape_brackets( $this->single_line( (string) $element->getAttribute( 'alt' ) ) );
return ' . ')';
}
/**
* A link or image target, in the form that survives the parser.
*
* Markdown ends a bare target at the first space or unbalanced parenthesis,
* so a URL carrying either is given the angle bracket form.
*
* @param string $href Raw target.
* @return string Target as it appears between the parentheses.
*/
private function target( string $href ): string {
return 1 === preg_match( '/[()\s]/', $href ) ? '<' . $href . '>' : $href;
}
/**
* Escapes the closing brackets of link text.
*
* A bracket that opens a target is left alone, so an image or a link nested
* inside the text keeps working.
*
* @param string $text Rendered link text.
* @return string Text safe to put between square brackets.
*/
private function escape_brackets( string $text ): string {
return (string) preg_replace( '/\](?!\()/', '\\\\]', $text );
}
/**
* Renders inline code.
*
* A snippet that itself contains a backtick is fenced with a double backtick
* and padded, which is how Markdown escapes it.
*
* @param DOMElement $element Code element.
* @return string Markdown, or an empty string when the element is empty.
*/
private function inline_code( DOMElement $element ): string {
$code = $this->single_line( $this->content( $element ) );
if ( '' === $code ) {
return '';
}
return false === strpos( $code, '`' ) ? '`' . $code . '`' : '`` ' . $code . ' ``';
}
/**
* Renders a preformatted block as a fenced code block.
*
* @param DOMElement $element Pre element.
* @return string Fenced block, or an empty string when it holds no code.
*/
private function fence( DOMElement $element ): string {
$code = str_replace( [ "\r\n", "\r" ], "\n", $this->content( $element ) );
$code = trim( $code, "\n" );
if ( '' === trim( $code ) ) {
return '';
}
return '```' . $this->language( $element ) . "\n" . $code . "\n```";
}
/**
* The code language declared on a preformatted block.
*
* @param DOMElement $element Pre element.
* @return string Language, e.g. `php`, or an empty string when none is declared.
*/
private function language( DOMElement $element ): string {
$classes = (string) $element->getAttribute( 'class' );
$code = $element->getElementsByTagName( 'code' )->item( 0 );
if ( $code instanceof DOMElement ) {
$classes .= ' ' . (string) $code->getAttribute( 'class' );
}
if ( preg_match( '/(?:^|\s)(?:language|lang)-([A-Za-z0-9#+_.-]+)/', $classes, $matches ) ) {
return strtolower( $matches[1] );
}
return '';
}
/**
* Renders a quotation, one `> ` prefixed line per line of content.
*
* Blank lines inside the quotation are dropped: the prefix alone would add a
* line no reader needs, and consecutive quoted paragraphs still read as one
* quotation.
*
* @param DOMElement $element Blockquote element.
* @return string Quoted lines.
*/
private function quote( DOMElement $element ): string {
$lines = [];
foreach ( explode( "\n", $this->collapse( $this->children( $element ) ) ) as $line ) {
$line = rtrim( $line );
if ( '' !== $line ) {
$lines[] = '> ' . $line;
}
}
return implode( "\n", $lines );
}
/**
* Renders a list and the lists nested inside its items.
*
* @param DOMElement $element List element.
* @param string $indent Indent every line of this level carries.
* @return string List lines.
*/
private function list_block( DOMElement $element, string $indent ): string {
$ordered = 'ol' === $this->tag( $element );
$lines = [];
$number = 0;
foreach ( $this->child_nodes( $element ) as $child ) {
if ( ! $child instanceof DOMElement || 'li' !== $this->tag( $child ) ) {
continue;
}
++$number;
$item = $this->list_item( $child, $indent, $ordered ? $number . '. ' : '- ' );
if ( '' !== $item ) {
$lines[] = $item;
}
}
return implode( "\n", $lines );
}
/**
* Renders one list item, its nested lists appended below it.
*
* @param DOMElement $element Item element.
* @param string $indent Indent the item's level carries.
* @param string $marker Item marker, e.g. `- ` or `2. `. Its width is the indent
* the item's own continuation lines and nested lists take.
* @return string Item lines, or an empty string when the item is empty.
*/
private function list_item( DOMElement $element, string $indent, string $marker ): string {
$content = '';
$nested = [];
$pad = $indent . str_repeat( ' ', strlen( $marker ) );
foreach ( $this->child_nodes( $element ) as $child ) {
if ( $child instanceof DOMElement && in_array( $this->tag( $child ), self::LISTS, true ) ) {
$sub = $this->list_block( $child, $pad );
if ( '' !== $sub ) {
$nested[] = $sub;
}
continue;
}
$content .= $this->node( $child );
}
$content = $this->collapse( $content );
if ( '' === $content && [] === $nested ) {
return '';
}
$lines = [];
foreach ( explode( "\n", $content ) as $index => $line ) {
if ( 0 === $index ) {
$lines[] = rtrim( $indent . $marker . $line );
continue;
}
$lines[] = '' !== $line ? $pad . $line : '';
}
return implode( "\n", array_merge( $lines, $nested ) );
}
/**
* Renders a table as a pipe table.
*
* The header is the first row that uses `th`, else the first row; every
* other row follows it in document order.
*
* @param DOMElement $element Table element.
* @return string Table lines, or an empty string when the table has no cells.
*/
private function table( DOMElement $element ): string {
$rows = [];
foreach ( $this->table_rows( $element ) as $row ) {
$cells = [];
$header = false;
foreach ( $this->child_nodes( $row ) as $cell ) {
if ( ! $cell instanceof DOMElement ) {
continue;
}
$tag = $this->tag( $cell );
if ( 'th' !== $tag && 'td' !== $tag ) {
continue;
}
$header = $header || 'th' === $tag;
$cells[] = str_replace( '|', '\\|', $this->single_line( $this->children( $cell ) ) );
}
if ( [] !== $cells ) {
$rows[] = [
'cells' => $cells,
'header' => $header,
];
}
}
if ( [] === $rows ) {
return '';
}
$head = 0;
foreach ( $rows as $index => $row ) {
if ( $row['header'] ) {
$head = $index;
break;
}
}
$lines = [
$this->table_row( $rows[ $head ]['cells'] ),
$this->table_row( array_fill( 0, count( $rows[ $head ]['cells'] ), '---' ) ),
];
foreach ( $rows as $index => $row ) {
if ( $index !== $head ) {
$lines[] = $this->table_row( $row['cells'] );
}
}
return implode( "\n", $lines );
}
/**
* The rows of one table, section wrappers unwrapped.
*
* Only rows this table owns are collected, so a table nested in a cell is
* rendered once, where it sits.
*
* @param DOMElement $element Table element.
* @return array Row elements, in document order.
*/
private function table_rows( DOMElement $element ): array {
$rows = [];
foreach ( $this->child_nodes( $element ) as $child ) {
if ( ! $child instanceof DOMElement ) {
continue;
}
$tag = $this->tag( $child );
if ( 'tr' === $tag ) {
$rows[] = $child;
continue;
}
if ( ! in_array( $tag, [ 'thead', 'tbody', 'tfoot' ], true ) ) {
continue;
}
foreach ( $this->child_nodes( $child ) as $row ) {
if ( $row instanceof DOMElement && 'tr' === $this->tag( $row ) ) {
$rows[] = $row;
}
}
}
return $rows;
}
/**
* Renders one table row.
*
* @param array $cells Rendered cells.
* @return string Row line.
*/
private function table_row( array $cells ): string {
return '| ' . implode( ' | ', $cells ) . ' |';
}
/**
* Renders a disclosure: its summary in bold, then the content it hides.
*
* @param DOMElement $element Details element.
* @return string Markdown.
*/
private function details( DOMElement $element ): string {
$out = '';
foreach ( $this->child_nodes( $element ) as $child ) {
if ( $child instanceof DOMElement && 'summary' === $this->tag( $child ) ) {
$out .= $this->wrapped( $this->single_line( $this->content( $child ) ), '**' );
continue;
}
$out .= $this->node( $child );
}
return $out;
}
/**
* Flattens rendered content onto one line.
*
* @param string $content Rendered content.
* @return string Trimmed single line.
*/
private function single_line( string $content ): string {
return trim( (string) preg_replace( '/\s+/', ' ', str_replace( self::BREAK_MARK, ' ', $content ) ) );
}
/**
* Trims a fragment and reduces its blank lines to at most one.
*
* @param string $content Rendered content.
* @return string Collapsed content.
*/
private function collapse( string $content ): string {
$content = (string) preg_replace( '/[ \t]+\n/', "\n", $content );
$content = (string) preg_replace( "/\n{3,}/", "\n\n", $content );
return trim( $content, " \t\n\r\0\x0B" . self::BREAK_MARK );
}
/**
* Turns the walked tree into the final document.
*
* @param string $content Rendered content.
* @return string Markdown with a single trailing newline, or an empty string.
*/
private function normalize( string $content ): string {
$content = (string) preg_replace( '/[ \t]*' . self::BREAK_MARK . '[ \t]*\n/', self::BREAK_MARK . "\n", $content );
$content = $this->collapse( $content );
return '' !== $content ? str_replace( self::BREAK_MARK, ' ', $content ) . "\n" : '';
}
}