Skip to content

How to Build a Grav Plugin: Part 5

Writing the business logic

  • code
  • tutorial
  • series

Update dependencies

We’ve identified that we have two dependencies for our plugin:

  1. the Shortcode Core plugin, and
  2. the highlight.php library.

Let’s add the Shortcode Core dependency first. When the plugin is installed via the admin panel or the gpm CLI tool, Grav checks the plugin’s blueprints.yaml file for dependencies and installs or upgrades those plugins as required.

Open blueprints.yaml and add the Shortcode Core plugin, version 4.2.2 or higher, as shown in the third line of the following: This should be around line 19 of the file. Why version 4.2.2? I scanned the Shortcode Core changelog and selected v4.2.2 on account of the bit about the init() method override and the autoloading changes in v4.2.1.

dependencies:
  - { name: grav, version: '>=1.6.0' }
  - { name: shortcode-core, version: '>=4.2.2' }

That’s a tidy little change to add and commit to git, too.

git add blueprints.yaml
git commit -m "add shortcode-core dependency"

Since we’re not adding our plugin using gpm yet, let’s also install Shortcode Core.

pushd ../../..  # needs gpm needs to be run from the root of the Grav install
php bin/gpm install shortcode-core
popd  # moves you back to the plugin directory

We’ll mostly follow the README for bringing the highlight.php library into our plugin, by adding their note on Composer Version Constraints to their composer command line snippet. That is, from your highlight-php folder, run:

composer require scrivo/highlight.php:^9.18

This shouldn’t take very long, as it’s a pretty lightweight dependency, but it does install 300 files or so. Most of these are JSON files that define the syntax languages that the library supports, followed by another big group of CSS files that define the colour themes.

git add .
git commit -m "add highlight.php dependency"

Set the stage for our shortcode

The scaffolding from the devtools plugin gives us a great headstart in the file highlight-php.php. Grav has pretty solid documentation on its available event hooks, so read that for more details. Before we write the shortcode itself, let’s configure the under-the-hood bits that will let Grav do its thing.

We’re going to tie into two core Grav events, and one provided by the shortcode core plugin. We’ll return only onPluginsInitialized from the getSubscribedEvents function call, and handle enabling other events in that function.

public static function getSubscribedEvents(): array
{
  return [
    'onPluginsInitialized' => [
      ['autoload', 100000], // since we're requiring Grav < 1.7
      ['onPluginsInitialized', 0]
    ]
  ];
}

onPluginsInitialized

When the onPluginsInitialized event is fired, we’ll do a couple of safety checks before enabling our other events.

  1. First, we ensure that we’re on the client-facing part of the site by returning early if we’re in the admin view:
// don't proceed if in admin
if ($this->isAdmin()) {
  return;
}
  1. Next, we’ll make sure that the plugin is actually enabled by the user:
// don't proceed if plugin is disabled
if (!$this->config->get('plugins.highlight-php.enabled')) {
  return;
}

If we get past both of those checks, we can proceed to enable the shortcode handler event and set the user-selected theme, falling back to a default:

// enable other required events
$this->enable([
  'onShortcodeHandlers' => ['onShortcodeHandlers', 0]
]);

// set the configured theme, falling back to 'default' if unset
$theme = $this->config->get('plugins.highlight-php.theme') ?: 'default';

// register the css for our plugin (this function doesn't exist yet)
$this->addHighlightingAssets($theme);

In the above codeblock, we called two functions that don’t exist in our PhpHighlightPlugin class yet. Let’s create onShortcodeHandlers to register our custom shortcode (which we’re almost ready to start writing) and addHighlightingAssets, handle adding our CSS to the Grav Asset Manager.

public function onShortcodeHandlers()
{
  // FYI: `onShortCodeHandlers` is fired by the shortcode core at the `onThemesInitialized` event 
  $this->grav['shortcode']->registerAllShortcodes(__DIR__ . '/shortcodes');
}

private function addHighLightingAssets($theme)
{
  // add the syntax highlighting CSS file
  $this->grav['assets']->addCss('plugin://highlight-php/vendor/scrivo/highlight.php/styles/' . $theme . '.css');
}

That feels like a good chunk of work to call a commit.

git add .
git commit -m "configure plugin events and register assets"

On to the shortcode itself!

In the onShortcodeHandlers event in the last codeblock, we referred to the path that our shortcode will live. The shortcodes subdirectory name is a convention that’s followed by many other shortcode plugins in the Grav plugin repository.While that naming convention isn’t strictly required, sticking to conventions is often a good practice. (What is required is that the directory that is passed as an argument to registerAllShortcodes is the same directory that houses your shortcodes.) We can make the shortcodes directory and the file where our ShortCode like so:

mkdir shortcodes
touch shortcodes/HighlightPhpShortcode.php

