URL and Link Helpers

Five helpers, doing two jobs. Four of them build an address: this page’s, the site’s, the preview host’s, or one of those with a query string on the end. The fifth builds the two _redirects lines that send a page type’s index somewhere else.

Four are pure string work with no context requirement at all. Only url needs to know what page it is on, and only resolveRedirectIndex reaches into the build’s collections. All five assume the mechanics on How Helpers Work — particularly the trailing options object, which has sharp consequences on two of the five.

url — this page’s address

url takes one optional parameter and gives you the current page’s address, absolute by default:

{{ url }}              → https://example.com/blog/my-post/
{{ url "relative" }}   → /blog/my-post/

The address itself comes from the page context — page.url, falling back to page.filePathStem. Three things then happen to it:

  • An /index segment truncates the path. The path is cut at the first occurrence of /index, keeping the slash, which is what turns /blog/my-post/index.html into /blog/my-post/.
  • A value already starting with http passes through unchanged, so an absolute URL is never prefixed twice.
  • Anything else gets https://<your domain> prepended when absolute was requested.

When no URL can be extracted at all — url called somewhere with no page on the context — the helper returns undefined and logs Could not extract a url from the provided context! under the urls logger. In a template that renders as nothing, so watch the build console rather than the page.

The canonical call sites are the feed templates, which need both forms in the same file: the Atom feed uses {{ url }} for <id> and <link rel="alternate"> and {{ url "relative" }} where a path is wanted. Feeds, Sitemaps and robots.txt covers what those templates emit.

siteUrl and pagesUrl — the two hostnames

Neither takes a parameter and neither needs a page context. siteUrl returns your site’s origin and pagesUrl returns the Workers preview host:

{{ siteUrl }}    → https://example.com
{{ pagesUrl }}   → https://example-com-preview.workers.dev

Neither has a trailing slash, which is why the shipped robots.hbs writes the separator itself:

sitemap: {{ siteUrl }}/sitemap.xml

Both are read from the build environment, so they differ between a preview build and a production one. Leed’s _headers template turns that into a real behavior: on a preview build it marks both hosts noindex, and on a production build only the preview host.

{{#if @root/leedSiteEnv.env.preview}}
{{ siteUrl }}/*
    X-Robots-Tag: noindex
{{#unlessEq (siteUrl) (pagesUrl) }}
{{ pagesUrl }}/*
    X-Robots-Tag: noindex
{{/unlessEq}}
{{else}}
{{ pagesUrl }}/*
    X-Robots-Tag: noindex
{{/if}}

Two things in that snippet are worth stealing. The parentheses around (siteUrl) and (pagesUrl) are required — as subexpressions they are evaluated and their results compared, where the bare names would be treated as missing context variables. And which hostname siteUrl resolves to is decided by the branch the build ran from; Preview Site vs Live Site explains which branch produces which.

pagesUrl is marked Templates: Leed in its source docblock, and leed-headers.hbs is the only shipped template that calls it. It is documented here so that reading that template makes sense, not because a customer site normally needs the preview hostname — if you find yourself linking to it from page content, you almost certainly want siteUrl.

A build-config failure, not a template failure

The URL plugin validates its configuration when it is installed, not when a template renders:

[urls] domain or pagesDomain not set
Error: domain or pagesDomain not set

buildUrl — appending a query string

Two parameters, both required, and it chooses the separator for you:

{{ buildUrl "/pricing/" "utm_source=footer" }}
→ /pricing/?utm_source=footer

{{ buildUrl "/pricing/?plan=growth" "utm_source=footer" }}
→ /pricing/?plan=growth&utm_source=footer

The rule is exactly “does the first argument contain a ? anywhere” — not “does it end with a query string” — so a URL with a ? in a fragment or a path also gets &.

The shipped use is the popup-video component, which appends an autoplay flag to a video URL only when the modal opens:

:src="showModal ? '{{ buildUrl videoSrc "autoplay=true" }}' : ''"

Omitting the second argument is not safe either: the options object lands in params and you get ?[object Object] appended. Omitting both is worse — url.indexOf is called on the options object, which does not have that method, and the build stops with a TypeError.

resolveRedirectIndex — a page type’s index redirect

A page type can declare that its index URL should redirect somewhere rather than render a listing. resolveRedirectIndex is what turns that setting into rules. It takes two required arguments, and the second is not optional in any sense — there is no parameter shift here:

{{#each pageTypeList }}
  {{#if redirectIndex }}
{{ resolveRedirectIndex this ../this }}
  {{/if}}
{{/each}}

this is the page-type object; ../this reaches back out of the each to the page context, which is where collections lives. It emits two tab-separated lines, one for the bare index and one for its index.html form:

/blog/	/blog/the-latest-post/	302
/blog/index.html	/blog/the-latest-post/	302

Those tabs are the field separator Cloudflare’s _redirects format expects, which is the reason this helper exists at all rather than the template writing the lines itself.

redirectIndex valueResolves toNotes
first/<page-type-slug>/<first page's slug>/The :unpaged collection is ordered by ascending publishedAt, tie-broken by URL, so first is the oldest page, not the newest
last/<page-type-slug>/<last page's slug>/Same ordering, so last is the most recently published
A path starting with /The path itselfA trailing / is appended when missing. The value is matched lower-cased but emitted with its original casing
A URL containing ://The URL, normalized through new URL()Use this to send an index off-site

The match is case-insensitive, so First and FIRST both work.

Three ways it produces nothing. A page type with no redirectIndex returns an empty string immediately and silently — that is the normal case for most page types. first or last against an empty collection, or against a page with no slug, also returns an empty string. A value that matches none of the four shapes logs an error naming the value and the collection size, then returns an empty string:

[customFields] redirectIndex: 'newest' has an issue; 12 items in collection

redirectIndex is a page-type setting, chosen in the CMS at Configuring a Page Type; this helper only renders the choice. It is not the mechanism behind the redirects Leed writes when a published page’s URL changes — those come from the page’s aliases, covered in Aliases and Redirects, and appear in the same _redirects file a few lines above these two.

Reference

HelperFormParametersRequires on contextReturnsBraces
urlinlinetype — string, optional. Only the literal "relative" is recognized; everything else, including the options object, means absoluteA page context carrying page.url or page.filePathStemA string, or undefined with a log.warn{{ }}
siteUrlinlinenonenothinghttps://<domain>, no trailing slash{{ }}
pagesUrlinlinenonenothinghttps://<pagesDomain>, no trailing slash{{ }}
buildUrlinlineurl — string, required. params — string, required. No encoding is applied to eithernothingThe two joined by ? or &{{ }}
resolveRedirectIndexinlinepageType — a page-type object carrying slug and redirectIndex, required. context — an object carrying collections, requiredcollections on the context you pass, not on thisTwo tab-separated _redirects lines, or ""{{{ }}} when the destination can contain &

resolveRedirectIndex is the one row here that does not return a SafeString. Leed’s own leed-redirects.hbs calls it with {{ }}, which is correct for the ordinary case because a path contains nothing Handlebars escapes — but an off-site destination carrying a query string would have its & written into _redirects as &amp;. Use {{{ }}} if that is reachable for your page types. The full picture of which helpers escape and which do not is on Helper Gotchas and Failures.

Every one of these five can also be called from CMS page content, not just from a template file, because process expands partials against the page context before the content is written out — see Date and Content Helpers.

ESC