The previous article discussed key concepts related to the development of Pelican plugins, installation of plugins, and gave a minimal example of a plugin.

In this article, a more practical example of a plugin — one actually used on this site — is demonstrated.

Building a Teaser Image Plugin

Task Description

Before writing any actual code for a plugin, I'll define exactly what problem I'm trying to solve, and why a plugin is a good way to solve it.

Say that on a blog site, for each article I want a "teaser image", i.e. a thumbnail used in indexes to visually represent the article. Ultimately, I want to be able to do something like this in an index template:

{% for a in articles %}
  <img src="{{ a.teaser_img }} alt="Thumbnail">
  <!-- Rest of article markup would go here -->
{% endfor %}

For now, the task is that simple. Each article should have a teaser_img variable that contains an image's URL.

Why a Plugin?

The simplest way to go about this would be to add the respective teaser image's path to each article's metadata. For example, assuming a site whose content is written in Markdown (input type has no real bearing on the plugin), here's how an article input file might look:

Title: Example Article
Date: 2018-01-01
Teaser_Img: /full/url/of/img.jpg

Article content here

This naive approach would work, but has some problems.

Problem 1: Required metadata

Using only metadata, every article must have a teaser_img explicitly defined, or templates might break. Of course, this can be easily abated with something like this in a template:

{% for a in article %}
  {% if a.teaser_img %}
    <img src="{{ a.teaser_img }}">
  {% endif %}
  <!-- Rest of article markup would go here -->
{% endfor %}

This adds a little clutter to templates, but is possibly desired behavior anyway, if you don't always want a teaser image. While this problem isn't that bad, others are more serious.

Problem 2: Hardcoded URLs

