The Darth Mall a personal website

Eleventy Photo Gallery

Published
Tagged
Eleventy
Web

For my photo gallery I decided to use the JPEG files as templates in Eleventy. I parse the EXIF data from the image files for things like the published date in Eleventy and for the image alt text. I’m not convinced this is a good idea. It seems too clever by half, but I intend to stick with it for a while to see if it becomes a problem.

The advantage of doing things this way is that I don’t have to create a separate file to accompany the image to define all of these fields. I can set a title and description for the image in my photo manager. The camera has already recorded the time that the image was taken, which will serve for Eleventy’s date field. Then all I have to do is export the image from the photo manager into the site’s input directory.

Responsive images with the Eleventy image plugin

Before looking at how I set up the JPEGs as templates, permit me to digress briefly into the shortcode I set up for the Eleventy image plugin. This is important because it crops up in both the gallery Liquid template, and in the custom template configuration.

const Image = require("@11ty/eleventy-img");

async function image(
src,
alt,
lazy = true,
widths = [512, 1024, 2048],
sizes = "100vw") {

let metadata = await Image(src, {
widths,
outputDir: "./_site/img/",
});

let imageAttributes = {
alt,
sizes,
loading: lazy ? "lazy" : "eager",
decoding: "async",
};

return Image.generateHTML(metadata, imageAttributes);
}

image shortcode used to generate responsive images

This shortcode is nearly identical to the example shortcode in the Eleventy Image docs. I’ve added some additional arguments—lazy and widths—and I’ve provided a default argument for sizes.

Configuring Eleventy to treat JPEGs as templates

Per the custom template language documentation, I need to call addTemplateFormats and addExtension in our config. You can add multiple formats, so you can support jpg, jpeg, and JPG extensions. I chose to only support jpeg extensions, because I think consistent extensions makes the file system look a little nicer.1 You could even support multiple image formats like PNG by aliasing them to the jpeg extension.

const exifr = require("exifr");

const { image } = require("./shortcodes.js");

module.exports = function (eleventyConfig) {
eleventyConfig.addTemplateFormats("jpeg");

eleventyConfig.addExtension("jpeg", {
read: false, // Do not actually read the image file

compile: function (inputContent, inputPath) {
return async (data) => image(inputPath, data.description, false);
},

getData: async function (inputPath) {
const tags = await exifr.parse(inputPath, true);
const data = {
title: tags.title.value,
description: tags?.description?.value ?? tags.notes,
date: tags.CreateDate,
exif: tags,
};

return data;
},
});
};

JPEG custom template config

Most of the work happens in addExtension. I set read: false to prevent Eleventy from reading the binary image data, because I don’t need it to. Normally, Eleventy will open and read the template file. It then makes the file contents available as the first argument to the compile callback. But, as you’ll see, I can generate the desired output for content with just the file path; no need to read the image data.

compile: function (inputContent, inputPath) {
return async (data) => image(inputPath, data.description, false);
}

“Compile” the image “template” file

The compile function will convert the template (image file) into HTML. The results of compiling the template are what Eleventy makes available in the content variable in your layout templates. Here, I use the image function I defined in the previous section (which also gets used as a shortcode) to generate HTML for a responsive <picture> element. The really cool thing about this—aside from the fact that I can now use {{ content }} in my layout templates to get the responsive image markup—is that, because I used the Eleventy Image plugin in my image function, Eleventy will handle resizing the images, converting them to modern formats, and ensure that they all get copied into the site’s output directory. Neat!

Notice that the final argument passed to image is false. I’ll save you the trouble of scrolling back up to the function declaration: this is the lazy argument. Since this image is the main content on the page, I don’t want to lazy load it. Hence, false.

I have, however, gotten a little ahead of myself. The second argument to image is the alt text for the image, for which I pass data.description. Now the data object that gets passed to the compile function is the data computed from Eleventy’s data cascade. But where does the description property come from? EXIF data.

getData: async function (inputPath) {
const tags = await exifr.parse(inputPath, true);
const data = {
title: tags.title.value,
description: tags?.description?.value ?? tags.notes,
date: tags.CreateDate,
exif: tags,
};

return data;
}

Parse EXIF data like frontmatter for the image files

