Form and CTA Helpers

Four helpers, two of which you should use and two of which you should stop using. fieldId and turnstileKey exist because a hand-written form needs the same ids and the same site key that Leed’s own form partial generates. CTA and Recommendations are the older, id-based way to place a dynamic content slot; they still work, they no longer do what most existing examples say they do, and there is a plain HTML attribute that replaces both. The registration mechanics all four rely on are established at How Helpers Work.

fieldId — connecting a label to its control

fieldId builds the id that ties a <label for> to the control it labels. It takes one required parameter, the form’s id, and reads the rest from this — which must be a form field record, so the call only makes sense inside a field iteration.

{{#each fields}}
  {{#if this.labelBefore}}
    <label for="{{ fieldId ../../formId }}">{{{ this.labelBefore }}}</label>
  {{/if}}
  <input id="{{ fieldId ../formId }}" name="{{ this.name }}" type="{{ this.type }}">
{{/each}}

The relative path is the fiddly part, and it is worth counting rather than guessing. Every block helper pushes a context frame, {{#if}} included — so inside {{#each fields}} the form id is one step up at ../formId, and inside an {{#if}} nested in that each it is two, at ../../formId. Leed’s own form partial uses both depths on adjacent lines for exactly this reason.

What comes back depends on the field type:

FieldReturnsExample
any type without a value<formId>-<name>contact-us-email
radio or checkbox with a value<formId>-<name>-<escaped value>contact-us-plan-growth

The value is HTML-escaped before it is interpolated, so an option value containing a quote cannot break out of the attribute. The result is a plain string rather than a SafeString, which is correct — Handlebars escapes it again on output, and an id has nothing in it that needs to survive escaping.

If you are placing {{> leed/form }} rather than building the markup yourself, this is already wired for every field type the builder can produce — see Form Partial, which documents each one.

turnstileKey — the site key for the spam widget

turnstileKey returns the Cloudflare Turnstile public site key for the build you are running, so a hand-written form can carry the same hidden challenge widget the form partial does.

<div class="cf-turnstile" data-sitekey="{{ turnstileKey @root }}" style="display: none;"></div>

The one parameter is not optional and is not read from this. Pass @root. The helper reaches into the context for leedSiteEnv.env.preview and for the deployment record, neither of which is on a page-level or field-level this:

  • On a preview build it returns deployment.preview.turnstilePublicKey.
  • Otherwise it returns deployment.public.turnstilePublicKey.
  • In either case, if that key is unset it falls back to Cloudflare’s always-pass invisible test key, 1x00000000000000000000BB.

Which build is which comes down to the branch you built from, described at Preview Site vs Live Site.

Calling {{ turnstileKey }} with no argument does not fall back to the page — Handlebars hands the trailing options object into the context slot, the helper looks for leedSiteEnv on it, finds nothing, and the build stops with a TypeError. There is no guard.

CTA and Recommendations are deprecated

Start with what they emit now, because almost every existing example — including Leed’s own older material — describes something else. Neither helper emits a <script> tag any more. Each emits a single hidden <span> naming the element that should receive the content:

<span data-leed-cta-target="article-cta" hidden></span>
<span data-leed-recommendations-target="reco-slot" hidden></span>

The client picks the marker up on DOM ready and renders into document.getElementById(...) — the same element the old inline call targeted, so behavior is unchanged. Any snippet showing <script>callToAction('article-cta');</script> or <script>recommendations('recommendations');</script> is describing a version that no longer ships.

The reason for the change is recorded in the source, and it is not stylistic. An inline script forces every page carrying one of these widgets to allow unsafe-inline in its Content-Security-Policy, and the exemption is per page rather than per feature — so both widgets had to move or a page using either still needed it. On top of that, interpolating a caller-supplied target id straight into a JavaScript string literal was an unescaped injection sink. The marker has neither problem: it is a plain element, and the id is HTML-escaped into an attribute.

What to do instead

Put the attribute on the receiving element and delete the helper call. There is no id to keep in sync, no marker element and nothing for a Content-Security-Policy to allow.

The attribute goes directly on the element the content is appended to. Leed fills it at read time; you style it and leave it empty.

<section data-leed-cta class="mt-16 border-t pt-8"></section>

<section id="related">
  <h2>Related reading</h2>
  <div data-leed-recommendations></div>
</section>

The -container id in that second example is not decoration. When there is nothing to recommend, the recommendations client hides the widget, and what it hides depends on how you placed it: if the receiving element has an id and an element with the matching <id>-container id exists, that wrapper is hidden, heading and all. A declarative host has no such convention, so only the host itself is hidden — which is why the heading in the first example survives an empty result. Move the heading inside the element carrying data-leed-recommendations and you get the same disappearing-heading behavior with no marker and no id. Dynamic CTAs have no empty-state hide at all, in either form.

If you still call them

Both are still registered, still supported and still tested. If you are maintaining a template that uses them, these are the details that matter.

CTARecommendations
ParametertargetId, required stringtargetId, required string
Rejectsa non-stringa non-string or an empty string
On rejectionlog.error("CTA targetId must be specified") and ""log.error("Recommendations targetId must be specified") and ""
Reads from the pagedisableCtanothing — it never touches this
ReturnsSafeStringSafeString

CTA honors the page’s Disable Dynamic CTA setting: when disableCta is truthy the helper emits nothing at all, not even the marker. That check is the entire reason the helper needs a page context, and it is also the one thing the attribute form cannot do for you — a plain HTML attribute has no build-time logic behind it, so an attribute-based template must wrap its own slot in {{#unless disableCta}} to honor the same checkbox. Dynamic CTAs shows that wrapper.

Because both return a SafeString, the triple braces in the shipped templates are unnecessary — {{ CTA "article-cta" }} produces identical output. They are harmless, and changing them is not worth a commit.

The two gated behaviors

Neither helper is gated — both are registered on every plan and both emit their marker regardless of what you pay. What is gated is the endpoint that fills the slot.

Both gates degrade rather than fail, which is the general pattern described at When a Feature Is Gated. Testing either behavior from a template means testing the plan, not the helper — see Tier Gating in Templates.

Reference

HelperFormParametersRequires on contextEmitsBracesStatus
fieldIdinline1. formId string, requiredthis is a form field record (name, type, value)<formId>-<name>, or <formId>-<name>-<escaped value> for a radio/checkbox carrying a value{{ }}current
turnstileKeyinline1. context, required — pass @rootleedSiteEnv.env.preview and deployment on the passed contextthe preview or public Turnstile site key, or 1x00000000000000000000BB{{ }}current
CTAinline1. targetId string, requireddisableCta (optional, honored when truthy)<span data-leed-cta-target="<id>" hidden></span>, or nothing when disableCta is set{{ }} — it is a SafeStringdeprecated, use data-leed-cta
Recommendationsinline1. targetId non-empty string, requirednone<span data-leed-recommendations-target="<id>" hidden></span>{{ }} — it is a SafeStringdeprecated, use data-leed-recommendations

When you know a helper’s name and not which page documents it, the Helper Index (A–Z) is the way in, and the failure modes that cut across every group — including the unguarded TypeError that turnstileKey throws with no argument — are collected at Helper Gotchas and Failures.

ESC