Of anchors and ToCs, part 1
Auto-replace headlines with anchors and generate a table of contents
Table of Contents
A Table of Contents (ToC) gives readers a quick overview of the contents of a page and allows accessing specific parts quickly.
Prerequisites
Being familiar with registering extensions in Kirby and a basic understanding of PHP will be useful.
To follow along, install a Kirby Starterkit, create a /site/plugins/toc
folder with an index.php
inside and add some headlines in a textarea field, for example in one of the subpages of the /content/notes
page.
Plugin parts
To create a ToC, we need two things:
- Anchors in the text to which the ToC can link. While those could be added manually, it is much easier to auto-generate them from headlines. Anchors are useful even without a ToC, because you can link to these anchors from elsewhere.
- A navigation list of links to these anchors usually at the top of the page or the sidebar, the actual ToC.
Consequently, we have to implement two steps:
- Replace the headlines in the text with anchor headlines
- Add the ToC where we want it to appear
Let's start with replacing the headlines in our text with anchors.
Replace headlines with anchors
We can replace headlines automatically with a KirbyText
hook or manually using a field method. The actual code to replace the headlines is the same, but we hook into different Kirby extensions.
Let's start with a kirbytext:after
hook that converts all given headline levels into anchors whenever the kirbytext()
method is called. We make the headline levels we want to convert configurable via a config option and use h2
and h3
headlines as defaults.
<?php
Kirby::plugin('k-cookbook/toc', [
'hooks' => [
'kirbytext:after' => [
function($text) {
// get the headline levels to convert from a config option, we use h2 as the default
$headlines = option('k-cookbook.toc.headlines', 'h2|h3');
// create the regex pattern to be used as first argument in `preg_replace_callback()`
$headlinesPattern = is_array($headlines) ? implode('|', $headlines) : $headlines;
// use `preg_replace_callback()` to replace matches with anchors
$text = preg_replace_callback('!<(' . $headlinesPattern . ')>(.*?)</\\1>!s', function ($match) {
// create the id from the headline text
$id = Str::slug(Str::unhtml($match[2]));
// return the modified headline:
// $match[1] contains the match for the first subpattern, i.e. `h2`, `h3` etc.
// $match[2] contains the match for the second subpattern, i.e. the actual headline text
return '<' . $match[1] . ' id="' . $id . '"><a href="#' . $id . '">' . $match[2] . '</a></' . $match[1] . '>';
}, $text);
return $text;
},
]
]
]);
The important part here is the preg_replace_callback()
function. It needs three parameters…
- the search pattern (
$headlinesPattern
) - the callback function
- the input (
$text
)
…and returns the modified string.
To include more headlines levels, you can set the k-cookbook.toc.headlines
option in the config:
<?php
return [
// …other settings
'k-cookbook.toc.headlines' => ['h2', 'h3', 'h4'],
];
Result
If you now look at a page that contains headlines of the given level, you will see that the anchors were added.
Alternative: A field method to generate the anchors
This is an alternative to using the kirbytext
hook described above. Do not use the hook and this method in conjunction.
<?php
Kirby::plugin('k-cookbook/toc', [
'fieldMethods' => [
'anchorHeadlines' => function($field, $headlines = 'h2|h3') {
// create the regex pattern to be used as first argument in `preg_replace_callback()`
$headlinesPattern = is_array($headlines) ? implode('|', $headlines) : $headlines;
// use `preg_replace_callback()` to replace matches with anchors
$field->value = preg_replace_callback('!<(' . $headlinesPattern . ')>(.*?)</\\1>!s', function ($match) {
// create the id from the headline text
$id = Str::slug(Str::unhtml($match[2]));
// return the modified headline:
// $match[1] contains the match for the first subpattern, i.e. `h2`, `h3` etc.
// $match[2] contains the match for the second subpattern, i.e. the actual headline text
return '<' . $match[1] . ' id="' . $id . '"><a href="#' . $id . '">' . $match[2] . '</a></' . $match[1] . '>';
}, $field->value);
return $field;
}
]
]);
In your templates/snippets, you can now use the field method like this:
With a single headline level
<?= $page->text()->kt()->anchorHeadlines('h2') ?>
With multiple headline levels
<?= $page->text()->kt()->anchorHeadlines('h2|h3|h4') ?>
Instead of passing a string of options separated by |
, you can also pass an array of options:
<?= $page->text()->kt()->anchorHeadlines(['h2', 'h3', 'h4']) ?>
If you don't pass an argument, the defaults will be used.
Generate the ToC
While the code we need to generate the ToCs from the headlines in a textarea field is the same, we can use different approaches to add them to the page.
- Use a placeholder that users can manually put into a textarea field where they want the ToC to appear
- Add a ToC in our template code using a field method
We will look into both options in this recipe.
Using a toc
placeholder
For the toc
placeholder, we use another kirbytext:after
hook, and a snippet that will contain the HTML for the ToC. We will reuse this snippet for the alternative field method approach below.
<?php
use Kirby\Toolkit\Collection;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\Obj;
Kirby::plugin('k-cookbook/toc', [
'hooks' => [
'kirbytext:after' => [
// (…) add the callback function from above here (without the wrapper)
function($text) {
// the pattern allows passing an optional headline level `(toc: h3)`
$pattern = '!\(toc(?::\s?(h[1-6]))?\)!';
$text = preg_replace_callback($pattern, function($match) use($text) {
// get the headline level from the match
$headline = $match[1] ?? 'h2';
// find all headline matches
preg_match_all('!<' . $headline . '.*?>(.*?)</' . $headline . '>!s', $text, $matches);
// create a new collection for the headlines…
$headlines = new Collection();
// …and add all matches
foreach ($matches[1] as $text) {
$headline = new Obj([
'id' => $id = '#' . Str::slug(Str::unhtml($text)),
'url' => $id,
'text' => trim(strip_tags($text)),
]);
$headlines->append($headline->url(), $headline);
}
// return the html for the ToC
return snippet('toc', ['headlines' => $headlines], false);
}, $text);
return $text;
},
],
],
'snippets' => [
'toc' => __DIR__ . '/snippets/toc.php'
],
]);
The code for the snippet goes into /site/plugins/toc/snippets/toc.php
:
<?php if ($headlines->isNotEmpty()) : ?>
<nav class="toc">
<h2>Table of Contents</h2>
<ol>
<?php foreach($headlines as $headline): ?>
<li><a href="<?= $headline->url() ?>"><?= $headline->text() ?></a></li>
<?php endforeach ?>
</ol>
</nav>
<?php endif ?>
Result
If you now add a (toc)
placeholder to a page that contains headlines of the given level, you should see a ToC appear where you added the placeholder.
Alternative: A field method to generate the ToC
<?php
Kirby::plugin('k-cookbook/toc', [
'fieldMethods' => [
'headlines' => function($field, $headline = 'h2') {
preg_match_all('!<' . $headline . '.*?>(.*?)</' . $headline . '>!s', $field->kt()->value(), $matches);
$headlines = new Collection();
foreach ($matches[1] as $text) {
$headline = new Obj([
'id' => $id = '#' . Str::slug(Str::unhtml($text)),
'url' => $id,
'text' => trim(strip_tags($text)),
]);
$headlines->append($headline->url(), $headline);
}
return $headlines;
}
]
]);
The field method returns a collection of headline objects.
In your templates, you can now use the headlines()
field method in conjunction with the snippet we registered above.
<?php snippet('toc', ['headlines' => $page->text()->headlines('h2')]) ?>
Conclusion
In this recipe we explored different ways to add anchor headlines and a ToC to pages.
You can also mix the hook that auto-generates anchor headlines with the headlines
field method and the toc.php
snippet to create the ToC in your templates rather than through a placeholder users add manually.
If you vote for generating the ToC via the field method in your templates, you can still give editor control whether to show a ToC on a page by page basis using a toggle or checkbox field in your blueprints, for example:
fields:
toggle:
label: Toggle
type: toggle
text: Show ToC
You could also only show the ToC if there is at least a given number of headlines by slightly modifying the toc.php
snippet, for example, if there are at least 3 headlines:
<?php if ($headlines->count() >= 3) : ?>
<nav class="toc">
<h2>Table of Contents</h2>
<ol>
<?php foreach($headlines as $headline): ?>
<li><a href="<?= $headline->url() ?>"><?= $headline->text() ?></a></li>
<?php endforeach ?>
</ol>
</nav>
<?php endif ?>