In the getData function I use exifr to parse all of the EXIF data from the image file. I look for three fields specifically, which I pull up into the title, description, and date properties.2 Then I stuff all the EXIF data in its entirety into an exif property. I could have merged all these fields into the root object, but there’s a lot of stuff in here, and I’m a little more comfortable segregating it from everything else.

So this is where the compile function gets data.description for the image alt text.

Also, using the CreateDate EXIF field for Eleventy’s date property means that Eleventy will automatically sort the images by the date they were captured. Handy.

Photo details layout

Once Eleventy is configured to parse image files as templates and generate responsive image markup for them, it’s simply a matter of writing some Liquid templates to display the image details. The image markup is available as {{ content }} and the page title and published date are {{ title }} and {{ page.date }}—pretty much like any other Eleventy layout template.

<header>
<h1>{{ title }}</h1>
<p>{{ page.date }}</p>
</header>

{{ content }}

<dl>
{% for tag in displayTags %}
<div>
<dt>{{ tag.display }}</dt>
<dd>{{ exif[tag.key]}}</dd>
</div>
{% endfor %}
</dl>

Photo detail layout template

Not shown is the frontmatter for the layout template where I define displayTags: an array of the EXIF fields I want to display along with the photo. These are things like the camera and lens used, the exposure settings, the 35mm equivalent focal length (since I shoot with a crop sensor). This approach lets me curate which fields are shown and control the order in which they’re displayed.

Finally, to create the gallery page that links to each photo’s details, I just loop over collections.all, as you do.

<h1>Gallery</h1>

<div class="contact-sheet">
{% assign gallery = collections.all | reverse %}
{% for img in gallery %}
{% if forloop.index > 3 %}{% assign lazy = true %}{% else %}{% assign lazy = false %}{% endif %}
<a href="{{ img.url }}">
{% image img.inputPath, img.data.description, lazy, thumbnailWidths, thumbnailSizes %}
</a>
{% endfor %}
</div>

Gallery layout template

Worth noting here is that on line 6 I determine whether the image in the gallery should be lazy loaded. I decided (somewhat arbitrarily) that the first three images should be fetched eagerly. On a mobile phone, this will probably catch the on-screen images, and maybe the next one off screen. Everything else can be fetched as needed.

The thumbnailWidths and thumbnailSizes are set in frontmatter (not shown) for the template.

Shortcomings

EXIF is extremely limited when it comes to user-supplied fields. It can also be a bit confusing. Furthermore, not all photo managers expose the same fields to users; Shotwell—the application I use currently—only exposes the comment field, while Darktable exposes both a description and a comment field. To make matters worse, the mapping between the fields in the JPEG and the properties parsed by exifr is not at all obvious to me.3

So while it’s convenient to use the same application for importing the photos and then preparing them to upload to the gallery, it also means I cannot, for example, define a title, caption, and alt text for the image. I can only choose two, because Shotwell only gives me a comment field. If I switched to an application with more EXIF fields, I might be able to have all three.

I also don’t love how this mixes metadata for one context (my photo gallery) into another, largely separate context (my photo manager). The alt text is really not relevant in the context of my photo manager, so it’s weird when I’m browsing photos in the photo manager to have random, detailed descriptions of images. And one could argue that good alt text is not really appropriate content for a comment field; a description of the photo is hardly a “comment.” I might want to use that field to provide context for the photo itself—to jog my own memory—but if the photo is destined for the gallery, I can’t. The comment has to be alt text, if I’m going to publish it.


Eventually I may tire of this approach. Working with EXIF data in my photo manager may prove too much of a hassle, or I may decide that I want to add more metadata to these pages than I can cram into the EXIF data. Time will tell. A nice thing about this approach is that I should be able to add template data files for each image if I want to add more metadata. I can keep using the JPEG files as the templates and write a Python script to extract the existing metadata out into a JSON file next to each image and let Eleventy take care of the rest.

Or maybe I’ll discover a great application for manipulating EXIF data. That’d be nice.


Footnotes

  1. Probably this is a hobgoblin for my little mind, because I think my camera produces .JPG, so I’m going to have to rename all of the files as I export them. ↩︎

  2. You see some hedging here with the optional chaining operator and the null coalescing operator because in my experiments with different photo managers, exifr would find my descriptions in different fields, so I’m being a little cautious here about where I look for the image description in the EXIF data. ↩︎

  3. I figured out which properties to use by loading EXIF data in an interactive Node session and just dumping the whole object to stdout. ↩︎