Custom Filters
How this site turns a file into a Nunjucks filter automatically, using a new real pluralize filter as the example.
Live Implementation
The same pluralize filter that powers every real tag page on this site, reused here with different real counts:
- 29 demos on this site
- 7 posts on the blog
- 14 tags in use
excerpt — the same filter that trims long descriptions on the demos listing, applied here to this page's own description:
- How this site turns a file into a Nunjucks filter automatically, using…
formatNumber — adds thousands separators. This site is small enough that there's no comma to show yet, but it's the same mechanism a bigger site would use for a real page-view count:
- 36 pieces of content, total
timeAgo — the same filter shown on the real blog listing, reused here on the most recent post's actual date. It's built on `pluralize` from above, not a second copy of the same singular/plural logic:
- Newest post published 5 months ago
absoluteUrl — not one of ours. This one ships with @11ty/eleventy-plugin-rss and is what turns every relative link into a real, absolute one in the RSS feed. Not every filter has to be hand-written:
- https://11ty-demos.netlify.app/demos/
How It Works
Every filter on this site — readableDate, toc, pluralize, all of them — is a single file under src/_11ty/filters/ that default-exports a function. Nothing registers them by name; a small helper scans the folder at build time and calls addFilter(filename, fn) for every file it finds:
export default function pluralize(count, singular, plural) {
const word = count === 1 ? singular : plural || `${singular}s`;
return `${count} ${word}`;
} Drop that in as pluralize.js and {{ count | pluralize("demo") }} works immediately, everywhere, with zero lines added to eleventy.config.js. This isn’t a toy example either — the real tag pages used to have {{ matching.length }} demo{% if matching.length != 1 %}s{% endif %} inline in the template to handle the singular/plural case by hand. pluralize replaced it outright.
Four more live below, same mechanism, different jobs:
excerpttrims text to a word count — it’s the filter actually capping the descriptions on the demos listing, so one long sentence can’t stretch a card taller than its neighbors.formatNumberadds thousands separators to a number.timeAgoturns a date into “3 months ago” — it reusespluralizeinternally for the unit word, rather than re-deriving the same singular/plural logic twice.absoluteUrlisn’t hand-written at all. It ships with@11ty/eleventy-plugin-rssand is what turns every relative link into a real, absolute one in this site’s actual RSS feed. A filter doesn’t have to come fromsrc/_11ty/filters/to work exactly the same way from a template’s point of view.
Folder Structure
src/_11ty/registerFromGlob.js ← scans the folder, calls addFilter(name, fn) for each file
src/_11ty/filters/pluralize.js ← used on real tag pages
src/_11ty/filters/excerpt.js ← used on real demo card descriptions
src/_11ty/filters/formatNumber.js
src/_11ty/filters/timeAgo.js ← used on the real blog listing, imports pluralize.js directly
src/tags.njk ← where pluralize replaced hand-written singular/plural logic
src/_includes/components/demo-card.njk ← where excerpt caps description length
src/blog.njk ← where timeAgo is used for real Important Files
src/_11ty/filters/pluralize.js
export default function pluralize(count, singular, plural) {
const word = count === 1 ? singular : plural || `${singular}s`;
return `${count} ${word}`;
} src/_11ty/filters/excerpt.js
export default function excerpt(text, wordLimit = 20) {
const words = String(text).trim().split(/\s+/);
if (words.length <= wordLimit) return text;
return `${words.slice(0, wordLimit).join(" ")}…`;
} src/_11ty/filters/timeAgo.js
import pluralize from "./pluralize.js";
const UNITS = [
["year", 31536000000],
["month", 2592000000],
["week", 604800000],
["day", 86400000],
];
export default function timeAgo(date) {
const diff = Date.now() - new Date(date).getTime();
for (const [unit, ms] of UNITS) {
const value = Math.floor(diff / ms);
if (value >= 1) {
return `${pluralize(value, unit)} ago`;
}
}
return "today";
} Notes
- The filename is the filter name. Renaming
pluralize.jsto something else changes what you’d type on the right-hand side of every|that uses it — same “filename is the contract” rule as Data Files. - Nunjucks filters have to be synchronous, which is why these are all plain functions — Eleventy’s async-filter support exists, but
registerFromGlobhere always calls the plainaddFilter, so anasync functionin this folder wouldn’t be awaited correctly. timeAgoimportingpluralize.jsdirectly (not through the Nunjucks filter pipeline) works because they’re both just plain JavaScript files —registerFromGlobregistering one as a filter doesn’t stop the other from importing it normally.