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:
-
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. -
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.