Having to include the entire URL of each image could easily lead to typos or broken images should that URL change. Most sites have a standardized schema to where their static files lie (either in one directory, or perhaps on a path including the article's slug somewhere), so it makes sense to programmatically determine most of the URL elsewhere rather than hardcoding it. Ideally, only the filename of the image should be specified in article metadata.

Problem 3: Extensibility

Extensibility is where the real magic of plugins comes in. Say eventually you want to extend the teaser image functionality to default to some image when no teaser_img is specified. Ideally, you want to specify that default image in one place (like your settings file) rather than in every article that needs it (which could be many depending on the size/age of the site).

Or, perhaps eventually you'd like to default to a random image within an article to be the teaser when none is specified. This would not be possible with only metadata.

Or, maybe you want Pelican to look for a file named teaser.jpg in an article's static directory and automatically set teaser_img to that if it's present, all without touching metadata. That would also be feasible with a plugin.

These issues are just a few reasons why a plugin is a good choice for this problem, as opposed to only adding a new metadata field.

Basic Implementation

The initial implementation of the plugin will keep to these specifications:

  1. An optional teaser_img attribute in article metadata should specify a filename of an image, which is assumed to be in the /static/images/ directory.

  2. If no teaser_img is specified for an article, the attribute should default to /static/images/default_teaser.jpg.

The plugin will be further generalized and improved later, but for now these are the goals, to keep things fairly simple for this tutorial.

Step One: Setup and Choosing a Signal

Now that we have a handle on what to implement, actual coding can begin.

I'm going to call this plugin teaser, so the first step is to create plugins/teaser.py and set it up with a register function.

"""
teaser.py
"""
IMAGE_PATH = '/static/images/'
DEFAULT_TEASER = '/static/images/default_teaser.jpg'

def register():
    """Register the plugin with Pelican"""

    # TODO - this is the next step
    pass

Next, the plugin needs to be registered in the settings file:

"""
pelicanconf.py
"""
# [Rest of file omitted...]

PLUGIN_PATHS = ['plugins']
PLUGINS = ['teaser']

But what goes into the register function? Recall from the minimal example in the previous article that the register function is generally used to connect to a signal, which will trigger the plugin's logic. The next step, then, is to decide which signal the plugin should listen for.

The first thing to consider when choosing a signal is what context a plugin needs in order to work. In this case, we need all the articles to have been read in, so the plugin has access to the teaser_img metadata.

Looking through the list of signals, a few may stick out as potentially useful. There are actually several that could work for this plugin. I'm going to go with the rather confusingly-named article_generator_write_article, because it handily passes each respective article as an argument whenever the signal is sent.

I'm not fond of the name of this signal because it's hard for a layperson to understand what "write" means in this context. There is an ambiguity about whether or not the template has been rendered when the signal is sent. The description of the signal in the docs doesn't help. In my early Pelican days I ended up digging into the source and finding where the signal was sent in order to understand what exactly it represented. Recall that a Writer in pelican is responsible for both rendering and writing an output file. In short, the article_generator_write_article signal is sent just before an article is rendered and written, so it is still safe to manipulate it, or its context — any changes will definitely be represented in the rendered article.

Now that a signal has been chosen, it can be connected to:

"""
teaser.py
"""
from pelican import signals

IMAGE_PATH = '/static/images/'
DEFAULT_TEASER = '/static/images/default_teaser.jpg'

# Don't rename `content` - it's a keyword arg. See note in text below
def normalize_teaser(generator, content):
    """
    Ensure that `content` (an article) has a valid teaser_img attribute.

    Gets the teaser_img metadata from each article, assumed to be a filename,
    and appends it to IMAGE_PATH (assigning the result back into teaser_img).

    If no teaser_img attribute found in article's metadata, it is set to DEFAULT_TEASER.
    """

    # TODO
    pass

def register():
    """Register the plugin with Pelican"""
    signals.article_generator_write_article.connect(normalize_teaser)

The plugin now connects to the article_generator_write_article signal, telling Pelican to call the normalize_teaser function when the signal is sent. We know the function will receive the article generator and article itself as arguments because the aforementioned list of signals says so.

There's a "gotcha" to look out for when hooking functions to signals: the secondary arguments such as content that are passed when a signal is sent are sent as keyword arguments. So, if the name content was changed (e.g. to something more explicit like article), a TypeError would be thrown.

Step Two: Implementing normalize_teaser

The next step is to write the actual plugin logic. As the initial specification is quite simple, the function doesn't have to do much right now.

"""
teaser.py
"""
from pelican import signals

# Note these are URLs, so forward slashes wanted regardless of platform
IMAGE_PATH = '/static/images/'
DEFAULT_TEASER = '/static/images/default_teaser.jpg'

def normalize_teaser(generator, content):
    """
    Ensure that `content` (an article) has a valid teaser_img attribute.

    Gets the teaser_img metadata from each article, assumed to be a filename of a 
    file residing in IMAGE_PATH, and appends it to IMAGE_PATH (assigning the result 
    back into teaser_img).

    If no teaser_img attribute found in article's metadata, it is set to DEFAULT_TEASER.
    """

    # Get teaser_img from metadata; fallback to None if not present
    filename = content.metadata.get('teaser_img', None)

    if filename:
        content.teaser_img = IMAGE_PATH + filename
    else:
        content.teaser_img = DEFAULT_TEASER

def register():
    """Register the plugin with Pelican"""
    signals.article_generator_write_article.connect(normalize_teaser)

You might be wondering about content.teaser_img vs content.metadata['teaser_img']. I chose to get the filename out of the metadata dictionary mostly to show that it's available. Since all metadata is set as attributes of the article object during generate_context, I could also have used content.teaser_img.

And that's it. At this point, the requirements for this minimal implementation of the plugin should be met.

Step Three: Testing

To verify the plugin works as expected, let's do a simple test. Add a log message to output the value of teaser_img for each article at the end of normalize_teaser:

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

logger = logging.getLogger(__name__)

# Note these are URLs, so forward slashes wanted regardless of platform
IMAGE_PATH = '/static/images/'
DEFAULT_TEASER = '/static/images/default_teaser.jpg'

def normalize_teaser(generator, content):
    """
    Ensure that `content` (an article) has a valid teaser_img attribute.

    Gets the teaser_img metadata from each article, assumed to be a filename of a 
    file residing in IMAGE_PATH, and appends it to IMAGE_PATH (assigning the result 
    back into teaser_img).

    If no teaser_img attribute found in article's metadata, it is set to DEFAULT_TEASER.
    """

    # Get teaser_img from metadata; fallback to None if not present
    filename = content.metadata.get('teaser_img', None)

    if filename:
        content.teaser_img = IMAGE_PATH + filename
    else:
        content.teaser_img = DEFAULT_TEASER

    logger.debug("teaser_img set for {}: {}".format(content.title, repr(content.teaser_img)))

def register():
    """Register the plugin with Pelican"""
    signals.article_generator_write_article.connect(normalize_teaser)

Test articles — one with a teaser, and one without to test the fallback — might look like the following examples.

Article 1:

Title: Test Article 1
Date: 2018-01-01
Teaser_Img: some-image.jpg

Article content here.

Article 2:

Title: Test Article 2
Date: 2018-01-02

Article content here.

Re-generating the site with pelican --debug content should print (somewhere amongst many other debug messages) each teaser_img value. Mine look like this:

DEBUG: teaser_img set for Test Article 1: '/static/images/some-image.jpg'
DEBUG: teaser_img set for Test Article 2: '/static/images/default_teaser.jpg'

Conclusion

The teaser plugin at this point works well enough for a specific set of requirements, but is far from optimal.

The user shouldn't be bound to one directory for images, for example, and shouldn't have to hard-code paths into the plugin module itself. (Hint: Perhaps the settings file?) These improvements and more will be made in the next article.