Table of Contents
Auto-generated table of contents from H2/H3 headings in Contensio posts.
About this plugin
Table of Contents
Auto-generates a table of contents from H2 and H3 headings in the post body. Rendered server-side (SEO-friendly links) and injected inline just before the content blocks. A small JS snippet adds matching id attributes to the rendered headings so the anchor links work.
The TOC is only shown when a post has 3 or more qualifying headings.
Requirements
- Contensio 2.0 or later
Installation
Composer
composer require contensio/plugin-table-of-contents
Manual
Copy the plugin directory and register the service provider via the admin plugin manager.
No migrations required.
How it works
Heading extraction
HeadingExtractor::extract($blocks, $langId) scans every block in document order:
| Block type | How headings are extracted |
|---|---|
heading |
data.level (h2 or h3) + translations[$langId].text |
richtext |
/<(h[23])[^>]*>(.*?)<\/h[23]>/ matched against the HTML |
H4 and deeper are intentionally excluded - they're too granular for navigation.
Slug algorithm
Both PHP and JavaScript use the same algorithm:
1. Lowercase
2. Remove non-word, non-space, non-hyphen characters
3. Replace spaces/underscores with hyphens
4. Collapse repeated hyphens
5. If empty → 'section'
6. Deduplicate: append -2, -3, … for repeated headings
This guarantees that the TOC href="#slug" always matches the id added by the JS.
Hook placement
Hooks into contensio/frontend/post-before-content - fires just before the blocks wrapper <div class="contensio-post-body">.
JS heading IDs
A small inline <script> runs on DOMContentLoaded. It scans .contensio-post-body h2, h3, applies the same slug algorithm, and sets el.id. This handles:
headingblocks rendered as bare<h2>/<h3>(noidby default)- H2/H3 inside
richtextblocks' HTML
Minimum headings threshold
The TOC is suppressed for posts with fewer than 3 qualifying headings. Change this in TableOfContentsServiceProvider:
private const MIN_HEADINGS = 2;
Customising
Override the Blade view
resources/views/vendor/toc/partials/toc.blade.php
Available: $headings - array of ['level' => 'h2'|'h3', 'text' => string, 'id' => string].
Collapsible TOC
Wrap the <ol> with Alpine.js to make it expandable/collapsible:
<div x-data="{ open: true }">
<button @click="open = !open" class="...">
Contents <span x-text="open ? '▲' : '▼'"></span>
</button>
<ol x-show="open" class="...">
...
</ol>
</div>
Sticky sidebar
To float the TOC in a sidebar, change the post layout to a two-column grid and render the TOC in the sidebar column. The $headings data can be passed through a shared view composer or a second pass of HeadingExtractor::extract().
Styling
The TOC wraps in <nav class="contensio-toc …">. H3 items get pl-4 indentation to show hierarchy. Target in CSS:
.contensio-toc { … }
.contensio-toc ol { … }
.contensio-toc a { … }
.contensio-toc li.pl-4 { … } /* H3 level */
Hook reference
| Hook | Type | Args | Description |
|---|---|---|---|
contensio/frontend/post-before-content |
Render | Content, ContentTranslation |
Just before the post blocks wrapper |