How Helpers Work

Leed’s source calls these things shortcodes, because addShortcode is the Eleventy API that registers them. Handlebars calls them helpers, because that is what they become a moment later. They are the same objects, and this whole reference calls them helpers.

If you came here looking for the CMS feature called Shortcodes — branded /s/ short links with UTM tags attached — that is an unrelated product surface, covered in Short Links and Attribution. Helper, partial, collection and shortcode each mean something narrow in Leed; the Glossary holds the short definitions.

The helpers themselves are ungated. Every one of the 64 is available on every plan, including Free. Four behaviors reached through helpers do depend on your plan — autolinking inside process, the documentation header and footer slots, live search, and the “Powered by Leed” footer badge — and each is stated on the page that owns it rather than repeated here.

How a helper gets into your template

Every Leed helper is registered the same way, on Eleventy’s config object:

eleventyConfig.addShortcode("siteUrl", () => buildSiteUrl(domain));

Eleventy files that in a shortcode registry and does nothing else with it. The last plugin the build adds — and the source comment says THIS MUST BE THE LAST PLUGIN!!! — is @11ty/eleventy-plugin-handlebars, whose job at that moment is to walk the registry and hand every entry to Handlebars unchanged:

for (let [name, callback] of Object.entries(eleventyConfig.getFilters())) {
  library.registerHelper(name, callback);
}
for (let [name, callback] of Object.entries(eleventyConfig.getShortcodes())) {
  library.registerHelper(name, callback);
}

