Categories
Guides Resources WordPress

Shortcode for a Table of Contents in WordPress

I am trying to create a WP shortcode to auto-add a Table of Contents (when shortcode is entered, not for all pages)…

…I found somebody using this JS snippet (see attached) to do it for all pages…

So I am trying to add that JS specifically to the shortcode…

– Josh

I get messages like this fairly frequently. This one was from my brother, who wanted a table of contents shortcode that he could implement in his posts.

What he had put together from following along with the js snippet was basically everything he needed, but we’ll start from scratch to walk through the process.

WordPress Shortcodes Primer

The WordPress Shortcodes API makes it simple to add your own custom shortcodes. We’ll need: a callback function that handles the shortcode content, and a call to add_shortcode().

I prefer to add functionality like this in a custom plugin, but you could also add this to functions.php.

// Basic Shortcode Example
// add_shortcode( $shortcode, $callback );
// $callback( $attributes, $content )

add_shortcode( 'ijae-shortcode', 'ijae_shortcode_callback');

function ijae_shortcode_callback( $attributes = array(), $content = '') {
    $updatedContent = $content;

    // Do something to update the content here...

    return $updatedContent;
}

As you can see, it’s very simple to hook into the WordPress Shortcode API to craft your own custom shortcodes.

Call add_shortcode() to tell WordPress that your shortcode exists and what function to call when it occurs in a post or page. That function accepts any attributes defined with the shortcode tag, as well as any content enclosed by the tag.

Don’t worry about those for now, this is just the primer.

Including Custom Javascript & CSS in WordPress

We could implement this shortcode in PHP but parsing HTML feels more natural to me in Javascript.

You could output raw Javascript or manually include a JS file with each shortcode, but that’s clunky and adds unnecessary weight to your code. Your archives pages may have dozens of these shortcodes rendering at once and would include those script tags for each one.

That’s bonkers.

Instead: you have to tell WordPress about your files, their dependencies, and their locations.

// Enqueueing WordPress Assets
// wp_enqueue_script( $script_identifier = '', $script_url = '', $dependencies = array(), $version = false, $in_footer = false);

// The script we'll enqueue in a moment, don't copy this just yet.
wp_enqueue_script('ijae-toc', plugin_dir_url( __FILE__ ) . 'ijae-toc.js', array('jquery'));

// wp_enqueue_style( $script_identifier = '', $script_url = '', $dependencies = array(), $version = false, $media = 'all');

// We don't have custom css yet, but if we did it would look like this
wp_enqueue_style( 'ijae-toc', plugin_dir_url( __FILE__ ) . 'ijae-toc.css');

The wp_enqueue_script() and wp_enqueue_style() function look, and work, similarly. WordPress keeps track of which assets need to be included and, in general, which order they need to be included in.

But what happens if WordPress isn’t ready to add your item to its list? What about if the list is already output to the browser? How do you even know whether you’re too early, too late, or somewhere on time?

WordPress gives us a filter, wp_enqueue_scripts, so we don’t have to figure that out ourselves which is good because that’s a pretty complex problem to solve and I’m feeling lazy.

// Using wp_enqueue_scripts hook to enqueue assets properly.
// The following can be copied and pasted to your plugin files 
// modify to replace ijae_toc and ijae-toc with your own prefixes as needed, or change the second parameter to map to your asset URL
function ijae_toc_wp_enqueue_scripts_callback() {
    wp_enqueue_script('ijae-toc', plugin_dir_url( __FILE__ ) . 'ijae-toc.js', array('jquery'));

    wp_enqueue_style( 'ijae-toc', plugin_dir_url( __FILE__ ) . 'ijae-toc.css');
}
add_action( 'wp_enqueue_scripts', 'ijae_toc_wp_enqueue_scripts_callback' );

I find that most WordPress websites will eventually need custom JS or CSS, so keeping that code snippet handy will save you time in the future.

Javascript and CSS in WordPress Shortcodes

If we try to enqueue a script or style in our shortcode callback it may never work or, even worse, only work intermittently. You can usually get away with enqueueing Javascript only, since most themes support js tags in the HTML BODY as well as the HEAD, but there’s no reason to.

Why? Because the shortcode processing happens during the the_content hook, which happens after the HEAD tag has been output to the browser. Some themes don’t include JS before </BODY>, which makes this unreliable even for Javascript.

We have two options: include the assets on every page and define the Javascript and CSS so only the shortcodes are affected, or only include those assets when the shortcode is present. The method you choose is ultimately up to you but I prefer the latter method for performance reasons.

However: we will select the former solution since it’s more widely applicable for this example. It’s also important to avoid focusing on optimizations before you need them.

The easiest way to tell Javascript or CSS to only apply on certain elements is using a combination of CSS selectors and HTML markup. JQuery makes this even easier for us.

And, since most WordPress installations include jQuery by default, we can reasonably assume it’s available to us for free.

If you don’t have jQuery available on your site send me a message, I’m curious what everyone is up to.

