` 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 '![' . $alt . '](' . $this->target( $src ) . ')'; } /** * 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" : ''; } }