So at render time there is no such thing as a Leed shortcode. There are 64 ordinary Handlebars helpers on the same instance as everything else. Two consequences follow, and both matter more than they look:

  • Registration says nothing about how a helper is called. Leed’s source contains no addPairedShortcode, no addHandlebarsShortcode, no addFilter and no direct Handlebars.registerHelper call. Whether {{ name }} or {{#name}}…{{/name}} is correct is decided entirely by what the function body does with its options argument.
  • Registration order decides collisions. The external library goes on first, Eleventy’s filters second, Leed’s 64 shortcodes last. Last write wins.
flowchart TD
    LIB["handlebars-helpers 0.10.0<br/>19 categories · 152 helpers"] -->|"registered directly, first"| HB["Shared Handlebars instance<br/>160 names in total"]
    TOC["eleventy-plugin-toc<br/>1 filter: toc"] --> REG["Eleventy registry"]
    LEED["addShortcode(name, fn) × 64<br/>Leed's own helpers"] --> REG
    REG --> PLUG["eleventy-plugin-handlebars<br/>added LAST"]
    PLUG -->|"getFilters() → registerHelper"| HB
    PLUG -->|"getShortcodes() → registerHelper"| HB
    HB --> TPL["Your compiled .hbs template"]
    HB -.->|"only collision: date"| NOTE["Leed's date wins<br/>the library's moment survives"]

Helpers run at the last stage of a three-stage pipeline. Leed sets markdownTemplateEngine: false and htmlTemplateEngine: "hbs", so markdown is converted to HTML by markdown-it with no template engine involved, and Handlebars runs over the HTML afterwards. How Templates Work walks the whole pipeline; this reference picks it up at the Handlebars stage.

Helpers are also only half the toolkit. The components you include with {{> leed/… }} are a separate mechanism with their own catalog, the Leed Partial Index.

Inline, block and subexpression

The same helper name can be written three ways, and the three are not interchangeable.

Inline — the helper returns a value that is written into the page:

<link rel="canonical" href="{{ url }}">
<img src="{{ imageUrl featureImage "medium" }}">

Block — the helper receives the body as a function it may call, with a context of its choosing:

{{#Image featureImage.src "medium"}}class="w-full rounded-xl" alt="{{ title }}"{{/Image}}

{{#Authors this true}}
  <span class="author">{{ fullName }}</span>{{#unless isLast}}, {{/unless}}
{{/Authors}}

Subexpression — the helper returns a boolean or an object that another helper consumes, in parentheses:

{{#if (hasTier "starter") }} … {{/if}}
{{#each (sorted (collection "pageTypeId-100111") "data.title") }} … {{/each}}

The rule that decides which shape a helper wants is mechanical:

What the body doesCall it asExamples
Calls opts.fn(...) to render the bodyBlockImage, Authors, Series, date, PaginationRange
Ignores opts.fn and returns a stringInlineurl, siteUrl, readingTime, fieldId
Returns a boolean or an objectSubexpressionhasTier, hrefIsRenderable, collection, sorted, filterObject

Five helpers genuinely work either way, because their bodies check for opts.fn and branch: pageType, user, label, ImageTag and FeatureVideo. For the lookup trio that is the whole point — {{ label id 'name' }} gives you a field, and {{#label id}}…{{/label}} makes the label record the block context. For FeatureVideo it is cosmetic: the block form parses, but the body is discarded.

The trailing options object

This is the mechanic that produces the most confusing helper bugs in Leed templates, and it is not Leed’s invention — it is how Handlebars calls every helper.

Handlebars always appends an options object as the final argument. A helper written as fn(url, variant, opts) and called as {{ imageUrl src "medium" }} receives exactly what it expects. Called as {{ imageUrl src }}, it receives url = src and variant = options — and options is a perfectly truthy object, so a if (!variant) guard does not fire. imageUrl then runs src.replace("original", options), JavaScript stringifies the object, and you get a URL containing the literal text [object Object].

The shift, where it exists, always looks like this:

// lookupUuid — collections.ts
function lookupUuid(map, uuid, field, opts) {
  if (opts == null) {
    opts = field;
    field = null;
  }
  // …
}

Which helpers accept an omitted middle argument

HelperOptional parameterOmitting it means
pageTypefieldReturn the whole page-type record instead of one field
userfieldReturn the whole user record
labelfieldReturn the whole label record
AuthorsvisibleInclude authors that are not marked visible
LabelsvisibleInclude labels that are not marked visible
filterObjectvalueKeep every entry whose field is truthy, instead of matching a value
sortedfieldSort the values themselves rather than a field on them
datecontextRead timezone and dateFormat from this

Nothing else shifts. Authors and Labels shift through the shared forEachUUID implementation, and the lookup trio through lookupUuid. Everywhere else, supply every documented parameter — including ones you do not care about — and check Helper Gotchas and Failures when a helper behaves as if you passed it something you did not.

Escaping and when you need triple braces

Handlebars HTML-escapes whatever {{ }} produces. A helper that returns markup therefore has two ways to survive that: return a Handlebars.SafeString, which is exempt, or return a plain string and rely on the author writing {{{ }}}.

Leed’s helpers do both, and there is no way to tell from the call site which kind you have. The symptom of getting it wrong is unmistakable once you have seen it: the page renders &lt;a href= as visible text.

HelperReturnsBraces
Image, FeatureImage, ImageTag, staticImage, svgDiagram, FeatureVideoSafeString{{ }}
JSONsafe, wrapSubstring, CTA, RecommendationsSafeString{{ }}
process, JSON-LD, SmartPaginationLinksRaw HTML string{{{ }}}
previousPageNav, nextPageNav, breadcrumbsNavRaw HTML string{{{ }}}
resolveRedirectIndexPlain text{{{ }}} when the destination can contain &

Triple braces do not make a SafeString wrong, so when you are unsure, {{{ }}} on a helper that returns markup is the safe guess. It is only wrong on a helper that returns text a reader supplied, where escaping is the point.

What context a helper reads

Around a third of Leed’s helpers read nothing but their arguments. The rest need the page context, and it is worth knowing what is reliably on it.

Eight keys are present on every page context, put there by the build rather than by your front matter: customerConfig, autolink, labelList, leedForms, leedMenu, pageTypeList, searchIndexList and userList. Build environment details — the domain, the branch, whether this is a preview build — live under this.leedSiteEnv.

The complication is pagination. On an ordinary page, this is the page. Inside a paginated list item, the interesting data has moved: the item’s own data sits at data.root.list.data on the options object, not on this at all. Eight helpers deal with that through a single shared unwrapper, which tries four locations in order:

1.  context.data.root.list.data   → a paginated list item's own data
2.  context.data.root             → the page root, in a normal render
3.  context.data                  → a bare Handlebars data frame
4.  context                       → the value you were handed

That precedence is the reason {{#FeatureImage}} works identically on a blog post and inside the loop that lists blog posts, and the reason a helper that works in one place can silently return nothing in the other. FeatureImage and FeatureVideo run it twice — once on the options object, then on this — which is what makes them work in both directions.

The handlebars-helpers library

Every build also loads handlebars-helpers, pinned to version 0.10.0. Leed enables 19 of its 20 categories, which adds 152 helpers directly onto the Handlebars instance. With Handlebars’ own eight built-ins — if, unless, each, with, log, lookup, blockHelperMissing and helperMissing — and Leed’s 64, there are 160 names on the instance before your own templates start.

Nothing about this needs installing or configuring. The upstream README is the full catalog; here is one representative call per category, with how many helpers each one contributes.

CategoryHelpersExampleWhat it gives you
array28{{ join labels ", " }}first, last, length, sort, sortBy, slice, pluck, unique, forEach
code3{{ embed "src/example.js" }}Embed a file as a fenced code block, or a gist
collection2{{#isEmpty items}}No results{{/isEmpty}}Emptiness checks and generic iteration
comparison25{{#eq status "published"}}Live{{else}}Draft{{/eq}}eq, gt, lt, and, or, contains, isFalsey, default
date3{{ year }}year and moment — plus a date that Leed shadows
fs3{{ read "snippets/note.txt" }}Read files and directory listings at build time
html7{{ sanitize description }}Attribute building, tag stripping, ul/ol generation
i18n1{{ i18n "cta.title" }}Language-keyed string lookup
inflection2{{ inflect count "post" "posts" }}Pluralisation and ordinalize (22 → “22nd”)
logging10{{ warn "no hero image" }}Levelled build-time console output
markdown2{{#markdown}}## Heading{{/markdown}}Render inline Markdown to HTML
match3{{ match files "*.hbs" }}Glob filtering of lists
math16{{ add list.page.number 1 }}add, subtract, multiply, divide, ceil, floor, avg, sum
misc5{{ typeOf value }}frame, option, noop, typeOf, withHash
number9{{ addCommas 1000000 }}toFixed, bytes, toAbbr, phoneNumber
object14{{ get "seo.title" this }}Deep property access, pick, merge, JSONstringify
path8{{ basename filePath }}Path segment extraction and resolution
regex2{{#if (test slug (toRegex "^docs-"))}} … {{/if}}Build and test regular expressions
url9{{ encodeURI shareUrl }}URI encoding and decoding, URL parsing, query stripping

Two of those are worth knowing about because they behave unlike their names suggest. default is a comparison helper, not a misc one, and it is what the shipped pagination partial uses to supply a fallback label. And or returns a boolean, never the first truthy value — Leed adds firstTruthy precisely because (or a b) cannot be used to pick a value.

The string category is not installed

One category is deliberately left out. handlebars-helpers registers reverse in both array and string, and Leed keeps the array version, so the whole string category is excluded. That costs 36 helpers, and it is the single most common cause of “this helper is documented upstream but does not work”.

The 36 string helpers Leed does not register

append, camelcase, capitalize, capitalizeAll, center, chop, dashcase, dotcase, downcase, ellipsis, hyphenate, isString, lowercase, occurrences, pascalcase, pathcase, plusify, prepend, raw, remove, removeFirst, replace, replaceFirst, reverse, sentence, snakecase, split, startsWith, titleize, trim, trimLeft, trimRight, truncate, truncateWords, upcase, uppercase

None of these exists in a Leed build. A template calling {{ uppercase title }} does not error — Handlebars resolves the unknown name to nothing and renders an empty string.

Leed ships four minimal stand-ins rather than a replacement library: stripTags for removing markup, wrapSubstring for wrapping an occurrence in a span, concat for joining, and firstTruthy for choosing. All four are documented on Utility Helpers. For anything else, do the string work in the CMS or in your data, not in the template.

date is shadowed, moment is not

Of the 152 library helpers and Leed’s 64, exactly one name appears in both lists: date.

:::warning {{#date}} is Leed’s helper, and it works nothing like the library’s handlebars-helpers registers date as an alias of moment, an inline helper taking a date and a format string. Leed’s date is a block helper whose body produces the date value and whose first argument is the format. Because Leed’s shortcodes register last, Leed’s version wins — and the library’s original is still reachable under its other name, moment, which nothing shadows.

{{#date "MMMM D, YYYY"}}{{ publishedAt }}{{/date}}

The library’s inline shape, {{ date publishedAt "MMMM D, YYYY" }}, reaches Leed’s helper instead and renders an empty string, because Leed’s date reads its value from the block body it never received. :::

The general rule this implies is worth carrying: because Leed registers last, any future Leed helper named after a library helper will shadow it silently, with no warning at build time. If a library helper stops behaving as its README describes, check whether Leed has since claimed the name.

toc, the one external filter

eleventy-plugin-toc is added with no global options, and the Handlebars plugin registers Eleventy filters as helpers alongside the shortcodes. That makes toc callable exactly like a helper, even though it is neither Leed’s code nor a shortcode:

{{{ toc content '{"tags": ["h2", "h3"], "wrapper": "div", "wrapperClass": "nav-toc"}' }}}

It builds an “on this page” list from headings that already carry an id, and skips any that do not. Its full signature, every default, and the undefined it returns when no id-bearing heading exists belong to Documentation Navigation Helpers.

Reading this reference

The twelve pages after this one are lookup material, grouped by the job you are doing:

PageCovers
Helper Index (A–Z)All 64 helpers plus toc, alphabetically, with the page that documents each
URL and Link HelpersAbsolute and relative links, query strings, page-type index redirects
Image and Media HelpersImages, size variants, inline SVG, Cloudflare Stream video
Collection, Lookup and Series HelpersPage types, users, labels, authors, series, sorting and filtering
Menu-Safety HelpersKeeping a hand-written menu from linking to an unpublished page
Pagination HelpersNumbered links and “showing X to Y of Z”
Documentation Navigation HelpersBreadcrumbs, previous and next, layout and theme names, toc
Date and Content HelpersFormatting dates, rendering CMS content, structured data
Form and CTA HelpersForm field ids, Turnstile keys, the two deprecated marker emitters
Tier Gating in TemplateshasTier and hasFeature, and degrading rather than breaking
Utility HelpersTemplate slots, string work, JSON output, build-time logging
Helper Gotchas and FailuresEvery failure mode, sorted by symptom

When you already know a helper’s name, skip all of that and go straight to the Helper Index. When something is failing rather than missing, start from Helper Gotchas and Failures — every log.warn and log.error quoted across this reference appears in the console output of leed site build, and several of the failures are silent unless you are watching it.

ESC