Skip to main content

Reading time: six minutes. Nothing on this page is needed to use the plugin. It is here because a contents plugin sits in the middle of every page on your site, and you are entitled to know exactly what it does to them.

The short version

When somebody asks for one of your posts, WordPress hands us the post’s content as finished HTML. We read through it once, note every heading, give each one a name, build a list, and put the list where you asked for it. Then we print the stylesheet into the page, once, and we are done.

No JavaScript is sent to your reader. No file is fetched. Nothing is written to the database while a page is being shown. Nothing is sent to us or to anyone else, ever.

Reading the post

Most contents plugins do this in one of two ways. Some load the whole post into PHP’s HTML parser, which builds a tree of every element in the document and costs memory in proportion to the whole post. Others run a regular expression over the entire post for each heading level they are interested in, which means walking the post five or six times.

We make one pass. We look for <h from wherever we got to last time, check whether what follows is a heading we care about, and if it is, find its closing tag and take the text between. Then we carry on from there. The post is read once, front to back, and the work is proportional to the number of headings rather than to the size of the post.

The difference is small on a normal post and large on a long one. On a post with 200 headings, measured on PHP 7.4 on a slow machine, the whole pass takes about three milliseconds. We test that figure as part of the release gate, because a plugin that is fast only on the author’s laptop is not fast.

Two details that matter for correctness rather than speed:

  • Headings inside <pre> or <code> are ignored. A tutorial showing someone else’s HTML does not get that example’s headings in its contents list.
  • Headings that already have an id keep it. If you set an anchor by hand, or the block editor set one, that is the anchor. We never rename somebody else’s id.

Naming the headings

An anchor is the #something at the end of a link that points at one heading. We make it from the heading’s words: “How it works” becomes #how-it-works.

Two headings with the same words get -2, -3 and so on after the second one, and the first one is left alone. That is what WordPress itself does and what readers expect.

Every setting explained lists the five formats on offer. The only reason to change the format is that you are moving from another plugin, and then the move does it for you.

This is the part no other contents plugin does, and it is the reason the plugin exists in the shape it does.

Every contents plugin makes the anchor out of the heading’s words. So the day you change “Laying the boards” to “Laying the first row”, the anchor changes with it, and every link anybody ever shared to that section stops working. There is no error: the reader lands at the top of the page and scrolls, so nobody tells you.

With Keep anchors when a heading is edited on, which it is by default, each post records the anchors it has published. On every save, and on every page view, each heading is matched back to that record in three passes, cheapest and most certain first:

  1. An id the writer set. If the heading carries an id of its own, that is the answer and nothing else is considered.
  2. The same words. A heading whose text matches a record keeps that record’s anchor. Same words at the same level first, then same words at any level, so moving an h3 up to an h2 does not lose its link.
  3. The same place in the post. Among the headings and records that are still unmatched, the first unmatched record at that level goes to the first unmatched heading at that level. This is the pass that catches a reworded heading: nothing else about it has changed, so it is still the third h2 in the post, and it keeps its anchor.

Anything still unmatched after all three is a heading we have never seen, and it gets a new anchor.

What this means in practice: reword a heading, and the link keeps working. Reorder your sections, and every link keeps working. Delete a section, and its link stops working, because the section is gone and there is nothing honest to do about that.

The record is per-post meta, which is to say it lives with the post. Deleting the plugin deletes it, along with everything else the plugin has stored.

Putting the list in the page

Automatic placement runs on the_content at priority 100, which is late: after shortcodes, after blocks, after anything else that changes your content. We are looking at the HTML your reader will actually see, not at an earlier draft of it.

Blocks, shortcodes and widgets cannot be answered on the spot, because at the moment one of them runs, the rest of the post has not been processed and the headings after it do not exist yet. So each of them prints a small marker instead, and when the content is complete we fill every marker in one pass. One read of the post, however many lists are on the page.

A list is only ever built for the post being read. On an archive page, a category page, a search result or a front page showing several posts, the markers are removed and no list is built, because a contents list for one of ten posts on a page is meaningless.

The stylesheet

The CSS is printed into the page itself, inside a <style> element, rather than linked as a file. It is about 4KB, it only appears on pages that have a list, and it saves your reader one HTTP request. On a page with no list, nothing is printed at all.

Every edge in it is a logical property, which is a modern way of saying “the start of the line” rather than “the left”. So in Arabic or Hebrew the indentation, the numbers and the fold arrow all move to the other side from the same stylesheet, with nothing to configure.

If you would rather write your own, Load no stylesheet prints none. The list is still a proper nested list of proper links, and the fold still works, because the fold is HTML.

The fold

The fold is a <details> element with the title as its <summary>. That is a part of HTML, so the browser does the work: it folds with no JavaScript, it is in the keyboard’s tab order, it announces itself to a screen reader as a control that expands, and it keeps working when something else on the page breaks.

It is also why the fold cannot remember whether a particular reader left it open. Remembering would need a script and somewhere to store it, and we would rather have a fold that always works than one that occasionally remembers.

Scrolling

Two lines of CSS. scroll-behavior: smooth for the slide, inside a query that switches it off for anybody who has asked their device for less motion, and scroll-padding-top for the fixed header offset, so the heading does not end up underneath your theme’s top bar.

Both are the browser’s own scrolling. No scroll handler, no animation loop, nothing running while your reader reads.

What it never does

  • No JavaScript on the front end. None.
  • No external request, no telemetry, no phoning home, no checking in.
  • No admin notices, no upsell banners, no review nags.
  • No writing to the database while a page is being shown.
  • No change to your content as stored. Everything happens on the way out, so switching the plugin off leaves your posts exactly as they were.

What next