Table of contents for Kirby 3

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.

I don’t understand where sould I add (toc) placeholder, is it in note.yml ?

Thanks for your time

The toc placeholder would not go into the blueprint but into a textarea field. But as I already mentioned above, the Starterkit doesn’t use any textarea fields anywhere. So if you want to use it, you have to add such a field to the blueprint where you want to use it, e.g. note.yml.

Currently, in note.yml a blocks field is used for long form text. If you want to create a toc from a blocks field: Of anchors and ToCs, part 1 | Kirby CMS

Thanks @texnixe

For the blocks should I replace $headlines by :

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

And remove 'kirbytext:after' => [ ?

<?php

use Kirby\Toolkit\Collection;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\Obj;

Kirby::plugin('k-cookbook/toc', [
  'hooks' => [
//
// Premiere fonction pour ancrer les titres
//

      function($text) {

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

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

//
// Deuxième fonction pour générer le ToC
//

      function($texto) {

        // the pattern allows passing an optional headline level `(toc: h3)`
        $pattern  = '!\(toc(?::\s?(h[1-6]))?\)!';

        $texto = preg_replace_callback($pattern, function($match) use($texto) {

          // get the headline level from the match
          $headline = $match[1] ?? 'h2';

          // find all headline matches
          preg_match_all('!<' . $headline . '.*?>(.*?)</' . $headline . '>!s', $texto, $matches);

          // create a new collection for the headlines…
          $headlines = new Collection();

          // …and add all matches
          foreach ($matches[1] as $texto) {
              $headline = new Obj([
                  'id'   => $id = '#' . Str::slug(Str::unhtml($texto)),
                  'url'  => $id,
                  'texto' => trim(strip_tags($texto)),
              ]);

              $headlines->append($headline->url(), $headline);
          }

          // return the html for the ToC
          return snippet('toc', ['headlines' => $headlines], false);

        }, $texto);

        return $texto;

      },
],
  'snippets' => [
    'toc' => __DIR__ . '/snippets/toc.php'
  ],
]);

Yes, you don’t need the kirbytext hooks for the blocks field.

I’ve got an error

Undefined variable: headlines

But I don’t understand why it don’t found $headlines

index.php

<?php

use Kirby\Toolkit\Collection;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\Obj;

Kirby::plugin('k-cookbook/toc', [
  'hooks' => [
//
// Premiere fonction pour ancrer les titres
//

      function($text) {

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

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

//
// Deuxième fonction pour générer le ToC
//

      function($texto) {

        // the pattern allows passing an optional headline level `(toc: h3)`
        $pattern  = '!\(toc(?::\s?(h[1-6]))?\)!';

        $texto = preg_replace_callback($pattern, function($match) use($texto) {

          // get the headline level from the match
          $headline = $match[1] ?? 'h2';

          // find all headline matches
          preg_match_all('!<' . $headline . '.*?>(.*?)</' . $headline . '>!s', $texto, $matches);

          // create a new collection for the headlines…
          $headlines = new Collection();

          // …and add all matches
          foreach ($matches[1] as $texto) {
              $headline = new Obj([
                  'id'   => $id = '#' . Str::slug(Str::unhtml($texto)),
                  'url'  => $id,
                  'texto' => trim(strip_tags($texto)),
              ]);

              $headlines->append($headline->url(), $headline);
          }

          // return the html for the ToC
          return snippet('toc', ['headlines' => $headlines], false);

        }, $texto);

        return $texto;

      },
],
  'snippets' => [
    'toc' => __DIR__ . '/snippets/toc.php'
  ],
]);

toc.php

  <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 ?>
type or paste code here

And <?php snippet('toc') ?> in note.php
I also add the line of code in /site/snippets/blocks/heading.php for overwrite the default heading block snippet and add an id attribute

Ok I replace <?php snippet('toc') ? by <?php snippet('toc', ['headlines' => $page->text()->headlines('h2')]) ?> and I don’t have the error anymore :white_check_mark:

But now I have the error

Object of class Kirby\Cms\Field could not be converted to int

But you now have to pass the filtered blocks as headlines_

<?php snippet('toc', ['headlines' => $page->text()->toBlocks->filterBy('type', 'heading')->filterby('level', 'h2')]) ?>
1 Like

Ok I understand better now, thank you @texnixe !

I now have an error for this line;

syntax error, unexpected ‘->’ (T_OBJECT_OPERATOR), expecting identifier (T_STRING) or variable (T_VARIABLE) or ‘{’ or ‘$’

I added an arrow too many when copy/pasting, corrected above.

Tip: Learn to understand PHP error messages. Saves a lot of time.

:woman_facepalming: sorry for that ! I should have seen it

I now have an error about the variable $headlines (Undefined variable: headlines), don’t found from where it come, probably from index.php…

You don’t actually need the plugin if you only want to use the toc with blocks.

Put the toc.php snippet into /site/snippets and call the snippet with like above. That’s it.

1 Like

Thank you a lot @texnine !
I spent lot of time between code for textarea and blocks for finaly so simple and logical lines of code.
It looks so easier to do it for other headings now, thanks!