The name of our file follows the convention of other shortcode plugins. Let’s scaffold the shortcode and check that our event handler is working as we’d expect.

    <?php // shortcodes/HighlightPhpShortcode.php

    namespace Grav\Plugin\Shortcodes;

    use Thunder\Shortcode\Shortcode\ShortcodeInterface;

    class HighlightPhpShortcode extends Shortcode
    {
        public function init()
        {
            $rawHandlers = $this->shortcode->getRawHandlers();
            $rawHandlers->add('hl', function (ShortcodeInterface $sc) {
                return "<div>shortcode <span style='font-family: monospace'>hl</span> successfully registered!</div>";
            });
        }
    }

Add the new shortcode to a page — I’ll add it to the home page in the base Grav installation — and fire up the Grav dev server with php bin/grav server.

<!-- 01.home/default.md -->
# Say Hello to Grav!

[hl /]  <!-- add this --> 

## installation successful...

The result should be something like this:

'hl' shortcode was processed as rendered as 'shortcode hl successfully
registered!' in our
HTML
The shortcode [hl /] in our source markdown has been successfully processed and rendered as the HTML returned by our init method, ‘shortcode hl successfully registered!’

Now that we know our plugin is registered and processing our shortcode correctly, we can add the logic that will handle our syntax highlighting. I covered the planned syntax in the second post of this series, so it’s just a matter of turning that desired syntax into something that the highlight.php can use.

Registering the languages

As a refresher, I decided that I’d use the language identifier itself as the tag name in the shortcodes. Fortunately, the highlight.php library’s Highlighter class includes a handy method to list all supported languages. The code looks like this:

    // highlight.php::Highlight/Highlighter.php
    public static function listRegisteredLanguages($includeAliases = false)
    {
        if ($includeAliases === true) {
            return array_merge(self::$languages, array_keys(self::$aliases));
        }

        return self::$languages;
    }

Back in our shortcode class, we’ll loop over these languages, including the aliases (so that js and javascript work in the same way), and register each language as its own shortcode. It’s not a bad idea to use plain language in comments as a kind of pseudocode before implementing the actual code. That’s what I’ll do here.

    <?php // shortcodes/HighlightPhpShortcode.php

    namespace Grav\Plugin\Shortcodes;

    use Thunder\Shortcode\Shortcode\ShortcodeInterface;

    class HighlightPhpShortcode extends Shortcode
    {
        public function init()
        {
            $rawHandlers = $this->shortcode->getRawHandlers();

            // create an instance of the Highlighter class
            $hl = new \Highlight\Highlighter();

            // store the result of the listRegisteredLanguages helper method,
            // passing in the `true` argument to include aliases
            $langs = array_unique($hl->listRegisteredLanguages(true));

            // loop over the languages...
            foreach ($langs as $k) {

                // ... and add each one in turn
                $rawHandlers->add($k, function (ShortcodeInterface $sc) {
                  // TODO: update the logic required by the Thunderer Shortcode engine.
                   return "<div>shortcode <span style='font-family: monospace'>" . $sc->getName() . "</span> successfully registered!</div>";
                });
        }
    }

Still some more work to do, of course, but for now, that little change should be enough to ensure that we’re on the right track. Instead of testing a single, hardcoded tag like we did above with hl, let’s check a few of the languages known to be supported by highlight.php and their aliases to make sure we’re on the right track.

<!-- 01.home/default.md -->
# Say Hello to Grav!

[php /]
[javascript /]
[js /] 

## installation successful...
Dynamic shortcodes are being correctly rendered, too, in our
HTML
The shortcodes in the source markdown are successfully processed and rendered as the HTML returned by our init method, ‘shortcode {language} successfully registered!’

Inline code snippets: self-closing shortcode

Let’s get inline snippets working. Recalling from the second post in this series, we’d like to turn this: [hl=js code=console.log('hey') /] into this: console.log('hey'). To do so, we need to get the language (which we just saw we can do using $sc->getName();) and the code to highlight (which we can do using $sc->getBbCode();), and pass those along to the Highlighter class instance.

Since we’ll be passing bits of data around, let’s abstract the highlighting portion into a private function called render.

    private function render(string $lang, string $code)
    {
        try {
            $hl = new \Highlight\Highlighter();
            $highlighted = $hl->highlight($lang, $code);
            $output = $highlighted->value;
            return "<code class='hljs language-$highlighted->language'>$output</code>";
        } catch (DomainException $e) {
            // if someone uses an unsupported language, we don't want to break the site
            return "<code class='hljs whoops-$lang-unknown-language'>$code</code>";
        }
    }

Now that we’ve got the render method, let’s update our init method to handle grabbing the code, pass the the $lang and $code variables from our shortcode to the render method and return the HTML the that highlight.php gives back instead of our placeholder text.

    // ...
    class HighlightPhpShortcode extends Shortcode
    {
        public function init()
        {
          // ...
                $rawHandlers->add($k, function (ShortcodeInterface $sc) {
                   $lang = $sc->getName();
                   $code = $sc->getBbCode();
                   return $this->render($lang, $code);
                });
        }
    }

Let’s try out our tiny example:

<!-- 01.home/default.md -->
# Say Hello to Grav!
Here goes nothing with our simple inline JavaScript example: [js=console.log('hey') /] 
Lookee here! Syntax-highlighted JavaScript code, rendered on the server!
Inline shortcode is almost working! We have successfully built a server-side syntax highlighting plugin for Grav.

So close! The syntax highlighting is working as expected, but we wanted an inline code snippet, and our tiny example is on its own line. It turns out that the default style, along with many others that ship with the plugin, include this ruleset: .hjls { display: block }.

Let’s amend our shortcode so that the rendered code element is always rendered inline. Taking advantage of CSS specificity rules, we can set a style attribute to override the ruleset in some of the themes that ship with our plugin. We’ll do this in the render function. Here’s the output from [shell=git diff] after making the change.

- return "<code class='hljs language-$highlighted->language'>$output</code>";
+ return "<code class='hljs language-$highlighted->language' style='display: inline;'>$output</code>";

And here’s the rendered result after making this change:

Fixed inline rendering
Inline shortcode working, and rendering inline!

Longer code samples: paired shortcode

For short snippets that we would prefer to display on their own, or in cases when we want to show mulitple lines of code (as elsewhere on this page)? Back in the second post of this series, I decided that paired shortcodes would handle this. The logic in our code will be basically the same, except that:

  1. to get the $code varible for the render function, we’ll grab the text between the shortcode tags, using $sc->getContent(););
  2. to ensure that the output is rendered on its own, we’ll ensure that it has the CSS rule { display: block }; and
  3. to ensure that the output preserves line breaks, we’ll wrap the output in <pre></pre> tags.

