inmake DOCS
Docs / Generative / Handlebars binding

Handlebars binding

The "handlebars-binding" rule fragment — composed into the CRM & Studio agents' prompts and served as drop-core://rules/handlebars-binding.

DATA BINDING: the template HTML is rendered with Handlebars against the document data, so bind every value to a field instead of hardcoding it:

  • Text: {{ document.hero.title }} (or {{ hero.title }}) — double braces, HTML-escaped. Rich-text values (the HTML fragment string an editor:"html" text field stores, block-editor values) render their markup through {{ }} as well, so double braces are always the safe default for text.
  • BRACES ↔ FIELD KIND: {{{ … }}} (triple, raw) ONLY for values that ARE markup — an editor:"html" text field (its value is the HTML fragment as a plain string — a single line bare, "Firma v oblasti <em>recyklace</em>", so it fits a heading or any inline slot; "<p>…</p><p>…</p>" only for several blocks; never a {type:"html"} wrapper), a block-editor field, an icon field (inline <svg>), and the .html of a rendered reference / element placement. Everything else — plain text, numbers, enums, dates, URLs — binds with {{ … }}. A text string that carries an HTML tag renders as markup under BOTH brace forms, so a plain-TEXT field must never hold tags (the renderer would show them as formatting, and click-to-edit opens the field as rich text): when a value needs bold/links/line breaks, declare the field editor:"html" and store the fragment. Never bind a plain-text field raw: it is a raw-HTML hole the click-to-edit surface cannot address (the write tools warn on brace/kind mismatches).
  • Image field → a URL string: <img src="{{ document.hero.cover }}" alt="">.
  • Gallery (kind:"gallery") → an ARRAY of image values, each addressed exactly like a single image field: {{#each document.gallery}}<img src="{{ this.thumb }}" srcset="{{ this.srcset }}" alt="{{ this.alt }}">{{#if this.caption}}<figcaption>{{ this.caption }}</figcaption>{{/if}}{{/each}}. {{this}} alone is the picture's URL.
  • File field (kind:"file" — a PDF, a document, an archive) → a URL string at the field key, like an image: <a href="{{ document.manual }}" download>{{ document.manual.name }}</a>. The file INFO is addressable off the value: {{ document.manual.name }} (original filename), {{ document.manual.ext }} ("pdf"), {{ document.manual.type }} (MIME), {{ document.manual.size }} (bytes) and {{ document.manual.size_text }} (localized, "15.3 MB" / "15,3 MB") — a download row is <a href="{{ this.file }}"><span>{{ this.file.name }}</span><small>{{ this.file.ext }} · {{ this.file.size_text }}</small></a>. Those names never resolve to the URL (an old node without them renders empty); per-usage metadata ({{ document.manual.title }}) binds exactly as on an image. NEVER write the name, type or size into neighbouring text fields — they come off the file value. A download BUTTON the visitor clicks is one object {label, file} (the GROUPING RULE of the field-kinds rule): <a href="{{ download.file }}" download>{{ download.label }} <small>{{ download.file.size_text }}</small></a>.
  • ALT TEXT AND CAPTIONS COME OFF THE IMAGE VALUE, not off a neighbouring field: {{ document.cover.alt }}, {{ document.cover.caption }} ({{ document.cover.metadata }} is the whole map) — the per-usage metadata the editor typed for THAT picture, declared by the field's metadata:[…] schema. Those names never resolve to the image URL, so an image with no alt typed renders alt="". NEVER write alt="{{ document.cover_alt }}" against a separate text field, and never bind a plain {{ image.src }} — an image field IS the URL at the field key (see IMPORTANT below).
  • Array of objects: {{#each document.shoes.products}}<div>{{ this.title }}</div>{{/each}}.
  • LINK FIELDS (kind:"link") — the way a page links to ANOTHER store document (a button, a CTA, a menu item, a footer link): the field's value is either a verbatim string (#anchor, tel:, https://…) or an envelope {type:"link", collection, document, hash?} that the renderer resolves to the target's public URL in the page's locale (slug + scope + collection route; the homepage → /; a deleted target → NOTHING). Bind it as <a href="{{ cta.href }}"> — DOUBLE braces, it is a plain value — and ALWAYS guard: {{#if cta.href}}<a href="{{cta.href}}">{{cta.label}}</a>{{/if}}. {{ cta.href.title }} is the target's title in the page's locale (a button labelled by its target). The link and what stands for it are ONE object field in the schema (cta: {label, href}, a linked picture {image, href}, an icon link {icon, label, href} — the GROUPING RULE of the field-kinds rule), so bind both off that object ({{cta.label}} + {{cta.href}}), never a label field and a link field that sit side by side at the top level. Never hand-build the path from the target's slug when a link field exists — the field already did it, locale and routes included (see the field-kinds rule). The {{route}} idiom below stays for documents you reach THROUGH a reference (cards of a listing).
  • LINKING to a referenced document — a reference field resolves at render to the target doc(s), so a collection/list reference (e.g. products_preview) is an array of full docs and a document reference is one doc. Link fields INSIDE those docs (this.href) come back resolved to URL strings too. A sibling value (the dynamic prev/next form of a document reference — see the reference kind in the field-kinds rule) is ONE doc or NOTHING (the first/last document of the sequence): ALWAYS guard it — {{#if next}}<a href="…">{{next.title}}</a>{{/if}} — then link it exactly like any resolved document reference (with render:true it gains the same .html key as an embedded component). When you render such an item as a card/link, point it at the item's OWN page: prefer its slug, fall back to the reference route. Never use href="#" or an invented path for these. {{#each document.products_preview}}<a href="{{#if this.slug}}/{{this.slug}}{{else}}/ref/preview/{{this.id}}{{/if}}">{{ this.title }}</a>{{/each}} — use the item's this.slug (or this.seo.slug) when present, else /ref/{collection}/{{this.id}} where {collection} is the reference field's TARGET collection (here preview). Every resolved item carries this.id, so the /ref/… fallback always works. PREFER the {{route}} helper for the slug branch: {{route "preview" this.seo.slug}} renders /{{slug}}, or /{segment}/{{slug}} when that collection has ROUTING enabled (setup/data collections[].route.enabled — its documents then live under the collection's own URL segment, its locale slug or key). Same fallback shape: <a href="{{#if this.seo.slug}}{{route "preview" this.seo.slug}}{{else}}/ref/preview/{{this.id}}{{/if}}">. A CATEGORIZED document (its seo.category names one of the collection's route.categories) lives at /{segment}/{category}/{slug} — pass the category as the THIRD argument: {{route "recipes" this.seo.slug this.seo.category}} (an empty or unknown category gives the plain /{segment}/{slug}, so pass it whenever the collection may have categories). JS-built cards (load-more) read the same map from window.DROP_ROUTES ({recipes: "recepty"}, routed collections only) and the category paths from window.DROP_ROUTE_CATEGORIES ({recipes: {soups: "polevky"}}, collection → category key → its path in the page language; absent when nothing is categorized), so a card's path is '/' + DROP_ROUTES[c] + (cat && DROP_ROUTE_CATEGORIES?.[c]?.[cat] ? '/' + DROP_ROUTE_CATEGORIES[c][cat] : '') + '/' + slug, and build a page link as window.DROP_BASE + (window.DROP_LOCALE_BASE || '') + path — DROP_LOCALE_BASE is the page language's URL prefix (/en) on a store that keeps the language in the URL, absent otherwise. The general element kit's runtime exposes this as DropElements.pageHref(path).
  • EMBEDDING a referenced document as a COMPONENT — a document reference whose stored value carries render: true is rendered by the server through the TARGET's own schema template, and the resolved value gains an html key holding that markup (its CSS/JS are hoisted into the page for you). Drop it in with {{{ document.contact_form.html }}} and do NOT re-author the component's markup in this page's template — the whole point is that the component's markup lives in ONE template (its own) and appears on many pages. The target's other fields stay addressable alongside it ({{ document.contact_form.headline }}), so you can still wrap it in your own section/heading. render is set on the VALUE (the reference picker's "Render with its own template", or your document-value write) — a reference without it resolves to data only and has no .html.
  • Icon field → inline markup: {{{ document.hero.icon }}} (triple braces).
  • Datetime field → renders ALREADY LOCALIZED in the page's locale ({{ document.published }} → 11. 6. 2026 on a cs_CZ page; which parts show — date, time, both — follows the field's date/time flags). The stored machine form stays at {{ document.published.iso }}: use <time datetime="{{ document.published.iso }}">{{ document.published }}</time> for markup-visible dates, and .iso for comparisons/sorting. This works the same for datetime fields of resolved reference items ({{ this.published }} inside {{#each}}). NEVER hand-format a date, add a second display-string field for one, or rewrite dates client-side in JS. NO HARDCODED TEXT: a page/component template must contain NO literal human-readable text. EVERY visitor-facing string — headings, eyebrows/kickers, button and badge labels, empty-state copy, and the alt/placeholder/aria-label/title attributes — is either a document-field binding or a {{t "group.key"}} store translation (see HELPERS below). The template is shared by every locale (and, for a shared schema, by every document), so a literal string can never localize and leaks the same words onto every page. When the copy has no home yet, CREATE one instead of inlining the literal — and pick the home by whether the words VARY: copy that differs from document to document → a field of the schema (set its value in each document); a string that is the same everywhere it appears → a {{t}} translations key. On a SHARED schema (one template rendering many documents — articles, campuses, services) every label of the template's own chrome — a sub-section heading (Máte dotaz?, Galerie, Lokalita), a button label (Kontaktujte nás), load-more / empty-state / tab / prev-next / breadcrumb labels — is the SAME on every document, so it is a translations key (one group per context: campus, article, ui), NEVER a field every document must fill and every editor must retype; a per-document OVERRIDE the user wants is an optional field with the key as its fallback ({{#if ask_title}}{{ask_title}}{{else}}{{t "campus.ask_title"}}{{/if}}). On a PER-DOCUMENT schema (one page) that page's own copy is a field. Never inline a literal "for now". Not text for this purpose: punctuation/decorative glyphs (·, —, ×), numbers coming from data, and machine values (CSS classes, data-* keys, URLs). (Transactional invoice/e-mail templates are the one exception — they are per-locale documents whose inline text IS the localized copy.) IMPORTANT: file/image fields are ALREADY resolved to a URL string AT THE FIELD KEY in the render context — write <img src="{{ document.hero.cover }}">, NEVER {{ document.hero.cover.path }} (there is no .path/.url). SIZE VARIANTS of an image field ARE addressable: {{ document.cover.thumb }} is the thumb rendition's URL (an unknown/missing variant name safely falls back to the original — old uploads never break), {{ document.cover.srcset }} is a ready width-descriptor srcset (<img src="{{cover}}" srcset="{{cover.srcset}}" sizes="100vw"> for responsive heroes; it lists the variants AND the original when the value carries width — uploads since 2026-10-27 do; empty otherwise), {{ document.cover.width }} / {{ document.cover.height }} are the original's pixel size when known (write them as width/height attributes to avoid layout shift), and {{ document.cover.original }} is always the original. Variants exist only when the image was uploaded FOR its field: every generateImage / importImageFromUrl call whose result lands in a field passes fieldKey + schemaId (and the field declares sizes) — a result with a warning has no variants, so redo it. Use the smallest sufficient variant in listing cards (<img src="{{ this.cover.thumb }}">) instead of the full-size original. Text fields are plain strings unless declared editor:"html" (then their value is the html envelope — see BRACES ↔ VALUE SHAPE). Use the ACTUAL field keys from the current schema below; do NOT hardcode copy that belongs in a field. HELPERS — ONLY the following are registered. Using ANY other helper (e.g. inc, concat, formatDate, json, capitalize, math, times) throws at render time and the WHOLE page falls back to raw, un-interpolated source. If a helper you want is not listed, compute it in the data via setDocumentValues instead — never invent one (dates never need this: datetime fields localize themselves, see above).
  • Built-ins: {{#if x}}…{{else}}…{{/if}}, {{#unless x}}, {{#each list}}…{{/each}} (inside each: {{this}}, {{@index}}, {{@first}}, {{@last}}, {{@key}}), {{#with obj}}, {{lookup obj key}}.
  • Comparisons (return a boolean — use as subexpressions inside #if): (eq a b), (ne a b), (gt a b), (lt a b), (gte a b), (lte a b) — e.g. {{#if (eq @index 0)}}active{{/if}}.
  • Boolean logic: (and a b …), (or a b …), (not a) — e.g. {{#if (and title subtitle)}}.
  • Arithmetic: (add a b), (subtract a b), (multiply a b) — e.g. alt="Image {{add @index 1}}".
  • Block equality shortcut: {{#ifEq a b}}…{{else}}…{{/ifEq}}.
  • Collection route: {{route "{collection}" slug}} / {{route "{collection}" slug category}} — the public path of a document reached through a reference (see LINKING above); the optional third argument is the document's seo.category (its category key) and adds the /{category} segment when that category is declared on the routed collection. The collection is a LITERAL string; a path expression that resolves to nothing renders empty.
  • Store translations: {{t "group.key"}} — resolves a shared per-store string from the localizations store (documents under the translations data collection: {{t "cta.buy"}} reads key buy of group doc cta, in the page's locale). Use it for SHORT REUSABLE UI strings that repeat across templates — button labels (Buy, Add to cart, Send, Read more), badges (Sale, New), empty-state and form-feedback copy — instead of hardcoding them per template. Optional {name} placeholders interpolate from hash args: {{t "cart.count" count=items.length}}. A missing key renders the key itself (cta.buy) — so only reference keys that exist (readable via the translations collection), or create them first when you can write collections. Several positional arguments join with a dot — {{t "tag" this}} → tag.sale — the form for a key held in DATA (a product's tags / categories: {{#each tags}}<span class="tag">{{t "tag" this}}</span>{{/each}}, {{t "category" category}}), since there is no concat helper. Page-specific copy (headlines, body text) stays in document fields, NOT in translations.