Skip to content

Instantly share code, notes, and snippets.

@kingkool68
Created Sep 26, 2020
Embed
What would you like to do?

Table of Contents WordPress Plugin

This WordPress plugin creates a table of contents based on the heading structure. It automatically adds anchor links for every heading.

Usage

Use the f1_toc() function to render the table of contents with markup. An optional paramater called limit can be used to only show headings up to a certain level. Example:

// Only show <H1> and <H2> headings in the table of contents
f1_toc( 2 );

Use get_f1_toc() to get an array of heading objects to do whatever you want with. This is used by f1_toc().

// Sample structure returned by get_f1_toc()

Array
(
    [0] => stdClass Object
        (
            [anchor] => this-is-a-headline
            [text] => This Is A Headline
            [html] => <h1>This Is A Headline</h1>
            [opening_tag] => <h1>
            [level] => 1
        )
)

Extending

CSS

There are no default styles set by this plugin. Styling is up to you.

Reccomended styling for deep table of contents:

<style>
.f1-toc li {
	list-style-type:decimal;
}
.f1-toc li li {
	list-style-type:upper-alpha;
}
.f1-toc li li li {
	list-style-type:upper-roman;
}
.f1-toc li li li li {
	list-style-type:lower-alpha;
}
.f1-toc li li li li li {
	list-style-type:lower-roman;
}
.f1-toc li li li li li li {
	list-style-type:decimal-leading-zero;
}
</style>

Filter Hooks

f1_toc_anchor_text lets you change the text within the anchor link for each heading. By default, the anchor text is an empty string

// Make anchor links be clickable '#' just before each heading.

function add_toc_anchor_text( $text ) {
	return '#';
}
add_filter( 'f1_toc_anchor_text', 'add_toc_anchor_text' );

f1_toc_headers lets you modify the list of headers before they are about to be displayed. Helpful if you want to remove items from the Table of Contents.

Parameters:

  • $text - Anchor text to be changed. Default = ''

Examples

Add the table of contents just before the first heading like Wikipedia does

