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 imagesThis 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;
},
});
};
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);
}
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;
}
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>
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.
Photo gallery layout
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>
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
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. ↩︎
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. ↩︎I figured out which properties to use by loading EXIF data in an interactive Node session and just dumping the whole object to
stdout
. ↩︎