*/ private array $depths = [ 'PRE' => 0, 'OL' => 0, 'UL' => 0, ]; /** * Whether parsing is currently inside a table header section. * * @var bool */ private bool $inside_table_head = false; /** * Whether parsing is currently inside a table cell. * * @var bool */ private bool $inside_table_cell = false; /** * Whether the active table cell should be treated as a header cell. * * @var bool */ private bool $current_table_cell_is_header = false; /** * Open block stack. * * @var array */ private array $stack = []; /** * Constructor. * * @param string $html HTML fragment. * @param WpHtmlApiRendererOptions|null $options Renderer options. */ public function __construct( string $html, ?WpHtmlApiRendererOptions $options = null ) { $this->html = $html; $this->options = $options ?? new WpHtmlApiRendererOptions(); $this->line_buffer = new WpHtmlApiLineBuffer(); } /** * Render the HTML fragment as markdown. * * @return string */ public function to_markdown() { $processor = \WP_HTML_Processor::create_fragment( $this->html ); if ( ! $processor ) { return ''; } $soft_limit = $this->options->soft_line_wrap; $this->output = ''; $node_finder = apply_filters( 'aisignal_markdown_converter_starting_node_finder', null, $processor ); if ( is_callable( $node_finder ) && ! call_user_func( $node_finder, $processor ) ) { $processor = \WP_HTML_Processor::create_fragment( $this->html ); } $main_depth = $processor->get_current_depth(); $base_url_confidence = isset( $this->options->base_url ) ? PHP_INT_MAX : 0; while ( $processor->get_current_depth() >= $main_depth && $processor->next_token() ) { $token_name = $processor->get_token_name(); $is_closer = $processor->is_tag_closer(); if ( $this->skip_hidden_content( $processor ) ) { continue; } if ( $this->handle_table_cell_block_token( $token_name, $is_closer ) ) { continue; } switch ( $token_name ) { case '#text': $preserve_whitespace = $this->depths['PRE'] > 0; $chunk = $processor->get_modifiable_text(); $chunk = $preserve_whitespace ? $chunk : preg_replace( '~[ \t\f\r\n]+~', ' ', $chunk ); $this->line_buffer->append_text( $chunk ); if ( ! $this->inside_table_cell && ! ( $this->innermost_block() instanceof WpHtmlApiBlockParagraph ) && $this->line_buffer->has_non_whitespace_content() ) { $paragraph = new WpHtmlApiBlockParagraph(); $paragraph->append_line( $this->line_buffer ); $this->enter_block( $paragraph ); } break; case 'LINK': if ( $base_url_confidence < 2 && 'canonical' === $processor->get_attribute( 'rel' ) ) { $this->options->base_url = $processor->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' === $processor->get_attribute( 'property' ) ) { $this->options->base_url = $processor->get_attribute( 'content' ); if ( true === $this->options->base_url ) { $this->options->base_url = null; } else { $base_url_confidence = 1; } } break; case 'A': if ( $is_closer ) { $this->line_buffer->release_format(); } else { $href = $processor->get_attribute( 'href' ); if ( is_string( $href ) ) { $this->line_buffer->require_format( new WpHtmlApiFormatLink( $href ) ); } } break; 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 WpHtmlApiBlockCode ) { $this->innermost_block()->infer_language( $processor ); } $format = WpHtmlApiFormatGeneric::from_html_tag( $token_name ); if ( isset( $format ) ) { $this->line_buffer->require_format( $format ); } } break; case 'BLOCKQUOTE': if ( $this->inside_table_cell ) { $this->append_table_cell_break(); break; } $this->close_a_paragraph(); if ( $is_closer ) { $this->flush_block(); } else { $this->enter_block( new WpHtmlApiBlockBlockquote() ); } break; case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6': if ( $this->inside_table_cell ) { $this->append_table_cell_break(); break; } $this->close_a_paragraph(); if ( $is_closer ) { $this->flush_block(); } else { $heading = new WpHtmlApiBlockAtx( (int) $token_name[1] ); $heading->append_line( $this->line_buffer ); $this->enter_block( $heading ); } break; case 'HR': if ( $this->inside_table_cell ) { $this->append_table_cell_break(); $this->line_buffer->append_text( '---' ); $this->append_table_cell_break(); break; } $this->close_a_paragraph(); $break = new WpHtmlApiBlockParagraph(); $this->line_buffer = new WpHtmlApiLineBuffer(); $this->line_buffer->append_text( '---' ); $break->append_line( $this->line_buffer ); $this->enter_block( $break ); $this->close_a_paragraph(); break; case 'IMG': $src = $processor->get_attribute( 'src' ); if ( ! is_string( $src ) || empty( trim( $src ) ) ) { break; } $alt = $processor->get_attribute( 'alt' ); $title = $processor->get_attribute( 'title' ); $this->line_buffer->require_format( new WpHtmlApiFormatImage( $src, is_string( $alt ) ? $alt : '', is_string( $title ) ? $title : '' ) ); $this->line_buffer->release_format(); break; case 'LI': if ( $this->inside_table_cell ) { if ( ! $is_closer ) { $this->append_table_cell_break(); $this->line_buffer->append_text( '- ' ); } else { $this->append_table_cell_break(); } break; } $this->close_a_paragraph(); if ( ! ( $is_closer || $this->innermost_block() instanceof WpHtmlApiBlockList ) ) { $this->enter_block( new WpHtmlApiBlockList( '' ) ); } break; case 'CENTER': case 'DETAILS': case 'DIALOG': case 'FIGURE': case 'FIGCAPTION': case 'FORM': case 'LEGEND': case 'NAV': case 'PLAINTEXT': case 'SEARCH': case 'SUMMARY': case 'XMP': 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': if ( $this->inside_table_cell ) { $this->append_table_cell_break(); $this->depths['PRE'] += $is_closer ? -1 : 1; break; } $this->close_a_paragraph(); if ( $is_closer ) { $this->flush_block(); } else { $this->enter_block( new WpHtmlApiBlockCode() ); $this->innermost_block()->infer_language( $processor ); } $this->depths['PRE'] += $is_closer ? -1 : 1; break; case 'OL': case 'UL': if ( $this->inside_table_cell ) { if ( $is_closer ) { $this->append_table_cell_break(); } $this->depths[ $token_name ] += $is_closer ? -1 : 1; break; } $this->close_a_paragraph(); if ( $is_closer ) { if ( $this->line_buffer->has_non_whitespace_content() ) { if ( $this->innermost_block() instanceof WpHtmlApiBlockList ) { $item = new WpHtmlApiBlockParagraph(); $item->append_line( $this->line_buffer ); } else { $item = $this->close_innermost_block(); } $this->innermost_block()->append( $item ); } $this->line_buffer = new WpHtmlApiLineBuffer(); $this->flush_block(); } else { if ( ! $this->line_buffer->has_non_whitespace_content() ) { $this->line_buffer = new WpHtmlApiLineBuffer(); } $type = $processor->get_attribute( 'type' ); if ( 'UL' === $token_name ) { $type = is_string( $type ) ? strtolower( trim( $type, " \t\f\r\n" ) ) : ''; $syntax_bullets = [ 'circle' => '*', 'disc' => '-', ]; $display_bullets = [ 'circle' => '•', 'disc' => '◦', 'square' => '▪', 'triangle' => '‣', 'dash' => '⁃', ]; $bullets = 'syntax' === $this->options->display_mode ? $syntax_bullets : $display_bullets; $style = $display_bullets[ $type ] ?? null; $bullet_values = array_values( $bullets ); $style = $style ?? $bullet_values[ $this->depths['UL'] % count( $bullet_values ) ]; } else { $style = in_array( $type, [ '1', 'a', 'A', 'i', 'I' ], true ) ? $type : [ '1', 'a', 'A', 'i', 'I' ][ $this->depths['OL'] % 5 ]; $start = $processor->get_attribute( 'start' ); $start = is_string( $start ) && strspn( $start, '0123456789' ) === strlen( $start ) ? (int) $start : 1; $style = "{$style}.{$start}"; } $this->enter_block( new WpHtmlApiBlockList( $style ) ); } $this->depths[ $token_name ] += $is_closer ? -1 : 1; break; case 'TABLE': $this->close_a_paragraph(); if ( $is_closer ) { $this->options->soft_line_wrap = $soft_limit; $this->flush_block(); } else { $this->options->soft_line_wrap = PHP_INT_MAX; $this->enter_block( new WpHtmlApiBlockTable() ); } break; case 'THEAD': $this->inside_table_head = ! $is_closer; break; case 'TBODY': case 'TFOOT': if ( ! $is_closer ) { $this->inside_table_head = false; } break; case 'TD': case 'TH': $table = $this->innermost_table(); if ( ! $table instanceof WpHtmlApiBlockTable ) { break; } if ( $is_closer ) { $table->append_table_cell( $this->line_buffer, $this->current_table_cell_is_header ); $this->line_buffer = new WpHtmlApiLineBuffer(); $this->inside_table_cell = false; $this->current_table_cell_is_header = false; } else { $this->line_buffer = new WpHtmlApiLineBuffer(); $this->inside_table_cell = true; $this->current_table_cell_is_header = $this->inside_table_head || 'TH' === $token_name; } break; case 'TR': $table = $this->innermost_table(); if ( ! $table instanceof WpHtmlApiBlockTable ) { break; } if ( $is_closer ) { $table->finish_row(); } else { $table->start_row( $this->inside_table_head ); } break; } } if ( $processor->paused_at_incomplete_token() || null !== $processor->get_last_error() ) { return ''; } while ( ! empty( $this->stack ) ) { $this->flush_block(); } return $this->output; } /** * Flush the innermost block. * * @return void */ private function flush_block() { $block = $this->close_innermost_block(); if ( null === $block || $block->is_empty() ) { return; } $parent = $this->innermost_block(); if ( $parent instanceof WpHtmlApiBlock ) { $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" ); } } /** * Close the current paragraph when text has accumulated. * * @return void */ private function close_a_paragraph() { if ( $this->inside_table_cell ) { $this->append_table_cell_break(); return; } if ( $this->innermost_block() instanceof WpHtmlApiBlockParagraph && $this->line_buffer->has_non_whitespace_content() ) { $this->flush_block(); } elseif ( $this->line_buffer->has_non_whitespace_content() ) { $paragraph = new WpHtmlApiBlockParagraph(); $paragraph->append_line( $this->line_buffer ); $this->enter_block( $paragraph ); $this->flush_block(); } $this->line_buffer = new WpHtmlApiLineBuffer(); } /** * Skip hidden or intentionally ignored HTML content. * * @param \WP_HTML_Processor $processor HTML processor. * * @return bool */ private function skip_hidden_content( \WP_HTML_Processor $processor ) { $should_skip = false; switch ( $processor->get_token_name() ) { case 'BUTTON': case 'DATALIST': case 'IFRAME': case 'INPUT': case 'OPTION': case 'PARAM': case 'SELECT': case 'SVG': case 'TEMPLATE': case 'TEXTAREA': case 'TITLE': $should_skip = true; break; } $hidden = $processor->get_attribute( 'hidden' ); if ( isset( $hidden ) && ! ( is_string( $hidden ) && 0 === strcasecmp( $hidden, 'until-found' ) ) ) { $should_skip = true; } $aria_hidden = $processor->get_attribute( 'aria-hidden' ); if ( is_string( $aria_hidden ) && 0 === strcasecmp( $aria_hidden, 'true' ) ) { $should_skip = true; } if ( ! $should_skip ) { return false; } if ( $processor->expects_closer() ) { $depth = $processor->get_current_depth(); while ( $processor->next_token() && $depth <= $processor->get_current_depth() ) { continue; } } return true; } /** * Handle block-level tokens that should be flattened inside table cells. * * @param string $token_name Current token name. * @param bool $is_closer Whether the token is a closer. * * @return bool */ private function handle_table_cell_block_token( string $token_name, bool $is_closer ): bool { unset( $is_closer ); if ( ! $this->inside_table_cell ) { return false; } if ( ! in_array( $token_name, [ 'ARTICLE', 'ASIDE', 'CENTER', 'DETAILS', 'DIALOG', 'DIV', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'HEADER', 'HGROUP', 'LEGEND', 'MAIN', 'NAV', 'P', 'PLAINTEXT', 'SEARCH', 'SECTION', 'SUMMARY', 'XMP', ], true ) ) { return false; } $this->append_table_cell_break(); return true; } /** * Append a logical line break inside the current table cell. * * @return bool */ private function append_table_cell_break(): bool { if ( ! $this->inside_table_cell || ! $this->line_buffer->has_non_whitespace_content() ) { return false; } $buffer = $this->line_buffer->raw_buffer(); if ( '' === $buffer || str_ends_with( $buffer, "\n" ) ) { return false; } $this->line_buffer->append_text( "\n" ); return true; } /** * Push a new block onto the stack. * * @param WpHtmlApiBlock $block Block to push. * * @return void */ private function enter_block( WpHtmlApiBlock $block ) { $this->stack[] = $block; } /** * Pop the innermost block from the stack. * * @return WpHtmlApiBlock|null */ private function close_innermost_block(): ?WpHtmlApiBlock { return array_pop( $this->stack ); } /** * Get the current innermost block. * * @return WpHtmlApiBlock|null */ private function innermost_block(): ?WpHtmlApiBlock { $stack_size = count( $this->stack ); return $stack_size > 0 ? $this->stack[ $stack_size - 1 ] : null; } /** * Get the innermost table block on the stack. * * @return WpHtmlApiBlockTable|null */ private function innermost_table(): ?WpHtmlApiBlockTable { for ( $index = count( $this->stack ) - 1; $index >= 0; --$index ) { if ( $this->stack[ $index ] instanceof WpHtmlApiBlockTable ) { return $this->stack[ $index ]; } } return null; } }