Dynamically add table of contents and add anchor based on heading innerHTML

I think you should use filters instead, and here are examples based on your code: (these should be added to your theme functions.php file)

  • Filter 1, to be hooked on the render_block_data hook:

    function my_render_block_data( $parsed_block ) {
        // Check whether it's a group block.
        if ( 'core/group' === $parsed_block['blockName'] ) {
            // Check whether the very first block is a heading block.
            if ( isset( $parsed_block['innerBlocks'][0] ) &&
                'core/heading' === $parsed_block['innerBlocks'][0]['blockName']
            ) {
                $block   = $parsed_block['innerBlocks'][0];
                $content = $block['innerContent'][0];
                $id      = sanitize_title( $content );
    
                global $toc_headings;
                $toc_headings = is_array( $toc_headings ) ? $toc_headings : array();
    
                // Add the heading to our global headings array, in the form of
                // `$toc_headings['<HTML id>'] = '<heading text>'`. But it's up to
                // you if you want to use another format.
                $toc_headings[ $id ] = wp_strip_all_tags( $content );
    
                // Add an `id` attribute to the heading tag.
                $new_content = preg_replace(
                    '/<h(\d)( |>)/',
                    '<h$1 id="toc-' . esc_attr( $id ) . '"$2',
                    $content
                );
                // Alternatively, you can add an anchor, e.g.
                //$anchor      = sprintf( '<a id="toc-%s"></a>', esc_attr( $id ) );
                //$new_content = $anchor . $content;
    
                // Modify the block's content.
                $parsed_block['innerBlocks'][0]['innerContent'][0] = $new_content;
            }
        }
    
        return $parsed_block;
    }
    
  • Filter 2, to be hooked on the the_content hook:

    function my_add_toc_headings( $content ) {
        global $toc_headings;
    
        if ( is_array( $toc_headings ) ) {
            $toc="<ol>";
    
            foreach ( $toc_headings as $id => $text ) {
                $toc .= sprintf(
                    '<li><a href="#toc-%s">%s</a></li>',
                    esc_attr( $id ),
                    esc_html( $text )
                );
            }
    
            $toc .= '</ol>';
    
            // Add the TOC at the top.
            $content = "$toc $content";
    
            // Reset the list.
            $toc_headings = array();
        }
    
        return $content;
    }
    

So with that, your while loop would be as simple as:

while ( have_posts() ) : the_post();
    add_filter( 'render_block_data', 'my_render_block_data' );
    add_filter( 'the_content', 'my_add_toc_headings' );

    the_content();

    remove_filter( 'render_block_data', 'my_render_block_data' );
    remove_filter( 'the_content', 'my_add_toc_headings' );
endwhile;

Note: I conditionally added the filters (or added them in the above way) so that the TOC stuff is only added for the above the_content() call. So for example, the REST API output would be left untouched.

Also, the above snippets were tried & tested working with WordPress v6.1.1 and the Twenty Twenty-Three theme.