Here’s some pseudocode of these new bits to describe how to make this happen:

# in `init`
  get $content
  create $flag indicating inline or block
  if $content is null:
    # we're in an inline context
    set $code to bbcode
  else:
    # we're in a block context
    set $code to $content
  update $flag

# in `render`
  receive $lang, $code, $flag
  get $highlighted from $lang + $code
  if $flag indicates inline:
    return $highlighted as is
  else:
    return $highlighted wrapped in `pre` tags

The init changes are pretty straightforward: I’ve called the $flag in my pseudocode the more descriptive $isInline. The changes to render are pretty similar to the pseudocode, too, although here I’ve added the $display variable to make things a little more readable. Here’s the diff of these changes:

@@ -24,8 +24,14 @@ class HighlightPhpShortcode extends Shortcode
             // ... and add each one in turn
             $rawHandlers->add($k, function (ShortcodeInterface $sc) {
                 $lang = $sc->getName();
-                $code = $sc->getBbCode();
-                return $this->render($lang, $code);
+                $content = $sc->getContent();
+                $isInline = is_null($content);
+                if ($isInline) {
+                    $code = $sc->getBbCode();
+                } else {
+                    $code = $content;
+                }
+                return $this->render($lang, $code, $isInline);
             });
         }
     }
@@ -33,20 +39,24 @@ class HighlightPhpShortcode extends Shortcode
-    private function render(string $lang, string $code)
+    private function render(string $lang, string $code, bool $isInline)
     {
         try {
             $hl = new \Highlight\Highlighter();
             $highlighted = $hl->highlight($lang, $code);
             $output = $highlighted->value;
-            return "<code class='hljs language-$highlighted->language' style='display: inline;'>$output</code>";
+            $display = $isInline ? 'inline' : 'block';
+            $codeElement = "<code class='hljs language-$highlighted->language' style='display: $display'>$output</code>";
+            return $isInline ? $codeElement : "<pre class='hljs'>$codeElement</pre>";
         } catch (DomainException $e) {
             // if someone uses an unsupported language, we don't want to break the site
-            return "<code class='hljs whoops-$lang-unknown-language'>$code</code>";
+            $codeElement = "<code class='hljs whoops-$lang-unknown-language'>$code</code>";
+            return $isInline ? $codeElement : "<pre class='hljs'>$codeElement</pre>";
         }
     }
 }

With those changes made, let’s have a look at how it renders. Updating our base install’s homepage again, now including both a self-closing (i.e., inline) and paired (i.e., block) shortcode example:

<!-- 01.home/default.md -->
# Say Hello to Grav!
Here goes nothing with our simple inline JavaScript example: [js=console.log('hey') /]

[hl=php]
<?php
function add($a, $b) {
    return $a + $b;
}
?>
[/hl] 

… and we get back almost what we’re after:

There's some unwanted space in our block
There’s some unwanted space in our block, but we’re almost there.

To fix that extra whitespace, we’ll just trim our $code before passing it to render.

+                $code = trim($code);
                 return $this->render($lang, $code, $isInline);

And with that, the logic of our plugin is basically done. Commit, push, and carry on! In the next one, we’ll set up the admin interface for our plugin.