diff --git a/plugin.php b/plugin.php index c80abec..cd5814a 100644 --- a/plugin.php +++ b/plugin.php @@ -15,6 +15,8 @@ namespace MToensing\SimpleTOC; +use WP_HTML_Token; + require_once __DIR__ . '/simpletoc-admin-settings.php'; require_once __DIR__ . '/simpletoc-class-headline-ids.php'; @@ -155,66 +157,58 @@ function ( $toc_plugins ) { */ function simpletoc_add_ids_to_content( $content ) { - $blocks = parse_blocks( $content ); + // Return early if the content does not contain a simpletoc shortcode. + $maybe_shortcode_result = preg_match( '/\[simpletoc ([^\]]*)\]/m', $content, $matches ); + if ( ! $maybe_shortcode_result ) { + return $content; + } - $blocks = add_ids_to_blocks_recursive( $blocks ); + // Add IDs to the headings of the content. + $content = add_ids_to_blocks( $content ); - $content = serialize_blocks( $blocks ); + // Render the Table of Contents block. + $content = simpletoc_render_toc( $content ); return $content; } -add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 1 ); +// Run late, but before toc is rendered as to be able to track and add IDs to the headings. +add_filter( 'the_content', __NAMESPACE__ . '\simpletoc_add_ids_to_content', 100 ); /** - * Recursively adds IDs to the headings of a nested block structure. + * Renders the Table of Contents block. * - * @param array $blocks The blocks to add IDs to. - * @return array The blocks with IDs added to their headings + * @param string $content The content to render the Table of Contents block for. + * @param bool $return_toc_html Whether to return the TOC HTML only, without the content included. + * @param array $attributes The attributes of the Table of Contents block. + * @param array $wrapper_attrs The wrapper attributes of the Table of Contents block. + * @return string The rendered Table of Contents block. */ -function add_ids_to_blocks_recursive( $blocks ) { +function simpletoc_render_toc( $content, $return_toc_html = false, $attributes = array(), $wrapper_attrs = array() ) { + if ( ! $return_toc_html ) { + $maybe_shortcode_result = preg_match( '/\[simpletoc ([^\]]*)\]/m', $content, $matches ); - $supported_blocks = array( - 'core/heading', - 'generateblocks/text', - 'generateblocks/headline', - ); - - /** - * Filter to add supported blocks for IDs. - * - * @param array $supported_blocks The array of supported blocks. - */ - $supported_blocks = apply_filters( 'simpletoc_supported_blocks_for_ids', $supported_blocks ); - - // Need two separate instances so that IDs aren't double coubnted. - $inner_html_id_instance = new SimpleTOC_Headline_Ids(); - $inner_content_id_instance = new SimpleTOC_Headline_Ids(); - - foreach ( $blocks as &$block ) { - if ( isset( $block['blockName'] ) && in_array( $block['blockName'], $supported_blocks, true ) && isset( $block['innerHTML'] ) && isset( $block['innerContent'] ) && isset( $block['innerContent'][0] ) ) { - $block['innerHTML'] = add_anchor_attribute( $block['innerHTML'], $inner_html_id_instance ); - $block['innerContent'][0] = add_anchor_attribute( $block['innerContent'][0], $inner_content_id_instance ); - } elseif ( isset( $block['attrs']['ref'] ) ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedElseif - // search in reusable blocks (this is not finished because I ran out of ideas.) - // $reusable_block_id = $block['attrs']['ref']; - // $reusable_block_content = parse_blocks(get_post($reusable_block_id)->post_content);. - } elseif ( ! empty( $block['innerBlocks'] ) ) { - // search in groups. - $block['innerBlocks'] = add_ids_to_blocks_recursive( $block['innerBlocks'] ); + if ( ! $maybe_shortcode_result ) { + return $content; } + + // Decode HTML entities and convert curly quotes to straight quotes for valid JSON. + $json_string = html_entity_decode( $matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + // Convert curly quotes (left and right) back to straight quotes for valid JSON. + $json_string = str_replace( array( "\u{201C}", "\u{201D}", "\u{2018}", "\u{2019}" ), array( '"', '"', "'", "'" ), $json_string ); + + // Extract out attributes and wrapper attributes from the shortcode. + preg_match( '/attributes=\'([^\']*)\'/', $json_string, $attributes_match ); + preg_match( '/wrapper_attrs=\'([^\']*)\'/', $json_string, $wrapper_attrs_match ); + + $attributes = json_decode( $attributes_match[1], true ); + $wrapper_attrs = json_decode( $wrapper_attrs_match[1], true ); } - return $blocks; -} + if ( empty( $attributes ) ) { + return $content; + } -/** - * Renders a Table of Contents block for a post - * - * @param array $attributes An array of attributes for the Table of Contents block. - * @return string The HTML output for the Table of Contents block - */ -function render_callback_simpletoc( $attributes ) { $is_backend = defined( 'REST_REQUEST' ) && REST_REQUEST && 'edit' === filter_input( INPUT_GET, 'context' ); $title_text = $attributes['title_text'] ? esc_html( trim( $attributes['title_text'] ) ) : __( 'Table of Contents', 'simpletoc' ); $alignclass = ! empty( $attributes['align'] ) ? 'align' . $attributes['align'] : ''; @@ -223,31 +217,127 @@ function render_callback_simpletoc( $attributes ) { $wrapper_enabled = apply_filters( 'simpletoc_wrapper_enabled', false ) || true === (bool) get_option( 'simpletoc_wrapper_enabled', false ) || true === (bool) get_option( 'simpletoc_accordion_enabled', false ); - $wrapper_attrs = get_block_wrapper_attributes( array( 'class' => 'simpletoc' ) ); - $pre_html = ( ! empty( $class_name ) || $wrapper_enabled || $attributes['accordion'] || $attributes['wrapper'] ) ? '
' : ''; - $post_html = ( ! empty( $class_name ) || $wrapper_enabled || $attributes['accordion'] || $attributes['wrapper'] ) ? '
' : ''; + $pre_html = ( ! empty( $class_name ) || $wrapper_enabled || $attributes['accordion'] || $attributes['wrapper'] ) ? '
' : ''; + $post_html = ( ! empty( $class_name ) || $wrapper_enabled || $attributes['accordion'] || $attributes['wrapper'] ) ? '
' : ''; - $post = get_post(); - $blocks = ! is_null( $post ) && ! is_null( $post->post_content ) ? parse_blocks( $post->post_content ) : ''; + $headings = filter_headings( $content ); + // $headings = simpletoc_add_pagenumber( $blocks, $headings ); + $toc_html = generate_toc( $headings, $attributes ); - $headings = array_reverse( filter_headings_recursive( $blocks ) ); - $headings = simpletoc_add_pagenumber( $blocks, $headings ); - $headings_clean = array_map( 'trim', $headings ); - $toc_html = generate_toc( $headings_clean, $attributes ); + if ( empty( $headings ) ) { + $toc_html .= get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found. Please save your post and ensure headings are present.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ) ); + } - if ( empty( $blocks ) ) { - return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No blocks found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ) ); + if ( empty( $toc_html ) ) { + $toc_html .= get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Check minimal and maximum level block settings.', 'simpletoc' ) ); } - if ( empty( $headings_clean ) ) { - return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Save or update post first.', 'simpletoc' ) ); + $toc_html = $pre_html . $toc_html . $post_html; + + if ( $return_toc_html ) { + return $toc_html; } - if ( empty( $toc_html ) ) { - return get_empty_blocks_message( $is_backend, $attributes, $title_level, $alignclass, $title_text, __( 'No headings found.', 'simpletoc' ), __( 'Check minimal and maximum level block settings.', 'simpletoc' ) ); + // Replace the [simpletoc] block with the rendered Table of Contents block. + $content = str_replace( $matches[0], $toc_html, $content ); + + return $content; +} + +/** + * Recursively adds IDs to the headings of a nested block structure. + * + * @param string $content The content to add IDs to. + * @return array The blocks with IDs added to their headings + */ +function add_ids_to_blocks( $content ) { + + $dom = new \DOMDocument(); + try { + @$dom->loadHTML( '' . "\n" . $content, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); + } catch ( \Exception $e ) { + return $content; } - return $pre_html . $toc_html . $post_html; + // use xpath to select the Heading html tags. + $xpath = new \DOMXPath( $dom ); + $tags = $xpath->evaluate( '//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]' ); + + foreach ( $tags as $tag ) { + // check if the heading has the SimpleTOC excluded class. + $tag_classes = $tag->getAttribute( 'class' ); + if ( $tag_classes ) { + if ( strpos( $tag_classes, 'simpletoc-excluded' ) !== false ) { + continue; + } + } + + /** + * Filter to skip headings inside container blocks. + * + * @param bool $skip_in_wrapper Whether to skip headings inside container blocks. + * @return bool The filtered value. + */ + $skip_in_wrapper = apply_filters( 'simpletoc_skip_in_wrapper', true ); + if ( strpos( $tag_classes, 'simpletoc-include' ) !== false ) { + // If someone has added the simpletoc-include class, then don't skip it, regardless of wrapper. + $skip_in_wrapper = false; + } + if ( $skip_in_wrapper ) { + // Try to get parent tag. + $parent_tag = $tag->parentNode; + if ( $parent_tag && isset( $parent_tag->tagName ) ) { + if ( in_array( strtolower( strtolower( $parent_tag->tagName ) ), array( 'div', 'section', 'article', 'main', 'header', 'footer' ), true ) ) { + continue; + } + } + } + + // Set the ID attribute of the headline anchor if it doesn't exist. + $tag_id = $tag->getAttribute( 'id' ); + $headline_anchor = SimpleTOC_Headline_Ids::get_headline_anchor( $tag->ownerDocument->saveHTML( $tag ), true ); + if ( empty( $tag_id ) ) { + $tag->setAttribute( 'id', $headline_anchor ); + } + } + + return $dom->saveHTML(); +} + +/** + * Renders a Table of Contents block for a post + * + * @param array $attributes An array of attributes for the Table of Contents block. + * @return string The HTML output for the Table of Contents block + */ +function render_callback_simpletoc( $attributes ) { + $wrapper_attrs = get_block_wrapper_attributes( array( 'class' => 'simpletoc' ) ); + $return = sprintf( + '[simpletoc attributes=\'%s\' wrapper_attrs=\'%s\']', + wp_json_encode( $attributes ), + wp_json_encode( $wrapper_attrs ) + ); // Placeholder to be replaced in `the_content` filters later. + if ( defined( 'REST_REQUEST' ) && REST_REQUEST && 'edit' === filter_input( INPUT_GET, 'context' ) ) { + // We're in the block editor rendering a server-side preview. + // Strip out the simple toc block from the content. + $post = get_post(); + if ( ! $post ) { + return $return; + } + $post_content = $post->post_content; + + //