Collection, Lookup and Series Helpers

The CMS data a template can reach comes in two shapes. Some of it is a keyed map you look up by uuid — the page types, the users, the labels — and some of it is an Eleventy collection you iterate. These sixteen helpers cover both, plus the three small readers that turn a page-type id into a slug, a word count into a reading time, and an author record into a name. If you have not read How Helpers Work, start there: the trailing options object and the parameter shift it forces show up in half the signatures below. The Helper Index (A–Z) is the way back out if you arrived here by name.

Nothing on this page returns raw HTML, so double braces are correct throughout — and block helpers never escape what their body renders.

Looking up a record by id: pageType, user, label

Three helpers, one shared implementation. Each takes a uuid and an optional field name.

{{ pageType this.pageTypeId "slug" }}
{{ user list.name "slug" }}
{{ label list.name "name" }}

Name a field and you get that field’s value. Omit it and you get the whole record — this trio is one of the eight helpers that shift their arguments, so leaving the middle parameter out is supported rather than merely tolerated:

{{#with (label this)}}
  <p class="series-name">{{ this.name }}</p>
  <p class="series-description">{{ this.description }}</p>
{{/with}}

All three also work as blocks, in which case the looked-up value becomes the block context:

{{#with (user list.name)}}
  <h1>Articles by {{ fullName }}</h1>
  <p>{{{ biography }}}</p>
{{/with}}

{{#user authorId}}
  <span>{{ firstName }} {{ lastName }}</span>
{{/user}}

A null id is the one input that is handled: it returns an empty string without touching the map.

Iterating a page’s people and topics: Authors, Labels

Authors walks this.authors; Labels walks this.labels. Both take the context to read from — pass this — and an optional visible boolean:

{{#Labels this true}}
  <a href="/blog/topic/{{ this.slug }}/">{{ this.name }}{{#unless isLast}}, {{/unless}}</a>
{{/Labels}}

Pass true and only records marked visible in the CMS are iterated. The boolean shifts if you omit it, and the effective default is false — meaning invisible authors and labels are included unless you ask for visible ones. Public-facing lists almost always want true; the shipped index and feed templates pass it everywhere.

Block variables

The per-iteration variables are not the ones Handlebars’ own {{#each}} gives you, and the difference catches people out. Five live on the item, so you read them with a plain name; one lives on the data frame, so it takes an @:

VariableWhere it livesValue
indexon the itemposition, 1-based
totalon the itemnumber of records in this iteration
isFirston the itemtrue on the first record
isLaston the itemtrue on the last record
idon the itemthe record’s uuid
@indexdata frameposition, 0-based

fullName and the author context

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

fullName takes no arguments and joins this.firstName and this.lastName with a single space. It reads this directly, with no null handling and no context resolution — so outside an author record it renders the literal string undefined undefined. That is the clearest example in this whole reference of a helper that demands a scope rather than finding one: it is correct inside {{#Authors}}, inside {{#user id}} as a block, inside {{#with (user id)}}, and on an author list page; it is wrong everywhere else, and it tells you so in the rendered HTML rather than in the build log.

Series

Series, MultipartSeries and AnnouncementSeries are the same helper with a different filter. Each takes the label id string and iterates the pages of that series, ascending by publishedAt and tie-broken by URL. MultipartSeries matches only labels whose series type is multipart, AnnouncementSeries only announcement, and Series matches either.

The mechanism is worth knowing because it is not guessable from the call. The helper composes a collection name out of the current page’s page type and the label you handed it, then iterates that collection:

pageTypeId-<current page's pageTypeId>:labelId-<the label id you passed>
flowchart TD
    A["Current page<br/>pageTypeId = ptblog"] --> C
    B["Label id you pass<br/>lbHowTo"] --> C
    C["Composed collection name<br/>pageTypeId-ptblog:labelId-lbHowTo"] --> D{"Is that label's series type<br/>the one this helper wants?"}
    D -- "no match" --> E["Block never runs<br/>nothing is rendered"]
    D -- "match" --> F["Look the collection up<br/>on the page context"]
    F --> G["Pages of THIS page type only,<br/>carrying THAT label,<br/>ascending publishedAt, then URL"]
    G --> H["Block runs once per page<br/>index / total / isFirst / isLast / id"]

Two consequences fall straight out of that name.

A series is always scoped to the current page’s page type. The same label applied to a blog post and to a docs page produces two different series, and a blog post rendering {{#Series lbHowTo}} sees only the blog posts. This is usually what you want and never what you expected.

Getting the label id in hand

The id you need is the raw uuid, and the readable way to reach it goes through three nested blocks. This is the pattern the shipped blog layout uses:

{{#each this.labels}}
  {{#with (label this)}}
    {{#AnnouncementSeries ../this}}
      {{#if isLast}}
        <p>The latest page in <b>{{ ../this.name }}</b> is
           <a href="{{ this.data.page.url }}">{{ this.data.title }}</a>.</p>
      {{/if}}
    {{/AnnouncementSeries}}
  {{/with}}
{{/each}}

Read it one frame at a time:

  • {{#each this.labels}} — this is now the label’s uuid string, because labels on a page is an array of ids.
  • {{#with (label this)}} — this is now the label object, so {{ name }} and {{ description }} work.
  • {{#AnnouncementSeries ../this}} — ../this reaches back out one frame to the uuid, which is what the series helper wants. Inside the block, this becomes a collection item, so the page’s own fields are under this.data.

The extra .. steps are the price of that nesting: in the example above, ../this.name is the label’s name seen from inside the series block, and comparing the iterated page with the page being generated needs ../../../page.url.

IfMultipartSeries and IfAnnouncementSeries

These two answer “is this label a series of the given kind?” and render their block when the answer is yes. They are more forgiving than the iterators: each accepts either a label id string or a label object, and when the type matches, the block runs with the label object as its context.

{{#each this.labels}}
  {{#IfMultipartSeries this}}
    <section class="multipart-series">
      <p class="series-name">{{ this.name }}</p>
      <ol>
        {{#MultipartSeries ../this}}
          <li><a href="{{ this.data.page.url }}">{{ this.data.title }}</a></li>
        {{/MultipartSeries}}
      </ol>
    </section>
  {{/IfMultipartSeries}}
{{/each}}

Passing the id string works only where the context carries the label map, which is any real page context. A type mismatch renders nothing at all — there is no {{else}} branch. A missing argument logs Too few parameters. label is required. and renders nothing.

Note that this idiom lets you skip the {{#with}} layer entirely: {{#IfMultipartSeries this}} gives you the label object as the block context for free, and ../this is still the uuid for the iterator inside it.

Working with collections

collection — fetch one by name

{{#each (collection "pageTypeId-ptblog:unpaged")}}
  <li><a href="{{ url }}">{{ data.title }}</a></li>
{{/each}}

filterObject — narrow a keyed map

Takes a map, a field path, and an optional value. With a value it keeps entries whose field is strictly equal to it; without one it keeps entries whose field is merely truthy. The field is a dot path resolved with lodash’s get, so nested fields work.

{{#each (filterObject pageTypeList "includeInFeeds")}}
  <!-- @key is the pageTypeId of each page type that opts into feeds -->
{{/each}}

{{#each (filterObject labelList "series" "multipart")}}
  <!-- every label configured as a multipart series -->
{{/each}}

The value parameter shifts, so the two-argument form above is the supported way to ask for “truthy”. A value that is present but not an object logs This only works on maps and returns an empty object; a map that is null or missing entirely throws before that check is reached.

sorted — reorder a list

Collections arrive in the build’s own order — ascending publishedAt, then URL. sorted is how a template asks for a different one without writing a filter of its own.

{{#each (sorted (collection "pageTypeId-ptblog:unpaged") "data.title")}}
  <li>{{ data.title }}</li>
{{/each}}

{{#each (sorted list.pageData "data.publishedAt" reverse=true)}}
{{/each}}

The field is an optional dot path — omit it to sort the values themselves — and reverse=true is a hash option, not a positional argument. Four behaviors are worth knowing:

  • Collation is locale-aware, case-insensitive and numeric. "i18n" sorts among the letters rather than after Z, and "Chapter 10" follows "Chapter 9" instead of "Chapter 1".
  • Entries missing the field sort to the end in both directions. A card with no title is noise wherever it lands, so it does not lead the list when you ask for descending.
  • Numbers compare as numbers, not as strings, when both sides are numeric.
  • The list is copied before sorting. Collection arrays are shared by every template in the build; sorting in place would quietly reorder them for pages that never asked.

A non-array argument logs a warning and returns an empty array.

The collections Leed builds for you

Every page type gets a family of generated collections, and those names are what collection, sorted and the series helpers are pointed at. Two shapes matter on this page: pageTypeId-<id>:paged, the chunked list a paginated template renders, and pageTypeId-<id>:labelId-<labelId>, the one the series helpers compose by hand. The full set — what each one contains, how pagingSize and minimumItems shape it, and the PageData object a paged collection carries — is enumerated once at Collections and Pagination Data.

Page and page-type data: pageTypeSlug, readingTime

pageTypeSlug takes a page-type id or a page-type object and returns the slugified slug || name. It is memoized, because page types are a small fixed set re-slugified for every page and every sitemap row.

<loc>{{ siteUrl }}/sitemap-{{ pageTypeSlug this }}.xml</loc>

It is one of the helpers Leed’s own templates use rather than something you are likely to reach for; an id that is not a real page type throws.

readingTime takes no arguments and returns a number of minutes:

<span>{{ readingTime }} min read</span>

It resolves the context both ways — the current page first, then the surrounding options — which is what lets the same call work on an article page and inside a paginated list item. It needs wordCount on whichever it finds; with neither it logs Unidentified context. and returns an empty string. The divisor is the site’s readingWpm when that is set and non-zero, and 200 otherwise, and the result is floored with a minimum of 1, so a very short page reads as “1 min” rather than “0 min”.

Reference

HelperFormParametersRequires on contextReturnsOn bad input
pageTypeblock or inlineuuid string, required · field string, optional (shifts)—field value, or the whole recordunknown id with a field → throws; without a field → empty; null id → ""
userblock or inlineuuid string, required · field string, optional (shifts)—field value, or the whole recordas pageType
labelblock or inlineuuid string, required · field string, optional (shifts)—field value, or the whole recordas pageType
Authorsblockcontext, required (pass this) · visible boolean, optional (shifts, default false)authorsrendered block per authorno authors → ""; boolean omitted → works, logs Wrong number of parameters! authors; no arguments at all → throws
Labelsblockcontext, required · visible boolean, optional (shifts, default false)labelsrendered block per labelas Authors, logging Wrong number of parameters! labels
Seriesblocklabel id string, requiredpageTypeId, labelList, collectionsrendered block per page in the seriesunknown label id → throws; non-string → logs label needs to be a string + ""
MultipartSeriesblocklabel id string, requiredas Seriesas Series, multipart labels onlyas Series
AnnouncementSeriesblocklabel id string, requiredas Seriesas Series, announcement labels onlyas Series
IfMultipartSeriesblocklabel id string or label object, requiredlabelList when passed a stringblock rendered with the label object as contextmissing argument → logs Too few parameters. label is required. + ""; type mismatch → ""
IfAnnouncementSeriesblocklabel id string or label object, requiredlabelList when passed a stringas IfMultipartSeries, announcement labels onlyas IfMultipartSeries
filterObjectsubexpressionmap object, required · field lodash path, required · value, optional (shifts)—a new object, keyed the same waynon-object value → logs This only works on maps + {}; missing map → throws
collectionsubexpressionname string, requiredcollectionsthe collection arrayunknown name → undefined, silently
sortedsubexpressionlist array, required · field lodash path, optional (shifts) · reverse= hash, default false—a new, sorted arraynon-array → logs a warning + []
pageTypeSluginlinepageType id string or page-type object, required—slug string (memoized)unknown id → throws
readingTimeinlinenonewordCount, optionally readingWpmnumber of minutes, minimum 1no wordCount on either context → logs Unidentified context. + ""
fullNameinlinenonethis is a user record"First Last"outside a user record → the literal undefined undefined

When one of the throwing helpers does stop a build, the message you see in the console is cataloged at Common Error Messages.

A label becomes a series only once you give it a series type in the CMS, and the difference between multipart and announcement — what each does to your pages and which one you want — is at Labels and Series. Author visibility and the slug that puts a person on their own listing page are set at Authors and Contributors. These helpers are at their most useful on a list layout, and where those layouts live and how they bind to a page type is covered at Layouts and Page Types; once a list layout is paginated, the numbered links and the “showing X to Y of Z” line come from Pagination Helpers.

ESC