Manipulating site taxonomy via plugin (php)

I have created a plugin to store author bios and then output them in various pages. That part is working great. I would now like to be able to create page collections based on author.

I’ve had this working before by manually setting up a new taxonomy filter of ‘author’ and adding to each page’s frontmatter per https://learn.getgrav.org/16/content/taxonomy#taxonomy-example.

Now I would like to do basically the same thing, but via the plugin’s php script. So far I have added the ‘author’ taxonomy filter via onPluginsInitialized:

$taxonomies = $this->config->get('site.taxonomies');
$taxonomies[] = 'author';
$this->config->set('site.taxonomies', $taxonomies);

Then I am updating individual page taxonomy via onPageInitialized:

$page_taxonomy = $page->taxonomy();
$page_taxonomy['author'][] = $header->aura['author'];
$page->taxonomy($page_taxonomy);

I have one page where I’ve set an author value in the frontmatter. I have verified that the above code is updating the contents of $page->taxonomy. However when I create a page collection filtered on author e.g. /blog/author:shawn-kendrick the collection is empty.

The full code is available here: https://github.com/matt-j-m/grav-plugin-aura-authors

This morning I went a step further thinking perhaps I need to refresh the overall taxonomy map, so I added to the above onPageInitialized

$taxonomy = $this->grav['taxonomy'];
$taxonomy->addTaxonomy($page, $page_taxonomy);

But still no good. I have dug down into Page::collection and found that when iterating through the blog child pages (lines 2714 - 2726 in system/src/Grav/Common/Page/Page.php) they are all being excluded from the collection by

if (empty($page->taxonomy[$taxonomy])

So apparently $page->taxonomy is being dropped/refreshed/overwritten after my plugin edits it. Is there another way around this, or am I trying to achieve the impossible? Thanks in advance!

@matt-j-m, I have had a look at both of your plugins and got the feeling you have a WordPress background. I recognise a style of “find an event that fires early enough” and “lookup the api to code the desired result”.

Approaching Grav with a WordPress mindset will hurt both performance and the joy of coding with a fresh new architecture.

Of course I may have misconceived the intentions of you code…

A few findings in both plugins:

  • Event subscriptions
    All events (Admin and front-end) are being subscribed to at once at getSubscribedEvents() instead of subscribing selectivily (depending whether Admin or front-end is calling) to the right events in onPluginsInitialized() or later when needed.
    If not:

    • Resources are burned for no use.
    • In every event, it has to be checked if it is being called from Admin or front-end page.

    See the plugin tutorial in the documentation.

  • Page blueprints declaration
    Page blueprints are being programmatically extended in event onBlueprintCreated(Event $event) instead of using Grav’s style of extending page blueprints and adding blueprints.
    See the page blueprint example in the documentation.

  • Caching metadata
    In plugin ‘grav-plugin-aura’, which adds metadata for Facebook, Twitter etc. to the <head> of the html, all metadata is generated at runtime at every page call.
    One of Grav’s forte is performance by lean code and by caching as much as possible. By generating metadata repeatedly at runtime the power of cache is defied.

    • Metadata should be written to the header of the page and will be cached by Grav. See the documentation on metadata in page headers.
  • Storing media files in folder of plugin
    Plugin ‘grav-plugin-aura-authors’, stores images of authors inside folder 'user/plugins/aura-authors/assets`, together with css and font files. This is not a sensible place to store data. That folder will be wiped when a new version of the plugin is released…
    See the documentation on Media to find better places to store images.

  • Storing data
    I’m not sure if profiles of authors should be stored inside the config file (’/user/config/plugins/aura-author.yaml’) of the plugin. Are the author profiles content or configuration data? Maybe `‘user/data/aura-author.yaml’ would be a more suitable location.

On the question itself…
The taxonomy of a page should be written in the header of the page using the ‘taxonomy’ property and not the ‘aura’ property. Then, Grav can create and cache a proper taxonomy tree and collections will work out of the box. See the documentation on Taxonomy Collections.

Example of page header

---
taxonomy:
    author: label
---

A few last suggestions:

  • Take a step back and have a fresh look at Grav by reading the docs (also the intros for a foundation on Grav)
  • Look at multiple plugins written by the dev team to learn Grav specific coding patterns.
  • Don’t be hasty in publishing a plugin to the community… Feel free to ask for a code review.

Hope this helps…

2 Likes

@pamtbaau thank you for your detailed response! I am not coming from a WordPress background, but you did get my development process pretty much right. I have read a lot of the documentation but I am definitely still learning my way around the Grav lifecycle and event hooks. If you are willing I will probably have a lot of questions for you and some new code to review pretty soon :slight_smile:

Your advice re event subscriptions, blueprints declaration, storing of media and my original question are all clear, and I think I have enough to go on to make some major improvements in those areas. For the remaining 2 items:

Caching metadata - you mention writing metadata to the page header so it can be cached. The plugin is currently writing 3 inputs to the frontmatter, which is then translated and output as up to 30 or so meta tags in the rendered page. The main purpose of the plugin is that it’s really easy for the admin user to complete those few fields, but still benefit from rich output. If they are required to input 30 or so fields, the value of the plugin is heavily reduced as they might as well just enter the meta tags manually on the Options tab. Is there a way for a plugin to “artificially” create the meta tags AND cache them? That would be the ideal scenario for both the admin user and end user performance.

Storing data - good point about storing the author information in user/data - that makes a lot of sense. Is it possible to create an interface for this via the admin plugin? I’ve only ever seen user input done through the Configuration, Pages, Plugins or Themes sections of the admin GUI. I’m not sure if it makes more sense to sit under any of those than it does under Plugins - but I’d like to hear your thoughts on that. Again, I am going for maximum ease of use - I really want it to have a graphical interface rather than asking the user to upload a yaml file.

Thanks again.

@matt-j-m,

Caching metadata
As a hint for saving extra data to the header of the page when Admin saves the page, try the following in your Grav test/dev installation:

  • Run $ bin/gpm install devtools
  • Run $ bin/plugin devtools newplugin and answer a few questions.
  • In the generated plugin (which also provides you a best practice for subscribing to events…), change the content to the following (see docs onAdminSave) :
    public function onPluginsInitialized()
    {
        // Don't proceed if we are in the admin plugin
        if ($this->isAdmin()) {
            $this->enable([
                'onAdminSave' => ['onAdminSave', 0],
            ]);
            return;
        }
    
        // Enable the main events we are interested in
        $this->enable([
            // Put your main events here
        ]);
    }
    
    public function onAdminSave(Event $event)
    {
        // Don't proceed if Admin is not saving a Page 
        if (!$event['object'] instanceof Page) {
            return;
        }
    
        $page = $event['object'];
        // Replace the following with you own metadata logic
        // You will probably need to merge your values with value entered by the
        // user in Admin panel
        $page->header()->metadata['tagName'] = 'tagValue';
    }
    

If all is well, the resulting page header should be:

---
metadata:
  - tagName: tagValue
---

In Admin, when editing a Page, the metadata your code generated will be visible and editable in tab ‘Options’, like:
image
This allows the user to make custom overrides. Your code should have some logic to merge the edited values with your generated values.

Hope this gives you an idea to start with…

1 Like

@matt-j-m, It’s your lucky day…

Storing data
The following steps will create a separate Admin page for editing authors, whereby the authors are stored in ‘/user/data/authors/authors.yaml’:

  • Run $ bin/gpm install devtools

  • Run $ bin/plugin devtools newplugin and answer a few questions. I used name ‘authors’.

  • Add the following folders/files:

     plugins/authors
      └── admin
        ├── pages
        │   └── authors.md
        └── templates
            └── authors.html.twig
    
  • Add the following to ‘plugins/authors/admin/pages/authors.md’

    Click to show 'authors.md
    ---
    title: Authors
    
    access:
        admin.guestbook: true
        admin.super: true
    form:
      name: authors
      action: '/authors'
      template: authors
      refresh_prevention: true
    
      fields:
        authors:
          type: list
          display_label: false
          collapsed: true
          style: vertical
          help: "Add or edit author details"
          data-default@: ['\Grav\Plugin\AuthorsPlugin::getAuthors']
    
          fields:
            .name:
              type: text
              size: large
              label: Name
              validate:
                required: true
            .label:
              type: text
              size: large
              label: Taxonomy Label
              validate:
                pattern: "[a-z][a-z0-9_\-]+"
                message: "Use all lowercase letters and replace spaces with hyphens."
                required: true
      buttons:
        submit:
            type: submit
            value: Submit
            classes: button
    ---
    
  • Add the following to ‘plugins/authors/admin/templates/authors.html.twig’

    Click to show authors.html.twig
    {% extends 'partials/base.html.twig' %}
    
    {% block titlebar %}
      <h1><i class="fa fa-fw fa-book"></i>Authors</h1>
    {% endblock %}
    
    {% block content %}
      {% include "forms/form.html.twig" %}
    
      <script>
        $(document).ready(function(){
          var form = $('#authors');
    
          form.submit(function(e) {
            // prevent form submission
            e.preventDefault();
    
            // submit the form via Ajax
            $.ajax({
              url: form.attr('action'),
              type: form.attr('method'),
              dataType: 'html',
              data: form.serialize(),
              success: function(result) {
                  $('.single-notification').removeClass('hidden').html('Successfully saved');
              }
            });
          });
        });
      </script>
    {% endblock %}
    
  • Add/update the following code in ‘plugins/authors/authors.php’:

    Click to show 'authors.php
    use Composer\Autoload\ClassLoader;
    use Grav\Common\Plugin;
    use RocketTheme\Toolbox\File\File;
    use Symfony\Component\Yaml\Yaml;
    
    class AuthorsPlugin extends Plugin
    {
      protected static $authorsFile = DATA_DIR . 'authors/authors.yaml';
      protected $route = 'authors';
      ...
      public static function getAuthors()
      {
        $fileInstance = File::instance(self::$authorsFile);
    
        if (!$fileInstance->content()) {
          return;
        }
    
        return Yaml::parse($fileInstance->content());
      }
    
      public static function saveAuthors(array $authors)
      {
        $file = File::instance(self::$authorsFile);
        $file->save(Yaml::dump($authors));
    
        echo json_encode('Saved');
      }
      ...
      public function onPluginsInitialized()
      {
        // Don't proceed if we are in the admin plugin
        if ($this->isAdmin()) {
          $this->enable([
            'onAdminMenu'         => ['onAdminMenu', 0],
            'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
            'onPageInitialized'   => ['onPageInitialized', 0],
          ]);
          return;
        }
        ...
      }
    
      public function onPageInitialized()
      {
        $page = $this->grav['page'];
    
        if ($page->template() === 'authors') {
          $post = $this->grav['uri']->post();
    
          if ($post) {
            $authors = isset($post['data']['authors']) ? $post['data']['authors'] : [];
    
            $file = File::instance(self::$authorsFile);
            $file->save(Yaml::dump($authors));
          }
        }
      }
    
      public function onTwigTemplatePaths()
      {
        $this->grav['twig']->twig_paths[] = __DIR__ . '/admin/templates';
      }
    
      public function onAdminMenu()
      {
        $this->grav['twig']->plugins_hooked_nav['Authors'] = ['route' => $this->route, 'icon' => 'fa-book'];
      }
    }
    
    
  • You should now have the following page in Admin (note the Authors menu item):

No error checking, no docs, no nothing, but I hope it gives you a rough idea of the possibilities…

Now I can go off and finally add this functionality to my own plugins… :wink:

1 Like

@pamtbaau thanks again for your help and guidance. I’ve made a number of changes based on your suggestions and now I have a new set of questions :slight_smile:

You can see the updated code on the develop branch: https://github.com/matt-j-m/grav-plugin-aura/tree/develop

The following is always returning false for some reason, so it’s currently commented out for testing. It now throws an error when trying to save anything else through the admin of course (like a config) but it allows the rest to work when saving a page:

if (!$event['object'] instanceof Page) {
    return;
}

I followed the doc you mentioned for extending the blueprint via onGetPageBlueprints, but I can’t get it to work. Not sure what I’m doing wrong there. I’ve left the code in, but commented out in case you can see something I can’t.

Other than above 2 hiccups everything seems to work pretty well!

So now any changes to the Metadata made on the Options tab will be overridden by what’s in the Aura tab. I’m mostly ok with that as I think the user is gaining more than they’re losing by using Aura. It does seem a little clunky though that there are now fields that appear to be editable but aren’t. When I have some more time to look at this I may explore other options… I wonder if those fields could be hidden or at least set to read only, or if there is a way to manipulate the cached version of the page directly, or if I should perhaps just use a twig template to output all the metadata and ask users to include the snippet in their base.html.twig…

Anyway, thank you very much for you help so far! Looking forward to some feedback on the overall restructure. Cheers.

@matt-j-m,

  • if (!$event['object'] instanceof Page) {
    You didn’t declare the use of ‘Page’.
    By the way, didn’t ‘Page’ get a red wavy/squiggly underline in your editor with an error message ‘Undefined type’ or something alike?
  • Extending page template
    • Rename ‘/blueprints/aura.yaml’ to ‘/blueprints/default.yaml’.
      When using name ‘aura.yaml’ the extension only shows in the page editor when editing a page of type ‘aura.md’.
    • Add the following to the top to inherit from the default ‘default.yaml’.
      title: Aura
      '@extends':
          type: default
          context: blueprints://pages
      
  • Removing option ‘metadata’ from tab 'Options’
    In your blueprint, define field ‘header.metadata’ in the same position as it is defined in ‘system/blueprints/pages/default.yaml’. Give it the propery unset@: true (docs)
    form:
      fields:
        tabs:
          fields:
            options:
              type: tab
              title: PLUGIN_ADMIN.OPTIONS
    
              fields:
                publishing:
                  type: section
                  title: PLUGIN_ADMIN.PUBLISHING
                  underline: true
    
                  fields:
                    header.metadata:
                      unset@: true
            aura:
              ...
    
  • Alternative to removing field 'metadata’
    You could leave the ‘metadata’ field for the user to edit and use array_merge to merge the incoming metadata (from the form) into your generated metadata. This way the user can override your defaults and you add non-existing ones.
    $metadata = [];
    // Save metadata
    foreach ($this->webpage->metadata as $tag) {
        if (array_key_exists('property', $tag)) {
            $metadata[$tag['property']] = $tag['content'];
        } else if (array_key_exists('name', $tag)) {
            $metadata[$tag['name']] = $tag['content'];
        }
    }
    
    $page->header()->metadata = array_merge($metadata, $page->header()->metadata);
    
  • Suggestion:
    Extract all metadata generation code into its own class to cleanup the main class. I can’t keep oversight with 500+ lines (but that’s just my limitation…)
1 Like

@pamtbaau

  • Sorry, yes, forgot to declare Page and didn’t happen to notice it was highlighted in IDE :roll_eyes:

  • Renaming blueprint to default.yaml worked! I suspected it was something like this so I had tried naming it item.yaml to match my page template. I realise now that probably didn’t work because of the “type: default” line at the top of the blueprint. Anyway, I would have complained that that was too template specific, so I’m glad default.yaml works!

  • Input fields

    • I had read about unsetting fields via blueprint previously but as I suspected it hides the entire metadata input, which prevents the user entering any additional meta tags via the GUI. I was not too keen on removing that functionality, ideally I just wanted to hide the Aura generated meta inputs.

    • I then tried your suggestion of merging fields instead, which worked, and as you said the user could override the Aura generated meta tags if they wished. The new problem though was that if the user wanted to change something on the Aura tab later, the changes were saved in the aura frontmatter, but did nothing to the actual metadata frontmatter.

    • I then tried switching the two, so that Aura would take precedence, in the hopes that you could still edit the metadata frontmatter in Expert mode, but nope, it still gave the Aura inputs precedence.

    • Finally I had the idea to combine both approaches. I have hidden the metadata inputs from the Options tab, and added them to the Aura tab. This allows you to enter “non-Aura” metadata AND override the Aura generated meta tags by manually entering them on the Aura tab. I think this is the cleanest solution and I’m really happy with it now.

  • Metadata generation has been shifted out into a separate class which will hopefully save some brain cycles :slight_smile:

Thanks again for all your help. Getting close to a stable release I think!