Skip to content

Of anchors and ToCs, part 1

Auto-replace headlines with anchors and generate a 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.

/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;
      },
    ]
  ]
]);

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.

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 ?>