Writing a plugin

In this tutorial, we will create a plugin that automatically creates an XML sitemap which we could submit to google. The plugin uses all features that you might need to build your own plugin, so it should give you a fairly good idea of how to realize your ideas.

This tutorial assumes you read the other pages that are hold within the plugin architecture section of blaze's documentation.

The final plugin is freely available for download and usage at blaze's website, but if you are interested in writing your own plugins, then this tutorial is a good start.

What does this tutorial cover?

We will create a basic plugin file and make blaze recognize our plugin. We then will include functionality which is bound to events. We finally will create a backend view and a UI action handler to fine tune our plugins's behaviour.

Preparations

In order to be able to follow the steps of this tutorial, you should have a working blaze site where you can immediately test your code. This is important to see what's going on.

You also should be prepared to read an understand OOP, to understand where to add methods to classes and so on.


Let's go

To get started, we will create a folder named 'sitemap' (that's what we will call the plugin) within blaze's 'plugin' directory. Within that folder, we create two files.

The first one is our main plugin class, named 'sitemap.php'. For now, this is what it looks like:

<?php
    namespace blaze\plugins\sitemap;

    class sitemap extends \blaze\src\prototype\Plugin
    {   
        public function helloWorld()
        {
            echo 'Hello World.';
        }
    }
?>

Currently it just holds an empty class which extends blaze's plugin prototype. To have some functionality that we can call to test if the plugin is properly loaded, we have added a little 'helloWorld' method.

To make blaze load our plugin, we do have to create the plugin's definition file, named 'plugin.json'. This is what it will hold:

{
    "info": {
        "name": "sitemap",
        "usedb" : false
    }
}

This is the smallest possible 'plugin.json' that might work. It will make blaze load our plugin.

The "info" section of the file (until now the only section) will always hold basic information about the plugin. Both given parameters are mandatory.

"name" must hold the name of the plugin (which must also be the plugin class' filename).

"usedb" must hold true or false, and indicates whether our plugin needs a database or not. We want our plugin to work without one, so we set this to false.

Installing the plugin

We now created a very small plugin. It holds exactly one method, which is fine for now. To see if we made errors until now and if blaze properly recognizes our plugin, we will install it.

Navigate to yoursite.com/blaze (replacing 'yoursite.com' with your site's domain) and login to the backend (if needed). In the backend, navigate to 'plugins'.

You should see our sitemap there. It is not currently installed (which is correct, we have not installed it yet) and the name is properly showing 'sitemap'. Everything is fine.

Compare our plugin to the other ones that are already there. You'll notice, that our plugin is missing a description, and that no author is displayed. So let's add these information to our 'plugin.json'. We'll extend the file to hold this:

{
    "info": {
        "name": "sitemap",
        "usedb" : false,
        "description" : "This plugin will automatically create an XML sitemap which can be submitted to Google's searching index.",
        "author" : "moay"
    }
}

Now reload the backend so it can reload the 'plugin.json'. It should now properly show this information. Done. Let's install and activate the plugin. Do so by using the 'Options' button in the top right corner of the plugin box in the backend.

Testing that everything works until now

There is not much to test, but let's try to use our little 'helloWorld' function.

Create an empty frontend page (I'll call mine 'test.php') and place it in your websites root folder. Add this content:

<?php
    $blaze->sitemap->helloWorld();
?>

Now visit this frontend page (if you named it 'test.php', you will be able to visit it at yoursite.com/test) and see what happens.

You will either see 'Hello World.' printed in fine letters or you'll get an error message telling you what went wrong.

Everything went right? Well done, you just created a working blaze plugin. Let's add some useful functionality.


Indexing functionality

We'll keep this part of the tutorial short as it is not primarily plugin related but has to take care of the pure php functionality.

In order to properly list our files, we need a plugin method to index the routes. It should index php and html files only and ignore blaze's core folder and all other file types.

We'll add two methods to our main plugin file. This is how our indexing method and the sorting method look like:

/**
 * Method which will build the pages fileindex excluding all files we don't want to be displayed in our sitemap
 * @return array holding our files as strings useable for the sitemap 
 */
private function buildIndex()
{
    // Basepath of the files to index
    $basepath = realpath(__DIR__.'/../../../.');

    // Filetypes that should be allowed
    $allowedfiletypes = array('php', 'html');

    // Make sure that blaze's folder will not be indexed
    $forbiddenfiles = array('blaze');

    // Adjust forbidden files to make them comparable 
    foreach($forbiddenfiles as $i => $f)
    {
        $forbiddenfiles[$i] = $basepath . '/' . $f;
    }

    // This will hold our files to add to the sitemap
    $fileindex = array();

    // Iterate through files
    foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($basepath)) as $file)
    {
        $validfile = false;
        foreach($allowedfiletypes as $filetype)
        {   
            // Check filetype and make sure it's a file of correct filetype
            if(strpos($file->getFilename(), '.'.$filetype) !== false 
                && count(explode('.', $file->getFilename()))>1)
            {
                $validfile = true;
            }
        }
        if($validfile){
            foreach($forbiddenfiles as $forbidden)
            {
                if(strpos($file->getPathname(), $forbidden) === 0)
                {
                    $validfile = false;
                }
            }
        }

        // Add to index
        if($validfile)
        {
            $fileindex[] = str_replace($basepath, '', $file->getPathname());
        }
    }

    // We want to replace all files that are called index.php or index.html
    foreach($fileindex as $i => $path)
    {
        $fileindex[$i] = str_replace('index.php', '', $path);
        $fileindex[$i] = str_replace('index.html', '', $fileindex[$i]);
    }

    // We want to sort the index so that it is in a more or less correct order. For simplicity, it will be ordered by filname length
    usort($fileindex, array(__CLASS__,'sortIndex'));

    return $fileindex;
}

/**
 * Sorting method which will sort the fileindex properly, use only with usort()
 * @param  string $a filename 1
 * @param  string $b filename 2
 * @return integer    the sorting result
 */
private static function sortIndex($a,$b) {
    if(count(explode('/',$a)) != count(explode('/',$b)))
    {
        return count(explode('/',$a)) - count(explode('/',$b));
    }
    return strlen($a) - strlen($b);
}

This method will provide an array holding the files which should appear in our sitemap. You see that both methods are private. They both are not intended to be accessed directly but to provide data for another method.

We now need a method that uses the provided data to build an xml file which we can provide to Google. There are really cool libraries for this out there. Let's use one of them to properly create our sitemap file. We'll take the xml sitemap builder class from xzander/sitemap-php which can be found on github. Just download the entire repository and copy the folder 'src' which is contained in the downloaded zip file into our plugin folder 'sitemap'.

As this part of the functionality also is not primarily plugin related, we will not entirely explain what happens. Read the in code comments you'll find. Let's add the sitemap creating method to our 'sitemap.php':

/**
 * Method which creates the file sitemap.xml which we can provide to Google's index.
 * @return void
 */
public function createSitemap()
{   
    // Get file index
    $files = $this->buildIndex();

    // Set basepath for file transformal times
    $basepath = realpath(__DIR__.'/../../../.');

    // Get pages url
    $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
    $baseurl = $protocol . $_SERVER['HTTP_HOST'];

    // Create the sitemap
    $sitemap = new \SitemapPHP\Sitemap($baseurl);
    $sitemap->setPath($basepath . '/');

    foreach($files as $file)
    {
        $sitemap->addItem(new \SitemapPHP\Url($file, '0.5', 'weekly', date(DATE_W3C, filemtime($basepath . $file))));
    }
    // Put out the sitemap
    $sitemap->endSitemap();
}

To make this method work, we will have to add the following things to the top of our sitemap.php:

require __DIR__.'/src/SitemapPHP/Sitemap.php';
require __DIR__.'/src/SitemapPHP/SitemapIndex.php';
require __DIR__.'/src/SitemapPHP/Url.php';
require __DIR__.'/src/SitemapPHP/Util.php';

Now, to test if everything works, we call the createSitemap() function in our test file.

<?php
    $blaze->sitemap->createSitemap();
?>

Visit yourdomain.com/test once and then visit yourdomain.com/sitemap.xml. If everything went well, you should now see a fully working sitemap which was automatically created.

Binding the method to the 'cron' event

Let's now make sure, that the sitemap will be created and updated automatically. This helps google to index our site properly.

Let's use the 'cron' event to trigger the 'createSitemap()' function. To do this, we will bind the method to the event within the constructor method. Create a __construct() method in the main plugin class.

function __construct()
{
    $this->bindEvent('cron', 'createSitemap');
}

This piece of code will make blaze automatically call the createSitemap() method every time the 'cron' event is triggered. This should be once a day if you use the softcron feature or in a given interval you may have setup using a real cronjob.


Creating a backend view

We could stop at this point, but the plugin cannot be controlled very well until now. Maybe you want to exclude files from the index or to manually rebuild the index? Let's create a backend view to allow manual index rebuilding or file exclusion.

Telling blaze that there is a backend view

Before we will be able to see anything, we will have to tell blaze that we are about to create a backend view. As we want to build a control panel for the sitemap plugin, we call our view 'control.twig'. To tell blaze about the view, we need to change the 'plugin.json'. Edit it to match this:

{
    "info": {
        "name": "sitemap",
        "usedb" : false,
        "description" : "This plugin will automatically create an XML sitemap which can be submitted to Google's searching index.",
        "author" : "moay"
    },
    "sidebar": {
        "admin" : [
            {
                "path":"control",
                "label":"Sitemap",
                "icon":"list"
            }
        ]
    }
}

We added the "sidebar" section. You'll see the "admin" section which is contained and holds our backend view's details. There are three parameters that you can provide for each view.

Now visit the backend. You'll see our plugin appearing in the sidebar as "Sitemap". That's fine, we told blaze to do so. But when you click on it, you will notice, that blaze will redirect you to the main settings panel. That's also fine, because we have not created our view file yet. Let's do so.

Create a folder 'views' within our main plugin folder. Create a new file within it and name it 'control.twig'. To see what happens, let's place this content in the file:

{% extends 'backend/apppage.twig' %}

{% block maincontent %}
    Testcontent
{% endblock %}

Now visit the view in the backend again. If you can read the wonderful word 'Testcontent' in the view, everything is ok. If not, you should take a look at what you did and compare your code.

Making the view translatable

Before we really start to fill the view with content, let's make it translatable. That's really easy. Add a new folder to the plugin directory and name it 'locale'. Place a new file 'en.json' in it and put the following content inside it.

{
    "sitemapcontrol" : {
        "sidebarlabel" : "Sitemap",
        "testcontent" : "Testcontent"
    }
}

This is the basic way to make backend views translatable. Doing it this way will enable you to make use of blaze's methods of translation, and the fully automatic language selection based on the backend settings. Let's make use of these features.

Step 1: we want to replace the 'Sidebar' in the 'plugin.json' with the translated version. Make the "admin" section match this:

    "admin" : [
        {
            "path":"control",
            "label":"sitemapcontrol.sidebarlabel",
            "icon":"list"
        }
    ]

Reload the backend and watch out for changes. You shouln't see any changes which is a good thing. You may test out changes in the 'en.json' to see that the translation handler really works.

For the view file, we need another method. To use the translation component in the backend view, make use of blaze's translator like this:

{% extends 'backend/apppage.twig' %}

{% block maincontent %}
    {{ blaze.locale.translate('sitemapcontrol.testcontent') }}
{% endblock %}

Keep in mind that this is a twig view, so you'll need to adapt the correct syntax when working with php objects and variables.


Filling the view with content

For starters, let's display the files that are indexed. blaze provides a super easy way to provide data for our view. Let's do so. We need a method in our plugin that provides the data we need in the view. Add the following method to the main plugin class in the 'sitemap.php':

/**
 * Method to provide data to the backend view
 * @return array holding the data to provide
 */
public function provideControlViewData()
{
    $data = array(
        'fileindex' => $this->buildIndex()
    );
    return $data;
}

Additionally, we need to tell blaze, that this method we added should provide the data for our view. That's easy. Let's extend the __construct() method to define the data provider.

function __construct()
{
    $this->bindEvent('cron', 'createSitemap');
    $this->setDataProvider('control', 'provideControlViewData');
}

Now that we are able to provide access data via the view, let's display the indexed files using twig and UIkit, on which the entire backend is built.

{% extends 'backend/apppage.twig' %}

{% block maincontent %}
    <h2>{{ blaze.locale.translate('sitemapcontrol.headline') }}</h2>
    {% for file in sitemap.fileindex %}
        <div class="uk-panel uk-panel-box">
            {{ file }}
        </div>
    {% endfor %}
{% endblock %}

Additionally, we will add "headline" : "Indexed files" to our en.json file.

Now visit the backend view again and see what happened. It should now hold an overview of all files that are held in the sitemap.


Adding functionality to the backend view

We could stop here, now that we know everything works fine. But let us add one bit of functionality to the backend view.

Maybe you want to exclude files from the index. There should be a button to do so. Let's add it to the view:

{% extends 'backend/apppage.twig' %}

{% block maincontent %}
    <h2>{{ blaze.locale.translate('sitemapcontrol.headline') }}</h2>
    {% for file in sitemap.fileindex %}
        <div class="uk-panel uk-panel-box">
            <div class="uk-panel-title">{{ file }}</div>
            <button class="uk-button" type="button" data-sitemap-action="exclude" data-sitemap-string="{{ file }}">{{ blaze.locale.translate('sitemapcontrol.exludefilebutton') }}</button>
        </div>
    {% endfor %}
{% endblock %}

Let's also add "exludefilebutton" : "Exclude from index" to our en.json in order to give the button a label.

To give our plugin the possibility to exclude files, it needs to store information about which files to exclude. Let's use blaze's settings component for this. Before we can integrate it, we have to create a settings file within our plugin folder. We name it indexconfig.json and place an empty object within it. This is what it should look like:

{
}

We now can provide this file to the plugin to be used as storage location for settings. We will initiate the settings component within the __construct() method of our main plugin file. It should look like this:

function __construct()
{
    $this->bindEvent('cron', 'createSitemap');
    $this->setDataProvider('control', 'provideControlViewData');

    // Setup a settings instance for this plugin
    $this->settings = new \blaze\src\SettingsHandler(__DIR__ . '/indexconfig.json');
}

This gives our plugin a new object config which we will use to store the file exclusion settings.

Our plugin needs a method to exclude files. Let's create it and add it to our sitemap.php.

/**
 * Method to exclude files from index
 * @param  string $path The path to exclude
 * @return boolean
 */
public function excludeFile($path){
    // make sure that $this->settings holds an array for excluded files
    if(is_array($this->settings->get('index.excludedfiles')))
    {
        // Load the files to exclude
        $excludedfiles = $this->settings->get('index.excludedfiles');
    }
    else
    {
        $excludedfiles = array();
    }

    // Add $path if it is not contained
    if(!in_array($path, $excludedfiles))
    {
        $excludedfiles[] = $path;
        $this->settings->set('index.excludedfiles', $excludedfiles);
        return true;
    }
    return false;
}

In order to bind our exclusion buttons to our newly created function, we need a UI action handler for our plugin. Let's create one. We will make a new file called 'uiaction.php' within the plugin folder. This is what it contains:

<?php
    namespace blaze\plugins\sitemap;

    class uiaction extends \blaze\src\prototype\UIActionHandler
    {   
        function __construct()
        {
            $this->sitemap = new sitemap();
        }

        /**
         * Method to bind excluding button from the backend view
         * @return boolean
         */
        public function excludefile()
        {
            // Let's make sure that only admin users can use this method
            $this->requireAdmin();

            // Get the file to exclude
            $filetoexclude = \fRequest::get('filetoexclude');
            return $this->sitemap->excludeFile($filetoexclude);
        }
    }
?>

Look at the file. It contains one method excludefile() which we can use in the backend. It makes use of the method requireAdmin() which comes with the UIActionHandler prototype. It makes sure, that the method will not be executed if the calling user is not logged in as admin.

We still are not able to call this method, we need to tell blaze that we now have a UI action handler. Add this to the first section of the plugin.json:

    "uiactionprovider" : "uiaction.php"

This should go directly after the "author" line.

Done. We can now use the functions we created. Let's do so by using JQuery. blaze uses JQuery, so it is already available. So we just create a new file named controlview.js (we place it in a newly created folder 'assets' within the plugin folder) with this content:

$(function(){
    $('[data-sitemap-action="exclude"]').bind('click', function(){
        var filetoexclude = $(this).attr('data-sitemap-string');
        $.post('/pluginuiaction/sitemap/excludefile', {'filetoexclude':filetoexclude});
    });
});

The path we use to call the UI action handler consists of three elements: /pluginuiaction to tell blaze, that we want to call a plugin's UI action handler. /sitemap to tell blaze, which plugin should be addressed. And finally /excludefile - the method which should be called.

Again, we use the plugin.json file to wire up things. We need to add an 'assets' section to the file to tell blaze that it should add the assets when the view is loaded.

This is, what the plugin.json should now look like:

{
    "info": {
        "name": "sitemap",
        "usedb" : false,
        "description" : "This plugin will automatically create an XML sitemap which can be submitted to Google's searching index.",
        "author" : "moay",
        "uiactionprovider" : "uiaction.php"
    },
    "sidebar": {
        "admin" : [
            {
                "path":"control",
                "label":"sitemapcontrol.sidebarlabel",
                "icon":"list"
            }
        ]
    },
    "assets" : {
        "control" : [
            "assets/controlview.js"
        ]
    }
}

See the "assets" section? Note that you can provide as many different assets as you like for each view. Each view has to be listed by it's filename. You could provide global assets (for ALL backend views, not only those who are related to your plugin) by using "global" instead of a view name.

Now, visit the backend view again. You will see our properly created buttons which we can click. You will see, that you can click the button once, which will make a tiny checkmark appear. This indicates, that the file was properly stored as file to exclude.

If you click it a second or third time, the exclusion method will add it again and return false. This causes the UI action handler to return an error with the error message 'method execution error'. That's not very clear as an error message, so let's make this a bit clearer.

Change the excludefile() method in the UI action handler class to match this:

public function excludefile()
{
    // Let's make sure that only admin users can use this method
    $this->requireAdmin();

    // Get the file to exclude
    $filetoexclude = \fRequest::get('filetoexclude');
    if($this->sitemap->excludeFile($filetoexclude))
    {
        return true;
    }
    return 'File already excluded'; 
}

Try to click an already excluded file again. You'll see an error message that tells a little bit more about why the method could not be executed. Why? Just because we did not return true but a string. This is considered to indidicate an error and is displayed as an error in the backend.

Why does it happen in the first place? Well, as the file is already excluded, we should not use the button any more. It shouldn't even be displayed at all. Let's add this behaviour to our backend view.

In order to let the view know which files are excluded, we have to pass the excluded files via the data providing method. Adapt this function to match this:

public function provideControlViewData()
{
    $data = array(
        'fileindex' => $this->buildIndex(),
        'excludedfiles' => $this->settings->get('index.excludedfiles')
    );
    return $data;
}

Now change the view to match this:

{% extends 'backend/apppage.twig' %}

{% block maincontent %}
    <h2>{{ blaze.locale.translate('sitemapcontrol.headline') }}</h2>
    {% for file in sitemap.fileindex %}
        <div class="uk-panel uk-panel-box">
            <div class="uk-panel-title">{{ file }}</div>
            {% if file not in sitemap.excludedfiles %}
                <button class="uk-button" type="button" data-sitemap-action="exclude" data-sitemap-string="{{ file }}">{{ blaze.locale.translate('sitemapcontrol.exludefilebutton') }}</button>
            {% else %}
                {{ blaze.locale.translate('sitemapcontrol.fileisexcluded') }}
            {% endif %}
        </div>
    {% endfor %}
{% endblock %}

Finally, let's add "fileisexcluded" : "File is excluded from the index." to our language file en.json.

Now revisit the backend page. Beautiful, isn't it? We'd want our backend view to reload the index if an exclusion happened, so let's change the returned value from true to 'reload', which will make the backend view reload after storing the exclusion.

This is, what the excludefile() method in the UI action handler class should finally look like:

<?php
    namespace blaze\plugins\sitemap;

    class uiaction extends \blaze\src\prototype\UIActionHandler
    {   
        function __construct()
        {
            $this->sitemap = new sitemap();
        }

        /**
         * Method to bind excluding button from the backend view
         * @return boolean
         */
        public function excludefile()
        {
            // Let's make sure that only admin users can use this method
            $this->requireAdmin();

            // Get the file to exclude
            $filetoexclude = \fRequest::get('filetoexclude');
            if($this->sitemap->excludeFile($filetoexclude))
            {
                return 'reload';
            }
            return 'File already excluded'; 
        }
    }
?>

Test the backend view again.

We're nearly done. There is one last thing to do: the excluded files are now stored in the settings file, but the indexing method does not take this seetings object into account. So let's finally change the sitemap creating method to match this:

/**
 * Method which creates the file sitemap.xml which we can provide to Google's index.
 * @return void
 */
public function createSitemap()
{   
    // Get file index
    $files = $this->buildIndex();

    // Set basepath for file transformal times
    $basepath = realpath(__DIR__.'/../../../.');

    // Get pages url
    $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https://" : "http://";
    $baseurl = $protocol . $_SERVER['HTTP_HOST'];

    // Create the sitemap
    $sitemap = new \SitemapPHP\Sitemap($baseurl);
    $sitemap->setPath($basepath . '/');

    foreach($files as $file)
    {
        // Make sure the file is not excluded
        if(!is_array($this->settings->get('index.excludedfiles')) || !in_array($file, $this->settings->get('index.excludedfiles')))
        {
            $sitemap->addItem(new \SitemapPHP\Url($file, '0.5', 'weekly', date(DATE_W3C, filemtime($basepath . $file))));
        }
    }
    // Put out the sitemap
    $sitemap->endSitemap();
}

Now revisit yoursite.com/test to rebuild the sitemap.xml and then revisit yoursite.com/sitemap.xml. There you go.

Done

That's it. You should now be able to create fully functional plugins all by yourself. You will find the final plugin (which will hold some more features) on blaze's website in the near future. Feel free to download and use it for all purposes.