<div class='ijae-toc-container'>
    <div class='ijae-toc'><ul class='ijae-toc-list'></ul></div>
    <div class='ijae-toc-content'>
        {POST_CONTENT}
    </div>
</div>

Creating that simple HTML structure allows us to tell our JS and CSS to only operate on elements under .ijae-toc-container.

Table of Contents – a WordPress Shortcode Example

At this point we have all the information and preparation we need, so it’s time to implement!

First: we need a Javascript function that will scan content, look for headings, add them to a list, and create anchors for quick access.

// ijae-toc.js
function ijae_toc_generate_toc(containerElement) {

    // Grab our container from the param or grab all containers if called without one
    var toc_container = containerElement || $('.ijae-toc-container');

    if (!toc_container.length) { return; }
    if (toc_container.length > 1) {
        // If we're dealing with many on a page we need to handle them individually
        // .each is a jQuery function for looping over a jQuery collection
        // https://api.jquery.com/jquery.each/
        toc_container.each(function (container) {
            ija_toc_generate_toc(container);
        });
        return;
    }

    // This is the list container, any element with class 'ijae-toc'
    var toc = $('.ijae-toc');

    // This is the list, what we'll append to
    var toc_list = toc.children('.ijae-toc-list');

    // Our content, we use .children() for performance since we know that
    // .ijae-toc-content is a direct child of .ijae-toc-container
    var toc_content = toc_container.children('.ijae-toc-content');

    // Finally, the actual headings found in the content.
    var subtitles = toc_content.find('h1, h2, h3, h4');


    // Loop through every heading
    for (var i = 0; i < subtitles.length; i++) {
        // Create the anchor for us to link to
        subtitles[i].setAttribute('id', `${subtitles[i].textContent.toLowerCase().replace(' ', '-')}`);

        // Create the link and append it to our list
        toc_list.append(`<li class="ijae-toc-list-item"><a href='#${subtitles[i].textContent.toLowerCase().replace(' ', '-')}' >${subtitles[i].textContent}</a></li>`);
    }
}

You’ll notice that it is pretty strict about the HTML structure used so you’re free to modify as necessary. This isn’t our final version so don’t copy and paste just yet, but this will work as-is.

Now we need to make sure it’s included and that the shortcode does what it’s supposed to.

// In functions.php, or your plugin files

function ijae_toc_shortcode_callback($attributes = [], $content = '') {
    return "<div class='ijae-toc-container'><div class='ijae-toc'><ul class='ijae-toc-list'></ul></div><div class='ijae-toc-content'>$content</div></div>";
}
add_shortcode('ijae-toc', 'ijae_toc_shortcode_callback');


function ijae_toc_wp_enqueue_scripts_callback() {
    wp_enqueue_script('ijae-toc', plugin_dir_url( __FILE__ ) . 'ijae-toc.js', array('jquery'));
}
add_action('wp_enqueue_scripts', 'ijae_toc_wp_enqueue_scripts_callback');

We don’t have custom styles yet so it’s pretty straightforward. We just have to register the shortcode with a callback and enqueue our assets.

Wrapping Up Our Shortcode

We’ve split the code into a Javascript portion and a PHP portion, and now we need to include them. This is the main reason I prefer creating a plugin.

Creating a ToC WordPress Shortcode

If you’re new to creating plugins check out my WordPress Plugin Development Primer. I put everything you’ll need to understand to do this in that guide.

As a refresher here’s what we’ll need to do:

  • Name our plugin, choose a slug
  • Create the plugin directory
  • Create the main plugin file
  • Add a WordPress Plugin Header
  • Create a Javascript file
  • Enqueue the Javascript file
  • Add the shortcode with callback

I’m naming my plugin IJA Examples - Table of Contents and choosing the unique slug ijae-toc. You’ve seen that through the post already, so it should be familiar.

<?php
/**
 * {wp-plugins}/ijae-toc/ijae-toc.php
 *
 * WordPress Plugin Header
 * Plugin Name: IJA Eamples - Table of Contents
 * Plugin URI: https://itsjustanthony.com
 */

function ijae_toc_shortcode_callback($attributes = [], $content = '') {
    return "
<div class='ijae-toc-container'>
    <div class='ijae-toc'>
        <ul class='ijae-toc-list'></ul>
    </div>
    <div class='ijae-toc-content'>$content</div>
</div>";
}
add_shortcode('ijae-toc', 'ijae_toc_shortcode_callback');


function ijae_toc_wp_enqueue_scripts_callback() {
    wp_enqueue_script('ijae-toc', plugin_dir_url( __FILE__ ) . 'ijae-toc.js', array('jquery'));
}
add_action('wp_enqueue_scripts', 'ijae_toc_wp_enqueue_scripts_callback');

The only code I haven’t talked about yet is the call to plugin_dir_url(). It returns the URL to the plugins directory. I passed __FILE__ as a parameter to get the URL to the current directory.

/**
 * {wp-plugins}/ijae-toc/ijae-toc.js
 *
 * (function($){ ... })(jQuery);
 */
