Four helpers that share nothing except that each one is the only way to do its job. date is how a date value becomes a string. latestDate is how a list page finds its most recent entry. process is how a page body written in the CMS becomes the HTML in your layout. JSON-LD is how a blog post gets structured data. Everything here assumes the registration mechanics from How Helpers Work — in particular that the last argument any helper receives is always Handlebars’ options object.
date — formatting a date value
date is a block helper, and the block body produces the value. This surprises everyone, so it is worth stating as a rule before anything else:
{{#date 'iso'}}{{ publishedAt }}{{/date}}Not {{ date publishedAt 'iso' }}. The format is the argument; the date is the block. Called inline, date finds no options.fn to render and returns an empty string — silently, with nothing in the build log.
The everyday shape is a <time> element that needs the machine format in the attribute and the human format in the text:
<time datetime="{{#date 'iso'}}{{ publishedAt }}{{/date}}">
{{#date "MMMM D, YYYY"}}{{ publishedAt }}{{/date}}
</time><time datetime="2026-06-04T14:00:00.000Z">June 4, 2026</time>The first parameter, format, is required and is checked before anything else happens. A non-string — the usual cause being a variable that did not resolve — throws a TypeError carrying the usage string, and the build stops:
TypeError: usage: {{#date 'iso'|'rfc3339'|'rfc822'|'default' [context]}}{{ e.g. publishedAt }}{{/date}}The second parameter, context, is optional and shifts. It decides where timezone and dateFormat are read from — with no second argument they come off this, which is correct on a single page and worth being explicit about on a list page, where you want the record you are iterating rather than whatever the surrounding block left in scope:
{{#date "default" this}}{{ publishedAt }}{{/date}}Accepted formats
Format matching is case-insensitive, which is why the shipped RSS feed writes 'Rfc822' and it works. Anything that is not one of the four names is handed straight to day.js’s format(), so any day.js token string is legal.
| Format value | Produces | Example output |
|---|---|---|
iso | toISOString() | 2026-06-04T14:00:00.000Z |
rfc3339 | ISO with the milliseconds segment removed, Z-suffixed | 2026-06-04T14:00:00Z |
rfc822 | ddd, DD MMM YYYY HH:mm:ss z | Thu, 04 Jun 2026 14:00:00 UTC |
default | the context’s dateFormat, or YYYY-MM-DD | 2026-06-04 |
| any day.js token string | whatever day.js makes of it | MMMM D, YYYY → June 4, 2026 |
An empty block, or one that produces 0 or null, returns an empty string rather than a formatted epoch — so a page with no publishedAt renders nothing rather than 1970-01-01.
Timezone and the default format
Both settings come off the resolved context, never off the helper call:
- Timezone is
timezoneon the context, falling back toUTC. defaultisdateFormaton the context, falling back toYYYY-MM-DD.
Neither setting can be passed as an argument. If a date is coming out in the wrong zone, the value to change is timezone on the site or page type, not the helper call.
That is also why Leed’s own paginated item template passes the context on one line and omits it on the other:
<time datetime="{{#date 'iso'}}{{ publishedAt }}{{/date}}">
{{#date "MMMM D, YYYY" this}}{{ publishedAt }}{{/date}}
</time>iso always renders UTC, so it has nothing to resolve; the human-readable line renders in the site’s zone and is given the record explicitly. Where timezone and dateFormat are configured is covered in Global Site Data and the Data Cascade.
date is not moment
Leed’s helpers are registered onto the Handlebars instance after the handlebars-helpers library, and date is the one name that appears in both. Leed’s registration wins.
:::warning {{ moment }} and {{#date}} are different helpers handlebars-helpers registers date as an alias of moment, an inline helper taking the date first. Leed’s block helper replaces it. moment itself is untouched, so {{ moment publishedAt "YYYY" }} still reaches the library version and behaves nothing like {{#date}}. If you copied a snippet from handlebars-helpers documentation and it renders empty, this is why. The full shadowing story is on How Helpers Work. :::
latestDate — the newest value in a collection
latestDate scans one collection and returns the greatest value of one field. It is what puts a meaningful <updated> on a feed and a <lastmod> on a sitemap without you iterating anything.
{{ latestDate "publishedAt" "all" }}Both parameters are required strings and both are checked, so a non-string in either position throws a TypeError. The field is any key on the collection items’ data — publishedAt and modifiedAt are the documented ones, and any front-matter key works. The second is the collection id.
Two behaviors are worth knowing before you trust the result:
- Comparison is plain string comparison. That is exactly right for ISO-8601 timestamps, which sort lexically, and wrong for anything else. Do not point it at a numeric field or a locale-formatted date.
- A missing collection returns an empty string, not an error. A typo in the collection id looks identical to a page type with no published pages.
Values equal to the literal string "list" are skipped, so the synthetic list entries a paginated page type generates do not win the comparison.
Because latestDate returns a raw value and date formats one, the two compose — and that composition is the whole of the Atom feed’s <updated> line:
<updated>{{#date 'Rfc3339'}}{{ latestDate "publishedAt" "all" }}{{/date}}</updated>The sitemap does the same thing per page type, building the collection name with concat as it goes:
{{#each pageTypeList}}
{{#if (latestDate 'publishedAt' (concat "pageTypeId-" @key))}}
<sitemap>
<loc>{{ siteUrl }}/sitemap-{{ pageTypeSlug this }}.xml</loc>
<lastmod>{{#date 'iso'}}{{ latestDate 'publishedAt' (concat "pageTypeId-" @key) }}{{/date}}</lastmod>
</sitemap>
{{/if}}
{{/each}}Both files are walked through end to end in Feeds, Sitemaps and robots.txt. The collection names these calls point at are enumerated in Collections and Pagination Data.
process — rendering CMS content
process takes one required parameter, the page body, and returns finished HTML. It is what every page layout calls, and it is raw HTML rather than a SafeString, so it needs triple braces:
{{{ process content }}}Double braces produce a page full of escaped tags. Inside a paginated item template the content lives on the root item rather than on this, so the shipped template reaches for it explicitly:
{{{ process @root/item.content }}}Falsy content, or a call with no options, logs Cannot process content when none is provided. {{{ process content }}} and returns an empty string.
What happens in between is three stages, in a fixed order. The order is the point: everything downstream sees the output of everything upstream, and each stage can be skipped for a different reason.
flowchart TD
A["CMS page body — HTML from Markdown"] --> B{"Page type has<br/>autolinking on?"}
B -->|no| S["Skip autolinking"]
B -->|yes| C{"Site has at least<br/>one autolink phrase?"}
C -->|no| S
C -->|yes| D{"Plan includes<br/>autoLinking — Starter+?"}
D -->|"no — including an<br/>unreadable tier"| L["log.info, once per build"]
L --> S
D -->|yes| E{"Page sets<br/>disableAutolink?"}
E -->|yes| L2["log.info, per page"]
L2 --> S
E -->|no| F["1. Phrases rewritten to links<br/>+ autolink UTM"]
S --> G["2. Handlebars comments stripped"]
F --> G
G --> H["3. Each partial call compiled<br/>individually against the page context"]
H --> I["HTML handed back to your layout"]
Two common questions land on two different branches of that picture. My autolinks did not appear is the left-hand chain of gates. My embedded form did not appear is stage 3.
1. Autolinking
Autolinking rewrites configured phrases in the body into links. Four conditions must all hold, and the first two are ordinary configuration:
- The page’s page type has
autolinkenabled. - The site has at least one autolink phrase defined.
- The plan includes the
autoLinkingfeature. - The page does not set
disableAutolink.
Every link the autolinker creates carries a fixed campaign string so autolink traffic is separable in analytics:
utm_campaign=leed&utm_source=internal&utm_medium=autolinkThe per-page opt-out logs differently, and once per page — Autolinking disabled on <pageId>: <url> — which is how you tell “the plan does not include it” from “this page turned it off”.
2. Handlebars comments are stripped
Both comment forms go: the block form \{{!-- … --}} and the short form \{{! … }}. The leading backslash on each of those is the escape, and it is the only reason this page can print the syntax at all — without it, the removal pass would delete the example along with the comment it describes.
That is why a Handlebars comment is the right place to document a partial’s parameters: it costs the reader of the built page nothing, because it never reaches the browser. It is also why a comment written into a page body in the CMS editor needs the same backslash if you want it to survive — the removal pass runs over Markdown source before it is parsed, code blocks included.
3. Embedded partial calls are compiled
process scans the HTML for partial-call expressions, compiles each occurrence individually against the page context, and splices the result back in place. Three consequences follow from individually:
- A partial that fails to compile is left in the page exactly as written, with a
log.debugand nothing louder. A mistyped partial name renders as its own source text on the live page. - A backslash-escaped call is skipped, so you can show partial syntax in a page without invoking it.
- Nothing else in the body is evaluated.
{{ title }},{{#if}}, helper calls and subexpressions typed into page content stay literal text. Only the partial-call form is matched.
That single exception is what makes the editor’s Form block work. A form dropped into a page body is a partial call, so it renders where an author put it:
{{> leed/form formId="contact-us" }}Inside a code block it does not, because the Markdown pass has already escaped the > — which is the only reason this page can print the syntax. The authoring side of that is Placing a Form on a Page, and the wider pipeline that hands content to process in the first place is at How Templates Work.
JSON-LD — structured data
JSON-LD takes no parameters, reads the current page, and emits a <script type="application/ld+json"> block. It returns raw HTML, so it needs triple braces. Leed’s own head partial calls it on every page:
{{{ JSON-LD }}}For a page type slugged blog, the @type is BlogPosting, or Blog when the page’s unique id starts with list- — that is, on the generated index page rather than an article.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"@id": "https://example.com/blog/my-post/",
"url": "https://example.com/blog/my-post/",
"headline": "My Post",
"datePublished": "2026-06-04T14:00:00.000Z",
"dateModified": "2026-06-11T09:30:00.000Z",
"publisher": {
"@type": "Organization",
"name": "Example",
"logo": "https://example.com/static/images/logo.svg",
"description": "What Example does."
},
"image": "https://example.com/images/hero.png",
"description": "A one-sentence summary.",
"wordCount": 1120,
"author": [
{ "@type": "Person", "familyName": "Reyes", "givenName": "Ana" }
]
}
</script>
<!-- JSON+LD generated by https://leed.ai -->Every field comes off the page record or the site record; none of it is authored separately.
| Field | Source | Always or conditional |
|---|---|---|
@context | the literal https://schema.org | always |
@type | BlogPosting, or Blog on the generated index page | always |
@id | the page’s absolute URL | always |
url | the page’s absolute URL | always |
headline | the page’s title | always |
datePublished | publishedAt | always |
dateModified | modifiedAt | always |
publisher.name | the site title | always |
publisher.logo | the site logo, resolved to an absolute URL | always |
publisher.description | the site description | always |
image | featureImage.src, absolute | only when the page has a feature image |
keywords | the page’s keywords | only when set |
description | the page’s summary | only when set |
wordCount | the page’s wordCount | only when non-zero |
author | one Person per visible author, with familyName and givenName | only when the page has at least one visible author |
The author filter is the one to remember: an author marked not visible is dropped from the array, and if that empties it the author key is omitted rather than emitted empty. Author visibility is a per-person setting, not a per-page one.
Reference
| Helper | Form | Parameters | Requires on context | Returns | Braces | On bad input |
|---|---|---|---|---|---|---|
date | block | 1. format string, required · 2. context object, optional (shifts) | timezone and dateFormat on the resolved context | formatted string | {{ }} | non-string format throws TypeError; called inline, returns "" silently |
latestDate | inline | 1. dateField string, required · 2. collectionId string, required | collections | the greatest string value found | {{ }} | non-string in either position throws TypeError; unknown collection returns "" |
process | inline | 1. content string, required | pageTypeList, pageTypeId, autolink, entitlements, disableAutolink, page | raw HTML | {{{ }}} | falsy content: log.warn + ""; a partial that will not compile is left verbatim |
JSON-LD | inline | none | pageTypeId; the page record | raw HTML — a <script> block or an HTML comment | {{{ }}} | no page type: an explanatory HTML comment, never an error |
Every failure on this page that is not a thrown TypeError is silent by design, which makes them hard to notice and easily misattributed. They are collected alongside the rest of the category’s failure modes at Helper Gotchas and Failures, and the messages they write appear in the console of leed site build. When you know the name of the helper you want and not the page it lives on, start at the Helper Index (A–Z).