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;
},
});
};
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
. ↩︎