Beginner

Custom Filters

How this site turns a file into a Nunjucks filter automatically, using a new real pluralize filter as the example.

2 min read

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:

  • excerpt trims 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.
  • formatNumber adds thousands separators to a number.
  • timeAgo turns a date into “3 months ago” — it reuses pluralize internally for the unit word, rather than re-deriving the same singular/plural logic twice.
  • absoluteUrl isn’t hand-written at all. It ships with @11ty/eleventy-plugin-rss and 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 from src/_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.js to 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 registerFromGlob here always calls the plain addFilter, so an async function in this folder wouldn’t be awaited correctly.
  • timeAgo importing pluralize.js directly (not through the Nunjucks filter pipeline) works because they’re both just plain JavaScript files — registerFromGlob registering one as a filter doesn’t stop the other from importing it normally.