// Add the table of contents just before the first heading like Wikipedia does
function f1_toc_the_content( $content ) {
	if( is_single() && function_exists( 'f1_toc' ) ) {
		if( $toc = f1_toc() ) {
			$content = preg_replace( '/<h(\d)/im', $toc . "\n\n<h$1", $content, 1 );
		}
	}
	return $content;
}
add_filter( 'the_content', 'f1_toc_the_content' );
<?php
/*
* Plugin Name: F1 Table of Contents
* Version: 0.1.4
* Description:
* Author: Forum One, Russell Heimlich
* GitHub Plugin URI: https://github.com/forumone/f1-table-of-contents
*/
class F1_Table_Of_Contents {
// Properties
/**
* Holds objects with information about each heading.
* @var array
*/
public $headings = array();
/**
* Collection of strings that need to be found and replaced.
* @var array
*/
public $find = array();
/**
* Collection of strings that are replacements to be found.
* @var array
*/
public $replace = array();
/**
* Collection of integers representing $header indexes that have already been processed during build_html_tree()
* @var array
*/
public $html_tree_processed = array();
// Methods
public function __construct() {}
/**
* Sets up hooks this class uses.
*/
public function setup() {
add_filter( 'the_content', array( $this, 'the_content' ), 100 ); // run after shortcodes are interpretted (level 10)
}
/**
* Replaces opening heading tags to add an anchor element that can be linked to.
* @param string $the_content The post content to be modified.
* @return string The modified content.
*/
public function the_content( $the_content ) {
if( !is_singular() ) {
return $the_content;
}
$this->get_headings( $the_content );
return $this->mb_find_replace( $the_content );
}
/**
* Extract heading information from a string of text.
* @param string $text Text with headings in it to be extracted.
* @return object Return information about the headings extracted.
*/
public function get_headings( $text ) {
$matches = array();
$toc_anchor_text = apply_filters( 'f1_toc_anchor_text', '' ); // If you want to add text within the anchor link then hook into this filter.
if( preg_match_all( '/(<h([1-6]{1})[^>]*>).*<\/h\2>/msuU', $text, $matches, PREG_SET_ORDER ) ) {
/*
$matches[x][0] - Full HTML heading, <h1>Hello World!</h1>
$matches[x][1] - Opening headling tag, <h1> or <h2> etc.
$matches[x][2] - Numeric heading level, 1 or 2
*/
$output = array();
for( $i = 0; $i < count( $matches ); $i++ ) {
// Get anchor and add to find and replace arrays
$anchor = $this->url_anchor_target( $matches[ $i ][0] );
$this->find[] = $matches[ $i ][0];
$this->replace[] = str_replace(
array(
$matches[ $i ][1], // Start of heading
'</h' . intval( $matches[ $i ][2] ) . '>', // End of heading
),
array(
$matches[ $i ][1] . '<a id="' . esc_attr( $anchor ) . '" href="#' . esc_attr( $anchor ) .'" class="toc-anchor">' . $toc_anchor_text . '</a>',
'</h' . intval( $matches[ $i ][2] ) . '>',
),
$matches[ $i ][0]
);
$output[] = (object) array(
'anchor' => $anchor,
'text' => strip_tags( $matches[ $i ][0] ),
'html' => $matches[ $i ][0],
'opening_tag' => $matches[ $i ][1],
'level' => intval($matches[ $i ][2]),
);
}
$this->headings = $output;
return $output;
}
}
/**
* Convert a string to a hypenated slug.
* @param string $title The string to slugify.
* @return string Slugified version of the string.
*/
private function url_anchor_target( $title = '' ) {
$return = false;
if ( $title ) {
$return = trim( strip_tags( $title ) );
// Replace newlines with spaces (eg when headings are split over multiple lines)
$return = str_replace( array("\r", "\n", "\n\r", "\r\n"), ' ', $return );
$return = sanitize_file_name( $return );
$return = strtolower( $return );
}
return $return;
}
/**
* Multibyte-aware find and replace.
* @param string $string String to be searched
* @return string Modified string
*/
private function mb_find_replace( &$string = '' ) {
// Check if multibyte strings are supported
$find = $this->find;
$replace = $this->replace;
if( function_exists( 'mb_strpos' ) ) {
for( $i = 0; $i < count( $find ); $i++ ) {
$string =
mb_substr( $string, 0, mb_strpos( $string, $find[ $i ] ) ) . // Everything before $find
$replace[ $i ] . // Its replacement
mb_substr( $string, mb_strpos( $string, $find[ $i ] ) + mb_strlen( $find[ $i ] ) ) // Everything after $find
;
}
}
else {
for( $i = 0; $i < count( $find ); $i++ ) {
$string = substr_replace(
$string,
$replace[ $i ],
strpos( $string, $find[ $i ] ),
strlen( $find[ $i ] )
);
}
}
return $string;
}
public function build_html_tree( $headers, $index = 0, $depth = 0 ) {
if( in_array( $index, $this->html_tree_processed ) ) {
return '';
}
$tree = '';
if( $depth === 0 ) {
$depth++;
foreach( $headers as $i => $header ) {
$tree .= $this->build_html_tree( $headers, $i, $depth );
}
if( $tree ) {
$tree = '<ol class="f1-toc">' . $tree . '</ol>';
}
return $tree;
}
$header = $headers[ $index ];
$next_header = false;
if( isset( $headers[ $index + 1 ] ) ) {
$next_header = $headers[ $index + 1 ];
}
$this->html_tree_processed[] = $index;
$tree .= '<li>';
$tree .= '<a href="#' . $header->anchor . '">' . $header->text . '</a>';
if( $next_header && $header->level < $next_header->level ) {
$node = $this->build_html_tree( $headers, $index + 1, $depth++ );
if( $node ) {
$tree .= '<ol>' . $node . '</ol>';
}
}
$tree .= '</li>';
if( $next_header && $header->level == $next_header->level ) {
$node = $this->build_html_tree( $headers, $index + 1, $depth++ );
if( $node ) {
$tree .= $node;
}
}
return $tree;
}
}
global $f1_table_of_contents;
$f1_table_of_contents = new F1_Table_Of_Contents();
$f1_table_of_contents->setup();
/* Helper Functions */
/**
* Gets header data from the content.
* @return array List of objects containing header data
*/
function get_f1_toc() {
global $f1_table_of_contents;
return $f1_table_of_contents->headings;
}
/**
* Generate HTML markup based on the heading structure of the post content with anchor links to different headers.
* @param integer $limit The deepest level of headings to show
* @return string HTML output
*/
function f1_toc( $limit = 6 ) {
global $f1_table_of_contents;
$raw_headers = get_f1_toc();
if( !$raw_headers ) {
return;
}
$headers = [];
foreach( $raw_headers as $header ) {
if( $header->level <= $limit ) {
$headers[] = $header;
}
}
$headers = apply_filters( 'f1_toc_headers', $headers );
// Reset the array that keeps track of which headers have already been processed
$f1_table_of_contents->html_tree_processed = array();
return $f1_table_of_contents->build_html_tree( $headers );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment