🎉 10 years since 1.0! Celebrate with us →
Skip to content

Programmable blueprints

Intro

Blueprints for Panel forms are usually static YAML files. While this is totally fine for most use cases, there are some situations where you might wish there was a more dynamic way of creating blueprints.

In a Kirby plugin, we can register blueprints inside the blueprints array as key/value pairs, where the key is the name of the blueprint. As values we can either provide the path to a file or we can assign an array or a callback. While an array is static, the callback approach allows us to create blueprint plugins dynamically without sacrificing performance.

This approach works for complete page/user/file blueprints as well as for partial blueprints like tabs, fieldgroups etc. and opens up many new possibilities.

Dynamic blueprints can be risky if they handle unvalidated or untrusted user data. This recipe assumes that all handled content is trusted.

We don't have access to the current page object in the PHP blueprint files, and therefore have to hard-code the pages we want to fetch via PHP. So the possibilities we have with this type of setup are not endless but useful in certain situations.

Prerequisites

Let's start with a new plugin folder called programmable-blueprints in /site/plugins/. Inside that folder, let's create the obligatory index.php file, where we will register all blueprints for this recipe.

/site/plugins/programmable-blueprints/index.php
<?php

use Kirby\Cms\App as Kirby;

Kirby::plugin('cookbook/programmable-blueprints', [
    'blueprints' => [
        // here we will add the blueprints
    ]
]);

Load different blueprints per user

With programmable blueprints it is now much easier to load different blueprints per user or user role, which is for example useful if you want to hide fields/sections/tabs from certain users/roles.

Let's assume we had multiple user roles, e.g. the default admin role and other roles like editor etc., and we wanted to provide a different site.yml blueprints for each role.

To achieve this, we create a subfolder /blueprints with two files site.admin.yml and site.editor.yml.

Then we register a site blueprint and assign the two files to use conditionally:

/site/plugins/programmable-blueprints/index.php
<?php

use Kirby\Cms\App as Kirby;

Kirby::plugin('cookbook/programmable-blueprints', [
    'blueprints' => [
        'site' => function () {
            if (($user = kirby()->user()) && $user->isAdmin()) {
                return Data::read(__DIR__ . '/blueprints/site.admin.yml');
            } else {
                return Data::read(__DIR__ . '/blueprints/site.editor.yml');
            }
        },
    ]  
]);

We use Data::read() to read the yaml files into an array.

Next we fill the blueprints with some content. For this example, let's keep them simple:

The site.admin.yml get's two tabs, one to access all pages of the site, the second for general meta settings which should not be editable by non-admin users.

/site/plugins/programmable-blueprints/blueprints/site.admin.yml
title: Site

tabs:
  overview:
    label: Page Overview
    columns:
      - width: 1/2
        sections:
          albums:
            type: pages
            headline: Photography
            parent: kirby.page("photography")
            size: small
            info: "{{ page.images.count }} image(s)"
            layout: cards
            template: album
            empty: No albums yet
            image:
              query: page.cover
              cover: true
              ratio: 3/2
      - width: 1/2
        sections:
          notes:
            type: pages
            headline: Notes
            parent: kirby.page("notes")
            info: "{{ page.published }}"
            template: note
            empty: No notes yet
            sortBy: date desc
            image:
              query: page.cover
              cover: true
            ratio: 3/2
          pages:
            type: pages
            create: default
            templates:
              - about
              - home
              - default
  settings:
    label: Settings
    icon: gear
    sections:
      fields:
        type: fields
        fields:
          metaTitle:
            label: Meta Title
            type: text
          metaDesc:
            label: Meta Description
            type: text
          ogImg:
            label: Open Graph Image
            type: files
            query: site.images
          ## ... more fields

In the site.editor.yml file, we don't need tabs and only include the pages overview without the meta data settings:

/site/plugins/programmable-blueprints/blueprints/site.editor.yml
title: Site

