$depths
*/
private $depths = array(
'PRE' => 0,
'OL' => 0,
'UL' => 0,
);
/**
* Tracks open block containers.
*
* @var Array
*/
private $stack = array();
public function __construct( string $html, ?WP_Experimental_HTML_Renderer_Options $options = null ) {
$this->html = $html;
$this->options = $options ?? new WP_Experimental_HTML_Renderer_Options();
$this->line_buffer = new WP_Experimental_HTML_Renderer_Line_Buffer();
}
public function to_markdown() {
$p = \WP_HTML_Processor::create_fragment( $this->html );
$soft_limit = $this->options->soft_line_wrap;
$this->output = '';
$node_finder = \apply_filters( 'html_to_markdown_starting_node_finder', null );
if ( \is_callable( $node_finder ) ) {
// If it failed to find something, show everything.
if ( ! \call_user_func( $node_finder, $p ) ) {
$p = \WP_HTML_Processor::create_fragment( $this->html );
};
}
$main_depth = $p->get_current_depth();
$base_url_confidence = isset( $this->options->base_url ) ? PHP_INT_MAX : 0;
while ( $p->get_current_depth() >= $main_depth && $p->next_token() ) {
$token_name = $p->get_token_name();
$is_closer = $p->is_tag_closer();
if ( $this->skip_hidden_content( $p ) ) {
continue;
}
switch ( $token_name ) {
case '#text':
$preserve_whitespace = $this->depths['PRE'] > 0;
$chunk = $p->get_modifiable_text();
$chunk = $preserve_whitespace
? $chunk
: \preg_replace( '~[ \t\f\r\n]+~', ' ', $chunk );
$this->line_buffer->append_text( $chunk );
if (
! ( $this->innermost_block() instanceof WP_Experimental_HTML_Renderer_Block_Paragraph ) &&
$this->line_buffer->has_non_whitespace_content()
) {
$paragraph = new WP_Experimental_HTML_Renderer_Block_Paragraph();
$paragraph->append_line( $this->line_buffer );
$this->enter_block( $paragraph );
}
break;
case 'LINK':
if ( $base_url_confidence < 2 && 'canonical' === $p->get_attribute( 'rel' ) ) {
$this->options->base_url = $p->get_attribute( 'href' );
if ( true === $this->options->base_url ) {
$this->options->base_url = null;
} else {
$base_url_confidence = 2;
}
}
break;
case 'META':
if ( $base_url_confidence < 1 && 'og:url' === $p->get_attribute( 'property' ) ) {
$this->options->base_url = $p->get_attribute( 'content' );
if ( true === $this->options->base_url ) {
$this->options->base_url = null;
} else {
$base_url_confidence = 1;
}
}
break;
case 'A':
// @todo Join with base URL of document, if available, to form URL.
if ( $is_closer ) {
$this->line_buffer->release_format();
} else {
$href = $p->get_attribute( 'href' );
if ( is_string( $href ) ) {
$this->line_buffer->require_format( new WP_Experimental_HTML_Renderer_Format_Link( $href ) );
}
}
break;
// Handle inline formatting.
case 'B':
case 'BR':
case 'CODE':
case 'EM':
case 'I':
case 'Q':
case 'S':
case 'STRONG':
case 'SUB':
case 'SUP':
if ( $is_closer ) {
$this->line_buffer->release_format();
} else {
if ( 'CODE' === $token_name && $this->innermost_block() instanceof WP_Experimental_HTML_Renderer_Block_Code ) {
$this->innermost_block()->infer_language( $p );
}
$format = WP_Experimental_HTML_Renderer_Format_Generic::from_html_tag( $token_name );
if ( isset( $format ) ) {
$this->line_buffer->require_format( $format );
}
}
break;
case 'BLOCKQUOTE':
$this->close_a_paragraph();
if ( $is_closer ) {
$this->flush_block();
} else {
$this->enter_block( new WP_Experimental_HTML_Renderer_Block_Blockquote() );
}
break;
case 'H1':
case 'H2':
case 'H3':
case 'H4':
case 'H5':
case 'H6':
$this->close_a_paragraph();
if ( $is_closer ) {
$this->flush_block();
} else {
$heading = new WP_Experimental_HTML_Renderer_Block_ATX( (int) $token_name[1] );
$heading->append_line( $this->line_buffer );
$this->enter_block( $heading );
}
break;
/*
* For simplicity’s sake, adding a thematic break is considered
* equivalent here to adding a new paragraph whose contents is
* the break syntax. Note that this syntax can be quite colorful
* and there are other options, but three dashes are very popular
* forms of the break, so will likely be among the most familiar.
*/
case 'HR':
$this->close_a_paragraph();
$break = new WP_Experimental_HTML_Renderer_Block_Paragraph();
$this->line_buffer = new WP_Experimental_HTML_Renderer_Line_Buffer();
$this->line_buffer->append_text( '---' );
$break->append_line( $this->line_buffer );
$this->enter_block( $break );
$this->close_a_paragraph();
break;
case 'IMG':
$src = $p->get_attribute( 'src' );
if ( ! \is_string( $src ) || empty( \trim( $src ) ) ) {
// Only consider images which contain some content.
break;
}
$alt = $p->get_attribute( 'alt' );
$alt = \is_string( $alt ) ? $alt : '';
$title = $p->get_attribute( 'title' );
$title = \is_string( $title ) ? $title : '';
$this->line_buffer->require_format( new WP_Experimental_HTML_Renderer_Format_Image( $src, $alt, $title ) );
$this->line_buffer->release_format();
break;
case 'LI':
$this->close_a_paragraph();
if ( ! ( $is_closer || $this->innermost_block() instanceof WP_Experimental_HTML_Renderer_Block_List ) ) {
$this->enter_block( new WP_Experimental_HTML_Renderer_Block_List( '' ) );
}
break;
// @todo This group all deserves special attention, to be replaced later.
case 'CENTER':
case 'DETAILS':
case 'DIALOG':
case 'FIGURE':
case 'FIGCAPTION':
case 'FORM':
case 'LEGEND':
case 'NAV':
case 'PLAINTEXT':
case 'SEARCH':
case 'SUMMARY':
case 'XMP':
$this->close_a_paragraph();
break;
case 'ADDRESS':
case 'ARTICLE':
case 'ASIDE':
case 'DIV':
case 'FOOTER':
case 'HEADER':
case 'HGROUP':
case 'MAIN':
case 'P':
case 'SECTION':
$this->close_a_paragraph();
break;
case 'PRE':
$this->close_a_paragraph();
if ( $is_closer ) {
$this->flush_block();
} else {
$this->enter_block( new WP_Experimental_HTML_Renderer_Block_Code() );
$this->innermost_block()->infer_language( $p );
}
$this->depths['PRE'] += $is_closer ? -1 : 1;
break;
case 'OL':
case 'UL':
$this->close_a_paragraph();
if ( $is_closer ) {
if ( $this->line_buffer->has_non_whitespace_content() ) {
if ( $this->innermost_block() instanceof WP_Experimental_HTML_Renderer_Block_List ) {
$item = new WP_Experimental_HTML_Renderer_Block_Paragraph();
$item->append_line( $this->line_buffer );
} else {
$item = $this->close_innermost_block();
}
$this->innermost_block()->append( $item );
}
$this->line_buffer = new WP_Experimental_HTML_Renderer_Line_Buffer();
$this->flush_block();
} else {
if ( ! $this->line_buffer->has_non_whitespace_content() ) {
$this->line_buffer = new WP_Experimental_HTML_Renderer_Line_Buffer();
}
$type = $p->get_attribute( 'type' );
if ( 'UL' === $token_name ) {
$type = \is_string( $type ) ? \strtolower( \trim( $type, " \t\f\r\n" ) ) : '';
$bullets_syntax = array(
'circle' => '*',
'disc' => '-',
);
$bullets_presentational = array(
'circle' => '•',
'disc' => '◦',
'square' => '▪',
'triangle' => '‣',
'dash' => '⁃',
);
$bullets = 'syntax' === $this->options->display_mode
? $bullets_syntax
: $bullets_presentational;
$style = $bullets_presentational[ $type ] ?? null;
$style = $style ?? \array_values( $bullets )[ $this->depths['UL'] % \count( $bullets ) ];
} elseif ( 'OL' === $token_name ) {
$style = \in_array( $type, [ '1', 'a', 'A', 'i', 'I' ], true )
? $type
: [ '1', 'a', 'A', 'i', 'I' ][ $this->depths['OL'] % 5 ];
$start = $p->get_attribute( 'start' );
if (
\is_string( $start ) &&
\strspn( $start, '0123456789' ) === \strlen( $start )
) {
$start = (int) $start;
} else {
$start = 1;
}
// @todo this would be better communicated structurally.
$style = "{$style}.{$start}";
}
$this->enter_block( new WP_Experimental_HTML_Renderer_Block_List( $style ) );
}
$this->depths[ $token_name ] += $is_closer ? -1 : 1;
break;
/*
* Tables deserve their own block type which maintains
* consistent widths for the cells, handles colspan
* widths, rowspan, etc. This gets very complicated so
* the current implementation does little more than to
* draw borders around the cells so they are visually
* separated.
*/
case 'TABLE':
$this->close_a_paragraph();
$this->options->soft_line_wrap = $is_closer ? $soft_limit : PHP_INT_MAX;
break;
case 'TD':
case 'TH':
if ( $is_closer ) {
$this->line_buffer->append_text( ' | ' );
}
break;
case 'TR':
if ( $is_closer ) {
$this->line_buffer->append_text( "\n" );
} else {
$this->line_buffer->append_text( '| ' );
}
break;
}
}
// Handle parsing failures.
if ( $p->paused_at_incomplete_token() || null !== $p->get_last_error() ) {
switch ( $this->options->recovery_mode ) {
// @todo: Add default 'reduced-fidelity' mode continuing with Tag Processor.
case 'abort':
default:
/*
* @todo Returning `null` would be a clearer signature, but also requiring
* typing the function as `?string`, and that calling code check the
* output for nullity. This could be resolved with different public
* interfaces that wrap the conditional response, for example, with
* `wp_html_to_markdown()` and `wp_try_html_to_markdown()`, but with
* better names.
*/
return '';
}
}
while ( \count( $this->stack ) > 0 ) {
$this->flush_block();
}
return $this->output;
}
private function flush_block() {
$block = $this->close_innermost_block();
if ( null === $block || $block->is_empty() ) {
return;
}
$parent = $this->innermost_block();
if ( $parent instanceof WP_Experimental_HTML_Renderer_Block ) {
$parent->append( $block );
} else {
if ( '' !== $this->output ) {
$this->output .= "\n" === $this->output[ \strlen( $this->output ) - 1 ] ? "\n" : "\n\n";
}
$this->output .= \ltrim( $block->flush( $this->options ), "\n" );
}
}
private function close_a_paragraph() {
if (
$this->innermost_block() instanceof WP_Experimental_HTML_Renderer_Block_Paragraph &&
$this->line_buffer->has_non_whitespace_content()
) {
$this->flush_block();
} elseif ( $this->line_buffer->has_non_whitespace_content() ) {
$paragraph = new WP_Experimental_HTML_Renderer_Block_Paragraph();
$paragraph->append_line( $this->line_buffer );
$this->enter_block( $paragraph );
$this->flush_block();
}
$this->line_buffer = new WP_Experimental_HTML_Renderer_Line_Buffer();
}
private function skip_hidden_content( $p ) {
$token_name = $p->get_token_name();
switch ( $token_name ) {
case 'BUTTON':
case 'DATALIST':
case 'IFRAME':
case 'INPUT':
case 'OPTION':
case 'PARAM':
case 'SELECT':
case 'SVG':
case 'TEMPLATE':
case 'TEXTAREA':
case 'TITLE':
goto skip;
}
$hidden = $p->get_attribute( 'hidden' );
if (
isset( $hidden ) &&
!( \is_string( $hidden ) && 0 === \strcasecmp( $hidden, 'until-found' ) )
) {
goto skip;
}
$hidden = $p->get_attribute( 'aria-hidden' );
if ( \is_string( $hidden ) && 0 === \strcasecmp( $hidden, 'true' ) ) {
goto skip;
}
return false;
skip:
if ( !$p->expects_closer() ) {
return true;
}
$depth = $p->get_current_depth();
while ( $p->next_token() && $depth <= $p->get_current_depth() ) {
continue;
}
return true;
}
private function enter_block( WP_Experimental_HTML_Renderer_Block $block ) {
$this->stack[] = $block;
}
private function close_innermost_block(): ?WP_Experimental_HTML_Renderer_Block {
return \array_pop( $this->stack );
}
private function innermost_block(): ?WP_Experimental_HTML_Renderer_Block {
$stack_size = \count( $this->stack );
return $stack_size > 0 ? $this->stack[ $stack_size - 1 ] : null;
}
}