loadHTML( '
' . $html . '
', LIBXML_NOERROR | LIBXML_NOWARNING );
libxml_clear_errors();
$root = $doc->getElementById( 'kdocs-md-root' );
$out = $root ? self::blocks( $root ) : '';
return trim( preg_replace( "/\n{3,}/", "\n\n", $out ) ) . "\n";
}
/**
* Block-level children of a node.
*
* @param \DOMNode $node Node.
* @param string $indent Prefix for nested lists.
* @return string
*/
private static function blocks( $node, $indent = '' ) {
$out = '';
$inline = '';
foreach ( $node->childNodes as $child ) {
$md = self::block( $child, $indent );
if ( null === $md ) {
$inline .= self::inline( $child );
continue;
}
if ( '' !== trim( $inline ) ) {
$out .= trim( $inline ) . "\n\n";
}
$inline = '';
$out .= $md;
}
if ( '' !== trim( $inline ) ) {
$out .= trim( $inline ) . "\n\n";
}
return $out;
}
/**
* Markdown of a block element, or null for inline content.
*
* @param \DOMNode $node Node.
* @param string $indent List indent.
* @return string|null
*/
private static function block( $node, $indent ) {
if ( ! $node instanceof \DOMElement ) {
return null;
}
$tag = strtolower( $node->tagName );
switch ( $tag ) {
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
$text = trim( self::inline( $node ) );
$text = preg_replace( '/\s*\[#\]\([^)]*\)$/', '', $text ); // Anchor links.
return '' === $text ? '' : str_repeat( '#', (int) $tag[1] ) . ' ' . $text . "\n\n";
case 'p':
$text = trim( self::inline( $node ) );
return '' === $text ? '' : $text . "\n\n";
case 'ul':
case 'ol':
return self::items( $node, 'ol' === $tag, $indent ) . ( '' === $indent ? "\n" : '' );
case 'pre':
$code = $node->getElementsByTagName( 'code' )->item( 0 );
$class = $node->getAttribute( 'class' ) . ' ' . ( $code ? $code->getAttribute( 'class' ) : '' );
$lang = preg_match( '/language-([\w-]+)/', $class, $m ) && 'text' !== $m[1] ? $m[1] : '';
$text = rtrim( $node->textContent, "\n" );
$fence = false !== strpos( $text, '```' ) ? '~~~' : '```';
return $fence . $lang . "\n" . $text . "\n" . $fence . "\n\n";
case 'blockquote':
$inner = trim( self::blocks( $node ) );
return '> ' . str_replace( "\n", "\n> ", $inner ) . "\n\n";
case 'table':
return self::table( $node );
case 'hr':
return "---\n\n";
case 'div':
if ( preg_match( '/(^|\s)kdocs-callout(\s|$)/', $node->getAttribute( 'class' ) ) ) {
// Callouts become quotes: "> ⚠️ **Title** …".
$icon = trim( (string) $node->getElementsByTagName( 'span' )->item( 0 )?->textContent );
$inner = trim( self::blocks( $node ) );
$inner = trim( '' !== $icon && 0 === strpos( $inner, $icon ) ? substr( $inner, strlen( $icon ) ) : $inner );
$lines = explode(
'
',
$inner,
2
);
if ( count( $lines ) > 1 && false === strpos(
$lines[0],
'
'
) ) {
$inner = '**' . $lines[0] . '**
' . $lines[1];
}
return '> ' . trim(
$icon . ' ' . str_replace(
'
',
'
> ',
$inner
)
) . '
';
}
return self::blocks( $node, $indent );
case 'figure':
case 'section':
case 'details':
case 'article':
return self::blocks( $node, $indent );
case 'summary':
return '**' . trim( self::inline( $node ) ) . "**\n\n";
case 'img':
return self::inline( $node ) . "\n\n";
case 'script':
case 'style':
case 'button':
case 'form':
case 'svg':
return '';
}
return null;
}
/**
* List items.
*
* @param \DOMElement $list List.
* @param bool $ordered Ordered.
* @param string $indent Indent.
* @return string
*/
private static function items( $list, $ordered, $indent ) {
$out = '';
$n = 1;
foreach ( $list->childNodes as $item ) {
if ( ! $item instanceof \DOMElement || 'li' !== strtolower( $item->tagName ) ) {
continue;
}
$marker = $ordered ? $n . '. ' : '- ';
++$n;
$text = '';
$nested = '';
foreach ( $item->childNodes as $child ) {
$name = $child instanceof \DOMElement ? strtolower( $child->tagName ) : '';
if ( 'ul' === $name || 'ol' === $name ) {
$nested .= self::items( $child, 'ol' === $name, $indent . ' ' );
} elseif ( 'p' === $name ) {
$text .= ' ' . self::inline( $child );
} else {
$text .= self::inline( $child );
}
}
$out .= $indent . $marker . trim( preg_replace( '/\s+/', ' ', $text ) ) . "\n" . $nested;
}
return $out;
}
/**
* Table as a GitHub-flavored Markdown table.
*
* @param \DOMElement $table Table.
* @return string
*/
private static function table( $table ) {
$rows = array();
foreach ( $table->getElementsByTagName( 'tr' ) as $tr ) {
$cells = array();
foreach ( $tr->childNodes as $cell ) {
if ( $cell instanceof \DOMElement && in_array( strtolower( $cell->tagName ), array( 'td', 'th' ), true ) ) {
$cells[] = str_replace( '|', '\|', trim( preg_replace( '/\s+/', ' ', self::inline( $cell ) ) ) );
}
}
if ( $cells ) {
$rows[] = $cells;
}
}
if ( ! $rows ) {
return '';
}
$width = max( array_map( 'count', $rows ) );
$line = static fn( $cells ) => '| ' . implode( ' | ', array_pad( $cells, $width, '' ) ) . ' |';
$out = $line( $rows[0] ) . "\n|" . str_repeat( ' --- |', $width ) . "\n";
foreach ( array_slice( $rows, 1 ) as $row ) {
$out .= $line( $row ) . "\n";
}
return $out . "\n";
}
/**
* Inline Markdown of a node.
*
* @param \DOMNode $node Node.
* @return string
*/
private static function inline( $node ) {
if ( $node instanceof \DOMText ) {
return preg_replace( '/\s+/', ' ', $node->nodeValue );
}
if ( ! $node instanceof \DOMElement ) {
return '';
}
$inner = '';
foreach ( $node->childNodes as $child ) {
$inner .= self::inline( $child );
}
switch ( strtolower( $node->tagName ) ) {
case 'strong':
case 'b':
return '' === trim( $inner ) ? $inner : '**' . trim( $inner ) . '**';
case 'em':
case 'i':
return '' === trim( $inner ) ? $inner : '*' . trim( $inner ) . '*';
case 'code':
case 'kbd':
return '`' . $node->textContent . '`';
case 'a':
$href = $node->getAttribute( 'href' );
if ( false !== strpos( $node->getAttribute( 'class' ), 'kdocs-anchor' ) ) {
return '';
}
return $href && '' !== trim( $inner ) ? '[' . trim( $inner ) . '](' . $href . ')' : $inner;
case 'img':
$src = $node->getAttribute( 'src' );
return $src && false === strpos( $node->getAttribute( 'class' ), 'kdocs-img-dark' ) ? '' : '';
case 'br':
return " \n";
case 'script':
case 'style':
case 'button':
case 'svg':
return '';
}
return $inner;
}
}