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' ) ? '![' . $node->getAttribute( 'alt' ) . '](' . $src . ')' : ''; case 'br': return " \n"; case 'script': case 'style': case 'button': case 'svg': return ''; } return $inner; } }