DEV3 Technologies home

Reading time and automatic table of contents

Filter the_content to add heading anchors, a linked table of contents and a words-per-minute reading time estimate.

WordPressUtilitiesthe-contenttocreading-timefilter

Code

<?php
/**
 * Reading time and table of contents.
 *
 * @package Acme_Theme
 */

defined( 'ABSPATH' ) || exit;

function acme_reading_time( int $post_id = 0 ): int {
	$content = get_post_field( 'post_content', $post_id ?: get_the_ID() );
	$words   = str_word_count( wp_strip_all_tags( strip_shortcodes( (string) $content ) ) );
	return max( 1, (int) ceil( $words / 220 ) );
}

function acme_add_toc( string $content ): string {
	if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
		return $content;
	}

	$items = array();
	$used  = array();

	$content = preg_replace_callback(
		'/<h([23])([^>]*)>(.*?)<\/h\1>/is',
		static function ( array $m ) use ( &$items, &$used ): string {
			$text = wp_strip_all_tags( $m[3] );
			$id   = sanitize_title( $text ) ?: 'section';
			$base = $id;
			for ( $n = 2; isset( $used[ $id ] ); $n++ ) {
				$id = $base . '-' . $n;
			}
			$used[ $id ] = true;
			$items[]     = array( 'id' => $id, 'text' => $text, 'level' => (int) $m[1] );
			return sprintf( '<h%1$d%2$s id="%3$s">%4$s</h%1$d>', $m[1], $m[2], esc_attr( $id ), $m[3] );
		},
		$content
	);

	if ( count( $items ) < 3 ) {
		return $content;
	}

	$list = '';
	foreach ( $items as $item ) {
		$list .= sprintf(
			'<li class="acme-toc__l%1$d"><a href="#%2$s">%3$s</a></li>',
			$item['level'],
			esc_attr( $item['id'] ),
			esc_html( $item['text'] )
		);
	}

	$toc = sprintf(
		'<nav class="acme-toc" aria-label="%1$s"><p class="acme-toc__title">%2$s</p><ol>%3$s</ol></nav>',
		esc_attr__( 'Table of contents', 'acme-theme' ),
		/* translators: %d: minutes */
		esc_html( sprintf( _n( '%d min read', '%d min read', acme_reading_time(), 'acme-theme' ), acme_reading_time() ) ),
		$list
	);

	return $toc . $content;
}
add_filter( 'the_content', 'acme_add_toc' );

How it works

What it solves

Long articles are easier to scan with a jump list, and readers like to know the time commitment up front. This filter generates both from the post itself, with no editor work and no plugin.

How it works

acme_reading_time() strips shortcodes and tags, counts words and divides by 220 words per minute, never returning less than one. The the_content filter only acts on single posts in the main loop. A regex callback finds h2 and h3 headings, builds a unique slug for each, writes it back as an id attribute and records the entry.

If at least three headings exist, a labelled nav with an ordered list is prepended, headed by the reading time string.

Adapting it

Change the threshold of three headings, the speed constant or the heading levels in the pattern. Call acme_reading_time() directly in a post meta template to show it near the byline. Style .acme-toc__l3 with extra indentation, and add scroll-margin-top to headings if you have a sticky header.

Pitfalls

  • Regex parsing is fine for editor output, but heavily nested HTML in headings may need DOMDocument instead.
  • Duplicate headings are numbered so anchors stay unique.
  • Cache the result with a transient on very long posts if profiling shows a cost.
  • Do not run it on excerpts; it checks in_the_loop() for that reason.

How to use it

Save as inc/post-extras.php and require it from functions.php.

Posts with three or more h2/h3 headings gain a contents box automatically.

Print the time elsewhere with echo esc_html( acme_reading_time() );.

Licence: GPL-2.0-or-later. This snippet comes from WordPress code and is GPL-2.0-or-later.

Related snippets