columns:
  - width: 1/2
    sections:
      albums:
        type: pages
        headline: Photography
        parent: kirby.page("photography")
        size: small
        info: "{{ page.images.count }} image(s)"
        layout: cards
        template: album
        empty: No albums yet
        image: icon

  - width: 1/2
    sections:
      notes:
        type: pages
        headline: Notes
        parent: kirby.page("notes")
        info: "{{ page.published }}"
        template: note
        empty: No notes yet
        sortBy: date desc
        image:
          query: page.cover
          cover: true
        ratio: 3/2
      pages:
        type: pages
        create: default
        templates:
          - about
          - home
          - default

To check if everything works, delete the site.yml file from /site/blueprints. Then log in as admin. Create a new editor user role as outlined in the docs, create a new user with this role and log in with this user. You should now see the simplified site view.

Page blueprint with filtered pages sections

A typical situation that has come up multiple times in support are dynamic numbers of pages sections that filter pages by category. Since the number of categories is usually not set in stone from the outset but new categories might be added any time, we cannot possibly set up all sections in advance. In such a use case, being able to create those sections dynamically is a big plus.

For this example we register a pages blueprint called notes in our index.php and include the notes.php file which we have yet to create.

/site/plugins/programmable-blueprints/index.php
<?php

use Kirby\Cms\App as Kirby;

Kirby::plugin('cookbook/programmable-blueprints', [
    'blueprints' => [
        'pages/notes' => function ($kirby) {
            return include __DIR__ . '/blueprints/pages/notes.php';
        },
    ]
]);

Now create a new subfolder pages in the /blueprints folder, and inside the pages folder the notes.php file with the following code:

/site/plugins/programmable-blueprints/blueprints/pages/notes.php
<?php

$sections = [];
if ($page = page('notes')) {
    // create a new section array for each unique tag plucked from the children pages
    foreach ($page->children()->pluck('tags', ',', true) as $tag) {
        $sections['section_' . $tag] = [
            'type'     => 'pagesdisplay',
            'headline' => 'Pages with tag ' . $tag,
            // note the required quotes around the `$tag` variable
            'query'    => "page.children.filterBy('tags', '". $tag . "', ',')", 
        ];
    }
}

// create the array for the page blueprint with two columns
$yaml = [
    'title'   => 'Notes',
    'options' => [
        'changeStatus' => false,
        'changeSlug'   => false
    ],
    'columns' => [
        'sidebar' => [
            'width'    => '1/3',
            'sections' => [
              'drafts' => [
                'type'     => 'pages',
                'headline' => 'Drafts',
                'status'   => 'drafts',
                ]
            ],
        ],
        'main' => [
            'width'    => '2/3',
            'sections' => $sections, // dynamically generated sections from above
        ]
    ]
];

return $yaml;

As mentioned in the prerequistes, this example requires the Pages Display plugin.

To use this blueprint, remove the original /site/blueprints/pages/notes.yml file.

As a result, we will end up with as many sections as we have tags (and in this example, there will be only one page per section, because all tags are only used once). For the screenshot I've reassigned the tags, so that the result looks less silly:

Note that we have set changeSlug to false, because the blueprint would stop working if the page was renamed. If you want to keep the option to change the slug, you can work with a page ID that doesn't change instead.

Field group with dynamic fields

The same kind of logic will work for dynamic fields, for example if you wanted to create a number of pages fields where users can select one (or more) pages from each parent.

Again, we first register the new blueprint:

/site/plugins/programmable-blueprints/index.php
<?php

use Kirby\Cms\App as Kirby;

Kirby::plugin('cookbook/programmable-blueprints', [
    'blueprints' => [
        'pages/notes'        => function($kirby) {
            return include __DIR__ . '/blueprints/pages/notes.php';
        },
        'fields/multifields' => function($kirby) {
            return include __DIR__ . '/blueprints/fields/multifields.php';
        }
    ],
]);

And then create a fieldgroup called multiselects in the given path:

/site/plugins/programmable-blueprints/blueprints/fields/multifields.php
<?php

