- PHP 96.2%
- Nix 3.8%
|
All checks were successful
/ test (push) Successful in 5s
Move the changelog's Unreleased entries into a 1.0.0 section, and add the link for the tag. As this is the first release, that link points at the tag itself rather than at a comparison with an earlier version. The Unreleased link now compares 1.0.0 with HEAD. Also correct the note in the changelog rules in `AGENTS.md`, which said that nothing had been released yet. 1.0.0 fixes the package's public API: the `toc` and `toc_depth` front matter keys, and the tree of headings the filter returns. |
||
|---|---|---|
| .forgejo/workflows | ||
| src | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| composer.json | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| phpunit.xml.dist | ||
| README.md | ||
Sculpin Table of Contents Bundle
A Sculpin bundle that provides a table_of_contents Twig filter, extracting a nested table of contents from rendered heading content.
Given a string of rendered HTML, it returns a tree of headings (starting at <h2>), which you can then render into your own markup - a nested list, for example. Pages opt in with a toc: true front matter value, and can set the depth with toc_depth.
Headings without an id attribute are skipped, since there is nothing to link to. Sculpin's Markdown converter adds id attributes to headings automatically, so no extra configuration is needed for content written in Markdown.
Requirements
- PHP 8.3 or later, with the
domextension - Sculpin (Symfony 6 or 7 components, Twig 3)
Installation
Composer
composer require opdavies/sculpin-table-of-contents-bundle
The source is also available at https://git.oliverdavies.uk/opdavies/sculpin-table-of-contents-bundle.
Nix
If your site fetches Sculpin bundles via Nix rather than Composer, add a derivation that fetches this repository, for example:
# nix/sculpin-table-of-contents-bundle.nix
{ stdenvNoCC, fetchgit }:
stdenvNoCC.mkDerivation {
pname = "sculpin-table-of-contents-bundle";
version = "unstable";
src = fetchgit {
url = "https://git.oliverdavies.uk/opdavies/sculpin-table-of-contents-bundle.git";
rev = "main"; # pin to a commit or tag for a reproducible build
hash = ""; # run the build once and copy the hash from the error message
};
installPhase = "cp -r . $out";
}
Expose it as a package and pass its store path to Sculpin as an environment variable, so your own autoloader can register the bundle's namespace - see Configuration below.
Configuration
1. Register the bundle
Sculpin loads additional bundles from app/SculpinKernel.php. Add this bundle to getAdditionalSculpinBundles():
<?php
declare(strict_types=1);
use Opdavies\Sculpin\Bundle\TableOfContentsBundle\SculpinTableOfContentsBundle;
class SculpinKernel extends \Sculpin\Bundle\SculpinBundle\HttpKernel\AbstractKernel
{
protected function getAdditionalSculpinBundles(): array
{
return [
SculpinTableOfContentsBundle::class,
// ...your other bundles
];
}
}
The bundle registers the table_of_contents Twig filter automatically - no further service configuration is required.
2. Register the namespace for autoloading (Nix installations only)
If you installed via Composer, its autoloader already handles this and you can skip this step.
If you installed via Nix, the package is outside Composer's autoloader, so register the PSR-4 namespace yourself - for example, in a file that Sculpin loads before booting the kernel:
spl_autoload_register(function (string $class): void {
$prefix = 'Opdavies\\Sculpin\\Bundle\\TableOfContentsBundle\\';
$baseDirectory = getenv('SCULPIN_TABLE_OF_CONTENTS_BUNDLE_PATH') . '/src/';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = $baseDirectory . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (file_exists($file)) {
require $file;
}
});
Then export SCULPIN_TABLE_OF_CONTENTS_BUNDLE_PATH (pointing at the package's root directory) in the environment that runs Sculpin.
Usage
A table of contents is opt-in per page. Add toc: true to a page's front matter, and optionally toc_depth to control how deep it goes:
---
title: My long post
toc: true
toc_depth: 3
---
Then pass the rendered content through the filter, which returns a list of headings, each with an id, text, and any nested children:
{% set headings = content|table_of_contents %}
Pages that don't set toc: true get an empty list, so there is no need to gate the filter in your template - and no cost in parsing the HTML of pages that don't want one.
Depth
toc_depth is the deepest heading level to include, so 3 nests <h3> headings under their preceding <h2>. If it isn't set, only <h2> headings are included.
A template can also pass a depth explicitly, which overrides the front matter value (but not the toc: true opt-in):
{% set headings = content|table_of_contents(3) %}
The order of precedence for the depth is:
- An explicit filter argument, e.g.
content|table_of_contents(3) - The page's
toc_depthfront matter value - The default of
2
Output
headings will look like:
[
['id' => 'hardware', 'text' => 'Hardware', 'children' => []],
['id' => 'software', 'text' => 'Software', 'children' => [
['id' => 'editor', 'text' => 'Editor', 'children' => []],
]],
]
The filter only extracts the headings - rendering them is up to you, so the output can match your site's markup and styling. A minimal, accessible example, rendering nested <ol> lists that mirror the heading structure:
{% set headings = content|table_of_contents %}
{% if headings|length > 1 %}
<nav aria-labelledby="table-of-contents-heading">
<h2 id="table-of-contents-heading">Contents</h2>
{{ _self.list(headings) }}
</nav>
{% endif %}
{% macro list(headings) %}
<ol>
{% for heading in headings %}
<li>
<a href="#{{ heading.id }}">{{ heading.text }}</a>
{% if heading.children %}
{{ _self.list(heading.children) }}
{% endif %}
</li>
{% endfor %}
</ol>
{% endmacro %}
Using a real <h2> (associated with the <nav> via aria-labelledby) and genuinely nested <ol> elements - rather than a flat list styled to look nested - means the table of contents is announced correctly by assistive technology, not just visually indented.
Development
A Nix flake is provided for a development shell with PHP and Composer. PHPUnit is installed by Composer and run from vendor/bin/phpunit, so its version is pinned by composer.json rather than by nixpkgs:
nix develop
composer install
vendor/bin/phpunit
The same commands run in CI via .forgejo/workflows/test.yml.
Contributing
Issues and patches are welcome at https://git.oliverdavies.uk/opdavies/sculpin-table-of-contents-bundle. Please include a test for any behaviour change.
License
MIT. See LICENSE.
Author
Oliver Davies - PHP Developer and Linux System Administrator.