Table of contents for Kirby 3

@HeinerEF Thanks for your reply! I’m coming back to this after a long while. I understand that, in how to use:

(toc: )

That means as part of the text content that you can type in the field, right? Have you also used it as part of a template? For example for a post page.

No, I always use it in textareas of the Panel. For my websites no template should always have a ToC.

But at https://github.com/getkirby/getkirby.com/tree/texnixe/cookbook-toc/content/1_docs/2_cookbook/7_extensions/0_table-of-contents the Kirby team prepares a recipe for this. May be you can wait until it is public…

1 Like

Oh great, yes, for sure I can wait. Thanks for the tip!

Now published:

2 Likes

@texnixe:

I like this recipe very much :smile:! Thank you!

Do you want to add a hint, that all heading texts have to be unique? At the moment I cannot test your code, but it looks like this is a requirement…

[Added:]
May be a check for the unambiguity of these texts is great…, may be in a next part…

Thanks a lot, very well put together! I’ve already used it on my website and is working properly.

As a side note (and surely due my lack of experience), it took me a bit to realise that

07. Alternative: A field method to generate the ToC

had to be a different file, I was a bit confused at first, because in the example it said /site/plugins/toc/index.php – same file name as before.

So now in my plugins folder (If I’m doing this correctly) I separated as follows:

toc-headings
toc-index

Just commenting this in case there’s another newbie out there :slight_smile: Thanks again.

No, you can put it into the same file, no need for a different file.

1 Like

To be sure. I’m struggling with the same issue.
At point 7:
07. Alternative: A field method to generate the ToC
I want to make use of this snippet inside my template:

<?php snippet('toc', ['headlines' => $page->text()->headlines('h2')]) ?>

Do I really have to rewrite /site/plugins/toc/index.php with this (code beneath) code 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;

    }
  ]
]);

I tried it but then I have to replace the code from point 3: Replace headlines with anchors and will my anchors break. Does someone know what to do?

You can either use a kirbytag (toc) to create the ToC in a textarea field, then you need the kirbytext:after hook, or you fetch the headlines via the field method in a template, then you need the field method. In any case you

To use the field method, you need the hook that auto-converts the headlines or the anchorHeadlines function: https://getkirby.com/docs/cookbook/extensions/table-of-contents#replace-headlines-with-anchors

I just followed your recipe @texnixe and it works quite well but not for blocks. Is there a possibility to extend it to blocks as well?

You can filter blocks by type:

$headings = $page->blocksfield()->toBlocks()->filterBy('type', 'h2');

Could I combine it with the KirbyText :after hook recipe plugin? I have a page with a blocks heading and a markdown block, which has headings as KirbyText, all in one page.
If I add your mentioned code line to the plugin file I get an error “Undefined variable: page”.

A KIrbytext hook is called when you use the kirbytext() method on a field.

The error is thrown because the $page variable cannot be used here, it is only available in templates and snippets.

As there is no hook for blocks I tried to do this but nothing happens regarding the anchor links:

<?php  

function($text) {

// get the headline levels to convert from a config option, we use h2 as the default

$headings = $page->blocksfield()->toBlocks()->filterBy('type', 'h2');

// create the regex pattern to be used as first argument in `preg_replace_callback()`

$headingsPattern = is_array($headings) ? implode('|', $headings) : $headings;

// use `preg_replace_callback()` to replace matches with anchors

$text = preg_replace_callback('!<(' . $headingsPattern . ')>(.*?)</\\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;

}; ?>

<?= $page->text()->toBlocks() ?>

                    <?php foreach ($page->blocks()->toBlocks() as $block): 

                        if(in_array($block->type(), $applyTo)): 

                            echo $text;

                        else: ?>

                        

                        

                    <div id="<?= $block->id() ?>" class="block block-type-<?= $block->type() ?>">

                        <?= $block ?>

                    </div>
<?php endif;?>
                    <?php endforeach ?>

I don’t understand what you are doing there. Where is that closure even used? And you run into the same issue with the undeclared variable.

I want to combine the KirbyText headings hook with the blocks headlines logic but for blocks Kirby has no :after hook and therefore I tried to emulate it. I got the idea and code from GitHub - sylvainjule/kirby-footnotes: Footnotes plugin for Kirby 3
The goal is to even show a complete Table of Content if a mix of blocks headings and Markdown within Blocks headings (KirbyText) is used in a post.
Could you add that part to your cookbook recipe @texnixe ?

Hm, I don’t know. I think this is going to be rather messy. So heading can be in a heading block, a markdown field and even in a writer field or maybe even a list field. Would probably be the best to extract the headlines from the completely rendered HTML page.

I don’t think it makes sense to add that to the current recipe. Maybe it would make more sense if someone creates a plugin for that.

In my personal opinion, it somehow defeats the purpose of having a blocks field and then mix it with markdown or even heading nodes in a writer field. Yes, all great flexibility wise, but it makes things like adding a ToC more difficult.

Hello everyone,

I’ve got some issues with the “Of anchors and ToCs, part 1” in CookBook. The first step works (Replace headlines with anchors) with the code :

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

But then, I’m not sure how to add this first part to the second part for Generate the ToC

<?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'
  ],
]);

I’m working on the starterkit on /content/notes page, has anyone done this before ?

Thanks !

Inside the 'kirbytext:after' array, you need the two functions. Note that both callbacks are supposed to work with a textarea field (which is not used anywhere in the Starterkit )

1 Like

Thank you @texnixe for your answer

I probably made a mistake, because it still doesn’t work

index.php

<?php

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

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

      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

<?php if ($headlines->isNotEmpty()) : ?>
  <nav class="toc">

    <h2>Table of Contents</h2>

    <ol>
      <?php foreach($headlines as $headline): ?>
        <li><a href="<?= $headline->url() ?>"><?= $headline->texto() ?></a></li>
      <?php endforeach ?>
    </ol>

  </nav>
<?php endif ?>

Yes I add a textarea field in note.yml, I saw later Extra: ToC from blocks field at the end of the document, I’ll try to do it in second time