$fields = [];

foreach (site()->children()->filterBy('template', 'in', ['notes', 'photography']) as $page) {
    $fields[$page->slug()] = [
        'label' => $page->title()->value(),
        'type'  => 'pages',
        'query' => 'site.find("' . $page->slug() . '").children',
        'min'   => 1,
        'max'   => 1,
    ];
}

return [
    'type'   => 'group',
    'fields' => $fields,
];

In a page/user/file blueprint, we can now use this field group like this:

fields:
  type: fields
  fields:
    extends: fields/multifields

Multilang field options

When you use language keys in your blueprints to translate field labels etc., these translations are shown based on the selected user language, not based on the currently selected content language.

This example from the docs…

fields:
  category:
      label:
        en: Category
        de: Kategorie
      type: select
      options:
        architecture:
          en: Architecture
          de: Architektur
        photography:
          en: Photography
          de: Fotografie
        design:
          en: Design
          de: Design

will therefore not switch to the German translation when we switch the content language to German, but when a user selects German as their interface language.

But often, users expect to see the translated option labels when they switch the content language. So, how can we achieve this?

Let's register a new field blueprint in our index.php:

/site/plugins/programmable-blueprints/index.php
Kirby::plugin('cookbook/programmable-blueprints', [
    'blueprints' => [
        'pages/notes'        => function($kirby) {
            return include __DIR__ . '/blueprints/pages/notes.php';
        },
        'fields/multifields' => function($kirby) {
            return include __DIR__ . '/blueprints/fields/multifields.php';
        },
        'fields/category' => function($kirby) {
            return include __DIR__ . '/blueprints/fields/category.php';
        },

    ],
]);

Then we create a new file category.php in the corresponding folder with the following code:

/site/plugins/programmable-blueprints/blueprints/fields/category.php
<?php

use Kirby\Toolkit\I18n;

return [
    'label' => [
      'en' => 'Category,
      'de' => 'Kategorie'
    ],
    'options' => [
      'architecture' => I18n::translate('architecture', null, kirby()->language()->code()),
      'photography'  => I18n::translate('photography', null, kirby()->language()->code()),
      'design'       => I18n::translate('design', null, kirby()->language()->code())
    ],
    'type' => 'select',
];

In our page blueprint, we can now replace the category field definition:

fields:
  category:
    extends: fields/category

Assigning filtered blueprints to sections

When you want to assign allowed templates to a pages section, you have to list them out one by one. Not with our programmatic approach.

For this example, we register a new section blueprint:

/site/plugins/programmable-blueprints/index.php
Kirby::plugin('cookbook/programmable-blueprints', [
    'blueprints' => [
        'pages/notes'        => function($kirby) {
            return include __DIR__ . '/blueprints/pages/notes.php';
        },
        'fields/multifields' => function($kirby) {
            return include __DIR__ . '/blueprints/fields/multifields.php';
        },
        'fields/categories' => function($kirby) {
            return include __DIR__ . '/blueprints/fields/categories.php';
        },
        'sections/notes'      => function($kirby) {
            return include __DIR__ . '/blueprints/sections/notes.php';
        },
    ],
]);

And create /blueprints/sections/notes.php with the following code:

/site/plugins/programmable-blueprints/blueprints/sections/notes.php
<?php

$blueprints = kirby()->blueprints();
$blueprints = array_filter($blueprints, function($blueprint) {
    return in_array($blueprint, ['error', 'home', 'about']) === false;
});

return [
    'type'      => 'pages',
    'parent'    => "kirby.page('notes')",
    'headline'  => 'Notepages',
    'info'      => '{{ page.files.count }}',
    'templates' => $blueprints,
];

First we fetch all registered page blueprints into $blueprints and then remove all the unwanted ones from the array. The remaining blueprints we assign to the section's templates prop.

We can now reuse this section in our templates like normal.

&quot;/site/blueprints/pages/notes.yml'
Title: Notes

#...

sections:

  drafts:
    extends: sections/notes
  listed:
    extends: sections/notes

If we need the same set of blueprints for multiple sections, we can return the filtered set from its own blueprint and include it in our PHP pages/sections blueprints:

From blueprints.php we now only return an array of blueprints:

/site/plugins/programmable-blueprints/blueprints/options/blueprints.php
<?php

$blueprints = kirby()->blueprints();
return array_filter($blueprints, function($blueprint) {
    return in_array($blueprint, ['error', 'home', 'about']) === false;
});

And load this array for example in the notes.php section:

/site/plugins/programmable-blueprints/blueprints/sections/notes.php
return [
    'type'      => 'pages',
    'parent'    => "kirby.page('notes')",
    'headline'  => 'Notepages',
    'info'      => '{{ page.files.count }}',
    'templates' => include __DIR__ . '/../options/blueprints.php',
];

We don't have to register options/blueprints.php, because we cannot use it like a normal extension but have to include it explicitly.

Tabs

In this last example, we register a tab blueprint.

/site/plugins/programmable-blueprints/index.php
<?php

use Kirby\Cms\App as Kirby;

Kirby::plugin('cookbook/programmable-blueprints', [
    'blueprints' => [
        'pages/notes'        => function($kirby) {
            return include __DIR__ . '/blueprints/pages/notes.php';
        },
        'fields/multifields' => function($kirby) {
            return include __DIR__ . '/blueprints/fields/multifields.php';
        },
        'fields/categories' => function($kirby) {
            return include __DIR__ . '/blueprints/fields/categories.php';
        },
        'sections/notes'      => function($kirby) {
            return include __DIR__ . '/blueprints/sections/notes.php';
        },
        'tabs/meta'           => function($kirby) {
            return include __DIR__ . '/blueprints/tabs/meta.php';
        },
    ],
]);

Let's assume we already had a basic yml blueprint that we wanted to extend programmatically.

This is our basic blueprint, which we put into the blueprints/tabs folder for simplicity.

/site/plugins/programmable-blueprints/blueprints/tabs/basic-meta.yml
label: Meta
icon: search

sections:
  basicMeta:
    type: fields
    fields:
      metaHeadline:
        label: Basic Meta Information
        type: headline
        numbered: false
      metaTitle:
        label: Title (Override)
        type: text
      metaDescription:
        label: Description
        type: text
      metaCanonicalUrl:
        label: Canonical URL
        type: url
      metaAuthor:
        label: Author/s
        type: text
      metaImage:
        label: Image
        type: files
        multiple: false
      metaPhoneNumber:
        label: Phone Number
        type: text

How can we extend this via PHP? In the same folder, let's create the meta.php file we already registered above, and add the following code:

/site/plugins/programmable-blueprints/blueprints/tabs/meta.php
<?php

use Kirby\Data\Data;

$basicBlueprint = Data::read(__DIR__ . '/seo-basic.yml', 'yaml');
$basicBlueprint['label'] = 'SEO';
$fields = $basicBlueprint['sections']['basicMeta']['fields'];
$basicBlueprint['sections']['basicMeta']['fields'] = array_merge($fields, [
    'twitterHandle' => [
        'label' => 'Twitter handle',
        'type' => 'text',
    ]
]);

return $basic_blueprint;

First, we read the file we want to extend into an array with Data::read().

Then we change the tab label and add a new field in the basicMeta section by merging the original fields array with the new twitterHandle field.

We can now add this tab in our page blueprints:

title: Some page blueprint

tabs:
  tab1:
    label: Content
    # code for tab1 here
  tab2: tabs/meta

If you want to add the new field at another position in the array, or remove another item from the original field list, you can achieve this using PHP's array functions.

Recap

In this recipe we looked into creating different types of blueprints dynamically, which can be helpful in several use cases. And if you don't like YAML, it might even become your favorite way of creating blueprints. Despite some limitations, you can still get pretty creative with this approach.

If you have other great ideas how to use this feature, let us know.

Author