Date and Content Helpers

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 valueProducesExample output
isotoISOString()2026-06-04T14:00:00.000Z
rfc3339ISO with the milliseconds segment removed, Z-suffixed2026-06-04T14:00:00Z
rfc822ddd, DD MMM YYYY HH:mm:ss zThu, 04 Jun 2026 14:00:00 UTC
defaultthe context’s dateFormat, or YYYY-MM-DD2026-06-04
any day.js token stringwhatever day.js makes of itMMMM 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 timezone on the context, falling back to UTC.
  • default is dateFormat on the context, falling back to YYYY-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:

  1. The page’s page type has autolink enabled.
  2. The site has at least one autolink phrase defined.
  3. The plan includes the autoLinking feature.
  4. 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=autolink

The 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.debug and 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.

FieldSourceAlways or conditional
@contextthe literal https://schema.orgalways
@typeBlogPosting, or Blog on the generated index pagealways
@idthe page’s absolute URLalways
urlthe page’s absolute URLalways
headlinethe page’s titlealways
datePublishedpublishedAtalways
dateModifiedmodifiedAtalways
publisher.namethe site titlealways
publisher.logothe site logo, resolved to an absolute URLalways
publisher.descriptionthe site descriptionalways
imagefeatureImage.src, absoluteonly when the page has a feature image
keywordsthe page’s keywordsonly when set
descriptionthe page’s summaryonly when set
wordCountthe page’s wordCountonly when non-zero
authorone Person per visible author, with familyName and givenNameonly 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

HelperFormParametersRequires on contextReturnsBracesOn bad input
dateblock1. format string, required · 2. context object, optional (shifts)timezone and dateFormat on the resolved contextformatted string{{ }}non-string format throws TypeError; called inline, returns "" silently
latestDateinline1. dateField string, required · 2. collectionId string, requiredcollectionsthe greatest string value found{{ }}non-string in either position throws TypeError; unknown collection returns ""
processinline1. content string, requiredpageTypeList, pageTypeId, autolink, entitlements, disableAutolink, pageraw HTML{{{ }}}falsy content: log.warn + ""; a partial that will not compile is left verbatim
JSON-LDinlinenonepageTypeId; the page recordraw 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).

ESC