Skip to content

Kirby 3.9.8

Of anchors and ToCs, part 1

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.

Note that the techniques described here only work for fields that are rendered using the kirbytext() method, usually a textarea field (or plugin fields derived from this), not with the Editor.

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.

/site/plugins/toc/index.php
<?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;
      },
    ]
  ]
]);

Since we want to add a second hook of the same type in the next step, we use an array of functions after the kirbytext:after key.

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:

/site/config/config.php
<?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.

/site/plugins/toc/index.php
<?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.

To keep it simple, we will include only one headline level in our ToC. We will cover multiple levels in a future 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.

/site/plugins/toc/index.php
<?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:

/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

/site/plugins/toc/index.php
<?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:

/site/plugins/toc/snippets/toc.php
<?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 ?>

Extra: ToC from blocks field

If you are using the blocks or layouts fields with separate blocks for your headings, you can filter the blocks by type heading and heading level:

// assuming a blocks field called text
$headlines = $page->text()->toBlocks()->filterBy('type', 'heading')->filterby('level', 'h2');

Then create the ToC snippet like this:

/site/plugins/toc/snippets/toc.php
<?php if ($headlines->count() >= 3) : ?>
  <nav class="toc">
    <h2>Table of Contents</h2>
    <ol>
      <?php foreach($headlines as $headline): ?>
          <li><a href="<?= $page->url() . '/#' . Str::slug($headline->text()) ?>"><?= $headline->text() ?></a></li>
      <?php endforeach ?>
    </ol>

  </nav>
<?php endif ?>

The last step is to overwrite the default heading block snippet and add an id attribute:

/site/snippets/blocks/heading.php
<?php /** @var \Kirby\Cms\Block $block */ ?>
<<?= $level = $block->level()->or('h2') ?> id="<?= Str::slug($block->text()) ?>"><?= $block->text() ?></<?= $level ?>>