Table of Content

Add Table of Contents in your blog posts.

PHP
/**
 * Snippet Name:     Table of Contents
 * Snippet Author:   coding-bunny.com
 * Description:      Add Table of Contents in your blog posts.
 * Version:          1.0.0
 * 
 * USAGE EXAMPLES:
 * [cbtoc title="Table of content" levels="h2,h3,h4" toggle_view="yes" initial_view="show" exclude_titles="Introduction,Summary"]
 */

if (!defined('ABSPATH')) exit;

add_shortcode('cbtoc', function($atts) {
    $atts = shortcode_atts([
        'levels'        => 'h2,h3',
        'title'         => 'Table of Contents',
        'toggle_view'   => 'no',
        'initial_view'  => 'show',
        'exclude_titles'=> '',
    ], $atts, 'cbtoc');

    $allowed_levels = ['h1','h2','h3','h4','h5','h6'];
    $requested_levels = array_map('trim', explode(',', strtolower($atts['levels'])));
    $levels = array_intersect($allowed_levels, $requested_levels);
    $exclude_titles = array_filter(array_map('trim', explode(',', $atts['exclude_titles'])));
    $exclude_titles_lc = array_map('mb_strtolower', $exclude_titles);

    if (empty($levels)) {
        return '<div class="cbtoc-error">No valid heading level specified for the Table of Contents.</div>';
    }

    global $post;
    if (empty($post) || empty($post->post_content)) return '';

    $content = $post->post_content;
    $headings = [];
    $pattern = '/<(' . implode('|', $levels) . ')([^>]*)>(.*?)<\/\1>/i';

    preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);

    if (empty($matches)) {
        return '<div class="cbtoc-empty">No headings found.</div>';
    }

    foreach($matches as $match) {
        $tag = strtolower($match[1]);
        $title = strip_tags($match[3]);
        if (in_array(mb_strtolower(trim($title)), $exclude_titles_lc, true)) {
            continue;
        }
        $slug = sanitize_title($title);
        $level = intval(substr($tag, 1));
        if (!preg_match('/id=["\'](.+?)["\']/', $match[2], $id_match)) {
            $anchor = $slug;
            $content = preg_replace(
                '/<' . $tag . $match[2] . '>' . preg_quote($match[3], '/') . '<\/' . $tag . '>/i',
                '<' . $tag . $match[2] . ' id="' . esc_attr($anchor) . '">' . $match[3] . '</' . $tag . '>',
                $content, 1
            );
        } else {
            $anchor = esc_attr($id_match[1]);
        }
        $headings[] = [
            'title' => $title,
            'anchor' => $anchor,
            'level' => $level,
        ];
    }

    if (empty($headings)) {
        return '<div class="cbtoc-empty">No headings found.</div>';
    }

    $min_level = min(array_column($headings, 'level'));
    $unique_id = 'cbtoc-' . uniqid();

    $toggle_view  = strtolower($atts['toggle_view']) === 'yes';
    $initial_view = strtolower($atts['initial_view']) === 'hide' ? 'hide' : 'show';
    $show_style   = ($toggle_view && $initial_view === 'hide') ? 'display:none;' : '';

    $toc  = '<nav class="cbtoc-pro" aria-label="Table of Contents" id="' . esc_attr($unique_id) . '">';
    $toc .= '<div class="cbtoc-title-wrap">';
    $toc .= '<span class="cbtoc-title">' . esc_html($atts['title']) . '</span>';
    if ($toggle_view) {
        $toggle_label = ($initial_view === 'hide') ? '+' : '';
        $toc .= '<button type="button" class="cbtoc-toggle-btn" aria-expanded="' . (($initial_view==='show')?'true':'false') . '" aria-controls="' . esc_attr($unique_id) . '-list">' . esc_html($toggle_label) . '</button>';
    }
    $toc .= '</div>';
    $toc .= '<div class="cbtoc-contents" id="' . esc_attr($unique_id) . '-list" style="' . $show_style . '">';
    $toc .= cbtoc_build_nested_ul($headings, $min_level);
    $toc .= '</div>';
    $toc .= '</nav>';

    $toc .= '<style>
.cbtoc-pro {
    background: #f9f9fc;
    border: 1px solid #e0e5ee;
    border-radius: 4px;
    padding: 20px;
    margin-bottom: 20px;
    max-width: 100%;
}
.cbtoc-pro .cbtoc-title-wrap {
    display: flex;
    align-items: center;
    justify-content: space-between;
}
.cbtoc-pro .cbtoc-title {
    font-size: 16px;
    font-weight: bold;
    color: #142520;
    border-left: 4px solid #177efa;
    padding-left: 10px;
}
.cbtoc-pro .cbtoc-toggle-btn {
    margin-left: 10px;
    border: none;
    background: none;
    font-size: 20px;
    font-weight: bold;
    color: #177efa;
    cursor: pointer;
    line-height: 1;
    padding: 0 3px 0 0;
    align-self: flex-end;
}
.cbtoc-pro .cbtoc-item {
    margin: 5px 0;
    font-size: 14px;
}
.cbtoc-pro .cbtoc-level-2 { margin-left: 0px; font-weight: bold;}
.cbtoc-pro .cbtoc-level-3 { margin-left: 10px; font-weight: normal;}
.cbtoc-pro .cbtoc-level-4 { margin-left: 15px;}
.cbtoc-pro .cbtoc-level-5 { margin-left: 20px;}
.cbtoc-pro .cbtoc-level-6 { margin-left: 25px;}
.cbtoc-pro a {
    color: #142520;
    text-decoration: none;
}
.cbtoc-pro a:hover {
    color: #177efa;
}
.cbtoc-list,
.cbtoc-list ul {
    padding-left: 20px;
    list-style-type: disc !important;
    margin-left: 0;
}
</style>
';
    if ($toggle_view) {
        $toc .= '<script>
(function(){
    var tocNav = document.getElementById("' . esc_js($unique_id) . '");
    if (!tocNav) return;
    var btn = tocNav.querySelector(".cbtoc-toggle-btn");
    var contents = tocNav.querySelector(".cbtoc-contents");
    if (!btn || !contents) return;
    btn.addEventListener("click", function() {
        var isOpen = contents.style.display !== "none";
        contents.style.display = isOpen ? "none" : "";
        btn.textContent = isOpen ? "+" : "–";
        btn.setAttribute("aria-expanded", (!isOpen).toString());
    });
})();
</script>';
    }

    return $toc;
});

function cbtoc_build_nested_ul($headings, $base_level) {
    $toc = '<ul class="cbtoc-list">';
    $prev_level = $base_level;
    $open_uls = 0;

    foreach ($headings as $heading) {
        $curr_level = $heading['level'];
        while ($curr_level > $prev_level) {
            $toc .= '<ul class="cbtoc-list">';
            $open_uls++;
            $prev_level++;
        }
        while ($curr_level < $prev_level) {
            $toc .= '</ul>';
            $open_uls--;
            $prev_level--;
        }
        $toc .= '<li class="cbtoc-item cbtoc-level-' . $curr_level . '"><a href="#' . esc_attr($heading['anchor']) . '">' . esc_html($heading['title']) . '</a></li>';
    }
    while ($open_uls > 0) {
        $toc .= '</ul>';
        $open_uls--;
    }
    $toc .= '</ul>';
    return $toc;
}

How To Implement This Solution?

Leave a Reply