(function($) {
    function ijae_toc_generate_toc(containerElement) {

        // Grab our container from the param or grab all containers if called without one
        var toc_container = containerElement || $('.ijae-toc-container');

        if (!toc_container.length) { return; }
        if (toc_container.length > 1) {
            // If we're dealing with many on a page we need to handle them individually
            // .each is a jQuery function for looping over a jQuery collection
            // https://api.jquery.com/jquery.each/
            toc_container.each(function (container) {
                ija_toc_generate_toc(container);
            });
            return;
        }

        // This is the list container, any element with class 'ijae-toc'
        var toc = $('.ijae-toc');

        // This is the list, what we'll append to
        var toc_list = toc.children('.ijae-toc-list');

        // Our content, we use .children() for performance since we know that
        // .ijae-toc-content is a direct child of .ijae-toc-container
        var toc_content = toc_container.children('.ijae-toc-content');

        // Finally, the actual headings found in the content.
        var subtitles = toc_content.find('h1, h2, h3, h4');


        // Loop through every heading
        for (var i = 0; i < subtitles.length; i++) {
            // Create the anchor for us to link to
            subtitles[i].setAttribute('id', `${subtitles[i].textContent.toLowerCase().replace(' ', '-')}`);

            // Create the link and append it to our list
            toc_list.append(`<li class="ijae-toc-list-item"><a href='#${subtitles[i].textContent.toLowerCase().replace(' ', '-')}' >${subtitles[i].textContent}</a></li>`);
        }

    }

    // When the DOM is ready to operate on, generally a reliable place to start working with DOM elements
    $(document).ready(function(){

        // Grab our container
        var toc_container = $('.ijae-toc-container');

        // .length will be 0 if it's not found, 0 evaluates to false
        if (toc_container.length) {

            // Once we know we have at least one toc container it's time to operate on it.
            ijae_toc_generate_toc(toc_container);
        }
    });
})(jQuery);

If you’re not familiar with Javascript closures you should read more about them. You will end up using more of them some day.

Adding a Table of Contents Shortcode with functions.php

You will have to add files to, or otherwise modify, your theme. When you update your theme you may have to re-do this. If that sounds annoying: scroll back up and make a plugin.

Otherwise: you’ll be using the same code from above. I’ll paste it here as well for reference.

/**
 * Created by anthony on 1/8/20.
 */
(function($) {
    function ijae_toc_generate_toc(containerElement) {

        // Grab our container from the param or grab all containers if called without one
        var toc_container = containerElement || $('.ijae-toc-container');

        if (!toc_container.length) { return; }
        if (toc_container.length > 1) {
            // If we're dealing with many on a page we need to handle them individually
            // .each is a jQuery function for looping over a jQuery collection
            // https://api.jquery.com/jquery.each/
            toc_container.each(function (container) {
                ija_toc_generate_toc(container);
            });
            return;
        }

        // This is the list container, any element with class 'ijae-toc'
        var toc = $('.ijae-toc');

        // This is the list, what we'll append to
        var toc_list = toc.children('.ijae-toc-list');

        // Our content, we use .children() for performance since we know that
        // .ijae-toc-content is a direct child of .ijae-toc-container
        var toc_content = toc_container.children('.ijae-toc-content');

        // Finally, the actual headings found in the content.
        var subtitles = toc_content.find('h1, h2, h3, h4');


        // Loop through every heading
        for (var i = 0; i < subtitles.length; i++) {
            // Create the anchor for us to link to
            subtitles[i].setAttribute('id', `${subtitles[i].textContent.toLowerCase().replace(' ', '-')}`);

            // Create the link and append it to our list
            toc_list.append(`<li class="ijae-toc-list-item"><a href='#${subtitles[i].textContent.toLowerCase().replace(' ', '-')}' >${subtitles[i].textContent}</a></li>`);
        }

    }

    // When the DOM is ready to operate on, generally a reliable place to start working with DOM elements
    $(document).ready(function(){

        // Grab our container
        var toc_container = $('.ijae-toc-container');

        // .length will be 0 if it's not found, 0 evaluates to false
        if (toc_container.length) {

            // Once we know we have at least one toc container it's time to operate on it.
            ijae_toc_generate_toc(toc_container);
        }
    });
})(jQuery);

And you’ll add this to functions.php

function ijae_toc_shortcode_callback($attributes = [], $content = '') {
    return "
<div class='ijae-toc-container'>
    <div class='ijae-toc'>
        <ul class='ijae-toc-list'></ul>
    </div>
    <div class='ijae-toc-content'>$content</div>
</div>";
}
add_shortcode('ijae-toc', 'ijae_toc_shortcode_callback');


function ijae_toc_wp_enqueue_scripts_callback() {
    wp_enqueue_script('ijae-toc', plugin_dir_url( __FILE__ ) . 'ijae-toc.js', array('jquery'));
}
add_action('wp_enqueue_scripts', 'ijae_toc_wp_enqueue_scripts_callback');

And then you’re done!

Leave a Reply

Your email address will not be published. Required fields are marked *