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:
| Field | Returns | Example |
|---|---|---|
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.
- Attribute (supported)
- Shortcode (deprecated)
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 helper renders only the hidden marker, so you need the target element and the call, with the id repeated in both.
<section id="article-cta" class="mt-16 border-t pt-8"></section>
{{{ CTA "article-cta" }}}
<section id="reco-slot-container">
<h2>Related reading</h2>
<div id="reco-slot" 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.
CTA | Recommendations | |
|---|---|---|
| Parameter | targetId, required string | targetId, required string |
| Rejects | a non-string | a non-string or an empty string |
| On rejection | log.error("CTA targetId must be specified") and "" | log.error("Recommendations targetId must be specified") and "" |
| Reads from the page | disableCta | nothing — it never touches this |
| Returns | SafeString | SafeString |
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
| Helper | Form | Parameters | Requires on context | Emits | Braces | Status |
|---|---|---|---|---|---|---|
fieldId | inline | 1. formId string, required | this is a form field record (name, type, value) | <formId>-<name>, or <formId>-<name>-<escaped value> for a radio/checkbox carrying a value | {{ }} | current |
turnstileKey | inline | 1. context, required — pass @root | leedSiteEnv.env.preview and deployment on the passed context | the preview or public Turnstile site key, or 1x00000000000000000000BB | {{ }} | current |
CTA | inline | 1. targetId string, required | disableCta (optional, honored when truthy) | <span data-leed-cta-target="<id>" hidden></span>, or nothing when disableCta is set | {{ }} — it is a SafeString | deprecated, use data-leed-cta |
Recommendations | inline | 1. targetId non-empty string, required | none | <span data-leed-recommendations-target="<id>" hidden></span> | {{ }} — it is a SafeString | deprecated, 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.