Table of Contents

Ok, so I am trying to follow @texnixe guide on creating a table of contents as I want to understand plugins more. Now I am probably being stupid here but I can’t get it to work, well I can in some parts.

I have created a site>plugins>toc folder which contains an index.php
Also, create a site>plugins>toc>snippets folder with a file called toc.php

For the main index.php file I have added the following. A hook to create the anchors which works fine. And I am using the field method to generate to TOC.

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

    }

  ]

]
]);

Then I am using the following to display.

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

The anchors are working fine but no TOC, I am being a noob I know it, probably not understanding what I am doing. Happy to be embarrassed but someone please put me out of my misery :slight_smile: as to what I am doing wrong.

In your code above, I’m missing the part where you register the snippet?

Apologies, I had not expanded that in the file before I copied.

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

    }

  ],

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

]
]);

Also my toc.php file

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

    <h2 class="text-red-100">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 ?>
'''

The index.php seems to have an error, make sure that all arrays etc are closed in the right places… That’s why the snippet is not properly registered At least if your real code is what you posted here.

1 Like

It does appear to be related to the snippet, even if I remove the if statement around the table of contents in toc.php still nothing, I thought maybe the issue was that it couldn’t find any headlines.

Oh well, nevermind, will spend a bit more time on it before throwing the towel in.

Copy the snippet into /site/snippets to see if that works.

This is what you index.php should look like

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

    }

  ],

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

]);

Fixed it. Ok, this is a new one for me.

So I use Sublime Text 3, I checked the closures in index.php several times, all looked fine, no errors were reported. Got annoyed, cried a little, stamped my feet and then for some reason decided to open the file in VSCode. Boom! The first thing I see, a closure issue, staring right at me…

fixed that and table of contents working.

Really sorry @texnixe, no excuse for wasting your time on that one.

1 Like

:joy: :hugs:

2 Likes

Just one last thing @texnixe, if I was to convert in this case my blog posts to blocks, is that going to be a difficult change for the ToC as well?

Thanks

Lee

It will need changes, yes, but getting the headings from blocks is actually very easy if you use heading blocks. Because you can filter blocks by type.

To add the anchors, you can then just modify the heading block snippets.

So if you stick to heading blocks, you don’t need a plugin, just the adapted heading snippets and a snippet to output the TOC.

It will be more complicated if you use headings inside the writer field.

Great thanks, just using the headings from the block would be fine. I will give that a go as well. All part of learning :slight_smile:

Can I jump in here? I would like to use a ToC block, but only for long blogposts (actually just 1 post, so I am tempted to just do it manually in HTML inside a textarea, but once you have a tool you actually need it more often, so maybe a block would be nice).

So, could you elaborate on what you said here?

  • Where do I filter the blocks by type? And probably: How do I filter them?
  • I have the headings block already extended with an ID field. Is that enough for the anchors (I guess so)
  • You say I need a snippet to output the ToC, but don’t you mean a “block”, not a snippet? I mean I have to insert that ToC on the page and how would a snippet do that when you don’t need a ToC on every blogpost, but just long ones?

See Of anchors and ToCs, part 1 | Kirby CMS

Yes, see above.

Yes, I mean a snippet that you would call in your template where you want to show the ToC. If it shouldn’t be shown for each blog post, you can do this conditionally, like if the post has more than x headlines or you can use a checkbox/toggle in the post itself (Show ToC?).

Now I get it. I’ll save that for a rainy day.

Thank you!

If only there were some rainy days…

Winter will be coming… :grimacing: