Introduction

Pelican is a static site generator written in Python, which was used to create this site. Extending Pelican with plugins can be a powerful tool, but unfortunately when going to create my own first plugin, I found documentation to be somewhat lacking. So, I'd like to provide a few tutorials demonstrating how I built plugins for this site, starting with a very simple one.

This article assumes basic familiarity with Python, and working with Pelican to create a basic blog site, but does not assume knowledge of Pelican internals.

Key Concepts

Before delving into plugins, there are a few Pelican concepts that developers should be familiar with.

First, it's a good idea to have a look through the Pelican internals page to get a sense of overall structure.

The following are some further concepts that will hopefully expand upon and clarify what's in the documentation.

Template Data Sources

Often, a plugin will want to make available some variable to templates, or manipulate an existing one (e.g. article metadata). It's important to understand the two major ways to make data visible to templates.

Context

Pelican uses a global context object — a dictionary — which holds relevant data (such as all the Article objects in context['articles']) necessary to build the site. This context object is passed between all generators, which typically add to or update it to define what articles/pages to write.

The context is sent to writers, which use its data to populate templates. When one writes something like {% for a in articles %} in a template, context['articles'] is what is being accessed behind-the-scenes.

The context contains a copy of the Pelican settings dictionary, so any setting can be accessed by name in a template. If you want to change settings in a plugin, it should be done when the initialized signal is sent (more on signals later), since that is before the settings are copied to context. User-defined settings for a plugin related to presentation can thus be placed in the regular Pelican settings file.

In short, if a plugin needs to make a variable available globally or across many different pages, context is probably the place to put it.

Content Attributes

The other way to send data to a template is to make it an attribute of an object visible to the template. This is most often seen in the case of articles or pages (which are both "content" types in Pelican).

You've probably seen that in the article.html template, an {{ article }} variable is available. This is the Article object itself, which is sent automatically as a variable by Pelican. The full list of variables available to various templates by default is here. If you set a custom attribute on that object in a plugin (e.g. article.foo = 'bar'), then {{ article.foo }} is available in any template that gets article.

All metadata for pages or articles become attributes of the respective content object. This is what makes e.g. {{ article.title }} available in a template.

When a plugin needs to set unique variables for individual articles or pages, it's best to do so on the content object, rather than in context.

Generators

Note: Any reference to generators herein is referring to Generator objects defined by Pelican, not the more general concept of generators in Python

Generators are essentially the middleman between Reader objects, which convert the content and metadata of input files (e.g. article or page markdown files) to something Python-friendly, and Writer objects which actually render and write output files from templates.

Generators ultimately control the flow of the bulk of what Pelican does. They are what actually iterate through each article/page, invoking readers and writers appropriately. In fact, examining a portion of the main run() function of Pelican (full source here), one can see that it mostly involves invoking operations on generators:

# Note global context includes all settings options
context = self.settings.copy()

# [...]

# Initialize all generators
generators = [
    cls(
        context=context,
        settings=self.settings,
        path=self.path,
        theme=self.theme,
        output_path=self.output_path,
    ) for cls in self.get_generator_classes()
]

# Generate context for each generator
# (read in input files; update global context)
for p in generators:
    if hasattr(p, 'generate_context'):
        p.generate_context()

# [...]

signals.all_generators_finalized.send(generators)

writer = self.get_writer()

# Write output of each generator
for p in generators:
    if hasattr(p, 'generate_output'):
        p.generate_output(writer)

signals.finalized.send(self)

By default, Pelican includes three major generators: ArticlesGenerator, PagesGenerator, and StaticGenerator. Each is responsible for handling their respective input types. However, any number of custom generators can be added. As one might guess from the snippet above, custom generators should implement one or both of the generate_context and generate_output methods. An example of building a custom generator will be in a future article in this series.

Signals

Key to developing Pelican plugins is understanding signals. As the site is being generated, signals are dispatched by Pelican which notify any listeners that some key part of the generation process has occurred (or is about to occur).

For example, in the code snippet in the previous section, the signals.all_generators_finalized.send(generators) line dispatches the all_generators_finalized signal after generate_context has been called on each generator.

Signals gif
THAT'S A SIGNAL! Signals, Jerry... Signals.

When writing plugins, you won't be sending signals, you will be listening for them. More on how to do this later.

A complete list of signals can be found here in the official docs. As you can see, the names of the signals often refer to the processes of generators, readers, and writers that were discussed previously.

A key component of writing a new plugin is determining which signal to connect to, based on when in the process you need to do work, and what data you need to work with. This is something I found particularly confusing when starting out with Pelican, and was partially the inspiration for these tutorials.

Plugin Basics and Setup

If you haven't already, I recommend reading through the official documentation page on plugins.

Here is a general process for creating a Pelican plugin, assuming you created your site with the pelican-quickstart command.

  1. Create a plugins folder in your project's root directory (the one with pelicanconf.py).

  2. In pelicanconf.py, add the line PLUGIN_PATHS = ['plugins']

  3. Create a package or module in the plugins folder that contains a register() function.

  4. Add your plugin to the list of installed plugins. In pelicanconf.py, add the line PLUGINS = ['your_plugin_name_here']. This name is whatever you named your plugin's module/package.

Minimal Example

Here is a barebones example of a plugin:

"""
exampleplugin.py
"""
import logging
from pelican import signals

logger = logging.getLogger(__name__)

# This function called when all_generators_finalized signal sent
def fn(generators):
    logger.info('\n\nHello World! All generators finalized\n\n')

# A register() function must be present in all plugins, and will be called 
# automatically when Pelican initializes plugins
def register():
    # Connect to all_generators_finalized signal
    # Argument to connect() is the callback function to be called 
    # when signal is sent
    signals.all_generators_finalized.connect(fn)

This plugin does, well, nothing. It simply logs a message when the all_generators_finalized signal is sent. But it serves as the smallest reasonable plugin that one could make.

Something important to note in this example is that when the callback function connected to the signal is called, an argument (or multiple arguments) will be passed to it. What exactly is passed depends on the signal. In the case of all_generators_finalized, a list of all generator objects is passed. The aforementioned list of signals specifies what data comes with each signal.

Try installing the plugin above by following the steps in the previous section. If the code above was in plugins/exampleplugin.py, it could be installed by adding the following two lines to the settings file:

PLUGIN_PATHS = ['plugins']
PLUGINS = ['exampleplugin']

Once the plugin is installed, if you run pelican --debug content to re-generate your site, you should see the 'Hello World...' message somewhere in the output.

Conclusion

The next article in this series will demonstrate building a more practical, yet still simple, plugin.