Sections
Reusable parts of your pages with fields anyone can edit: setting types, the template language, styles, limits, versions, copies and add-on sections.
Self-hosted videos, responsive pictures and licensed font uploads extend the template and font primitives described here.
Written 2026-09-27. A storefront page is a list of sections: the hero, the product collection, a band with an offer, a row of testimonials, questions and answers. Every theme comes with its own built-in sections, and you (or your agent) can write sections of your own: a section is written once, placed on as many pages as you like, and edited by anyone in the builder, field by field, without touching code.
Build a section instead of a custom HTML block when a layout repeats, or when someone should be able to change its words, colours or images later. A block is a one-off piece of markup; a section is a small, typed component with its own fields.
Nothing you do with sections reaches a shopper until you publish the storefront. Saving a section changes the draft; publish_storefront makes it live.
1. What a section is
A section is one package of five parts, and a sixth when it needs one:
- Settings: up to 40 typed settings (text, a colour, an image, a product, a list of questions…) in up to 8 groups. The builder turns them into fields; an agent fills them in. The same part says where the section may go (which kinds of page, the header or footer) and how many times one page may hold it.
- A template: the section's HTML, with
{{ }}to print a setting or a store value,{% if %}to show a part only sometimes and{% for %}to repeat a part for each item of a list. - Styles: CSS for this section only. Every rule stays inside the section, so nothing it does can reach the rest of the page.
- Presets: named starting points, such as "Announcement" or "With a code", each a set of settings.
- Details: an id, a name, a description, a category and a version.
- An interaction, optional:
behavioris an isolated worker for bounded section UI and commerce actions; it runs only after Scripts in sections is enabled. The olderscriptfield remains a separate, more powerful same-origin option. See section behaviours and section scripts.
Each time a section appears on a page is an instance, with its own settings. A promo band placed on the home page and on the catalog is one section and two instances, and changing one instance's text leaves the other alone.
There are four kinds of section in a store:
| Kind | Where it comes from | Can you change it? |
|---|---|---|
| Built-in | Every store has them: the theme's hero, collection, more products and styles sections, each page's own body, and custom HTML | You place, hide, remove and fill them in; the theme draws them |
| A theme's own | Picking a theme puts its header, footer and home page sections in your store (themes) | Yes: they are your own from then on, and picking the theme again never overwrites them |
| Your own | You or your agent saved it with save_section | Yes, and every save is a new version |
| From an add-on | An add-on you installed ships it | No; make your own copy with fork_section |
The theme's four home sections (hero, collection, more products, styles) can be hidden with set-section-visibility or taken off the home page for good with remove-section, for example when your own sections replace them. add-section { sectionId: "hero" } puts a removed one back, once. The one rule left for the home page: it has to show at least one visible section or block (home_empty). Every other page keeps its own body: the theme's (main), or on the product, catalog and cart pages a section of your own made to be that page's body (§6a).
Your own header and footer. A section whose slots include "header" (or "footer") can take the theme's own header (or footer) away: place one instance in that slot and every page shows your section instead, inside the page's header (or footer) landmark. The slot holds one section; leave it empty, or hide its instance, and the theme's own comes back. The payment page always keeps the theme's own. A header section can stay at the top of the window while the page scrolls: give its schema sticky: true (its slots must include "header") and the store makes its own header landmark sticky, above the page and below the store's own layers. In the builder's edit mode the header stays in place, so it never covers what you select under it. "header.top" and "footer.top" are different: they add sections and blocks above the theme's header and footer and keep it.
2. The package
| Field | What it is |
|---|---|
schemaVersion | Always 1: the version of the section format. |
id | 2 to 40 lower-case letters, digits and single hyphens, starting with a letter. Built-in ids and layout words (hero, main, start, page and the like) are taken. |
name | What the builder calls it, up to 60 characters. |
description | One or two sentences for the builder's section picker, up to 400 characters. |
category | One of hero, text, products, collections, media, social-proof, promotion, layout, custom. |
version | major.minor.patch, such as 1.2.0. Leave it out when saving and the next version is chosen from what changed. |
author | { kind, name }: kind is "owner" for the store's own sections. Optional. |
origin | "store" for the store's own; add-on sections are read-only. Optional. |
app | Only on an add-on's sections: the add-on that ships it. |
forkedFrom | Set for you on a copy made with fork_section: { id, version, origin }. |
rendering | Always "template" for your own sections. Optional. |
schema | { settings, groups, pages, slots, maxInstancesPerPage, elements, fieldOnly, sticky, body }: the settings the builder and agents fill in, where the section may go and how often; elements and fieldOnly: part editable; sticky: true keeps a header section (slots include header) at the top of the window; body: a page kind makes it that page's body (the *-page parts). Up to 40 settings in up to 8 groups; pages from home, catalog, product, page, cart, blog, post (default home and page); slots from header.top, footer.top, header, footer (default none); maxInstancesPerPage 1 to 60 (default 10). |
template | The section's markup with {{ }} output, {% if %} and {% for %}, up to 32 KB, in the template language described below. |
styles | CSS for this section only, up to 16 KB. Every rule is confined to the section; :scope or & means the section itself. Colour, number, range, select, on/off and image settings arrive as var(--fh-s-<setting in kebab case>). |
script | Optional legacy same-origin module; new or changed source requires a signed-in person. Agents and apps use isolated behavior instead. |
behavior | Optional isolated section behaviour worker; export default function mount(api). Bounded UI, overlay, commerce and section storage bridge. See describe_sections part behaviors. |
artwork | Optional: { name: svg text }, up to 32 of 32 KB, for the icon tag. |
presets | Up to 12 named starting points: { id, name, description?, settings } with some of the settings filled in. |
3. Setting types
| Type | What it holds | Its own fields |
|---|---|---|
text | One line of text, up to 600 characters. | default, maxLength, placeholder, required, inlineEditable |
textarea | Plain text over several lines, up to 4,000 characters. | default, maxLength, placeholder, required, inlineEditable |
richtext | Formatted text: paragraphs, bold, italic, links, lists and small headings, up to 8,000 characters. | default, maxLength, required, inlineEditable |
number | A number, with an optional minimum, maximum, step and unit. | default, min, max, step, integer, unit |
range | A number picked on a slider between a minimum and a maximum. | min (required), max (required), step (required), default (required), unit |
boolean | On or off. | default |
select | One choice from a list of 2 to 24 options. | options (required), default (required), display |
color | A colour as #rrggbb, or #rrggbbaa with an opacity pair, or empty for the theme's own. | default |
font | A family from the font list (part fonts) and the weights used, or empty for the theme's own. | default, weights, italic |
image | An image from your store's media library. | default, required |
link | Where a button or link goes: a page on your store, an https:// address, mailto: or tel:. | default |
product | One of your products, by its handle. | default |
collection | One of your collections, by its handle. | default |
url | An outside https:// address. | default |
list | Repeated items of the same shape, such as questions or testimonials, up to 24. | fields (required), itemLabel, minItems, maxItems, default |
html | Your own HTML (built-in custom HTML section only). | default |
css | Your own CSS for this section (built-in custom HTML section only). | default |
Every setting also has key (letters, digits and _, starting with a letter; the template reads it as settings.<key>), label, and optionally help and group. inlineEditable lets people type a text, textarea or rich text setting on the page itself in the builder.
Every default has to fit its own setting: a default colour is a real #rrggbb, a default link is a real address, a default list's items have the list's fields. A setting a template reads but the section does not declare is refused when you save.
Images are always your store's own. Upload one first (upload_image, or the media library) and use its key (products/<id>.jpg) or its /media/ path; a full address on your own store's domain is turned into its path for you, and an image anywhere else is refused, so a section can never show something from a site you do not control.
image_url: 800 asks your store for a copy no wider than about 800 pixels. Copies are made at a fixed set of widths (64 up to 2,400, rounded up to the next one) and kept, so a large photo is sent at the size the page shows it; if a copy cannot be made, the original is sent instead and the page still shows the image.
4. Templates
A template is ordinary HTML with three additions: {{ }} prints a value, {% if %} shows a part only when a condition holds, and {% for %} repeats a part for each item of a list. A template reads only the objects listed below, and a template cannot fetch, include another file, run code or keep variables of its own (interactive behaviour goes in the section's isolated behaviour, or a legacy script). Every object, filter and tag comes with an example and exactly what it renders.
Tags
| Tag | Syntax | What it does | Example | Renders |
|---|---|---|---|---|
| output | {{ value | filter: argument }} | Prints a value, escaped. Chain up to 6 filters with |. | <p>{{ settings.tagline }}</p> | <p>Cups & <saucers></p> |
| if | {% if condition %}…{% elsif condition %}…{% else %}…{% endif %} | Shows a part only when a condition holds. elsif and else are optional; conditions may use filters, the operators, and, or and not. | {% if settings.showBadge %}<span>Sale</span>{% else %}<span>New</span>{% endif %} | <span>Sale</span> |
| for | {% for item in list | reverse limit: 8 offset: 0 %}…{% else %}…{% endfor %} | Repeats a part for each item of a list; | reverse (optional) turns it round first. limit (a whole number or a number setting) is at most 50, and a loop without one stops there; offset skips items first. The else part shows when the list is empty. Inside, loop tells you where you are. | {% for product in settings.shelf.products limit: 8 %}{{ product.title }};{% endfor %} | Blue mug;Side plate; |
| for-else | {% for item in list %}…{% else %}…{% endfor %} | What to show when the list has nothing in it. | {% for question in settings.questions %}{{ question.question }}{% else %}Nothing yet{% endfor %} | Nothing yet |
| comment | {# a note #} | A note for whoever edits the template; never printed. | A{# not shown #}B | AB |
| widget | {% widget "newsletter" label: "…" placeholder: "…" button: "…" note: "…" %} | Places a store control: newsletter, search, cart-button, account, quick-add (part widgets). Arguments are quoted text or a setting (empty takes the default). Between elements, outside loops, at most 4. | {% widget "account" label: "Sign in" %} | <a class="fh-widget fh-widget-account" data-fh-widget="account" href="/account"><span class="fh-widget-label">Sign in</span></a> |
| icon | {% icon "star" size: 20 label: "…" %} | Draws an icon (part icons) in the text colour; the name is quoted text or a setting. size 12 to 96 (24). A label makes it announced. Between elements only. "art:<name>" draws its artwork; size is the width, 8 to 1600. | {% icon "check" size: 16 %} | <svg class="fh-icon fh-icon-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M5 12l5 5l10 -10"/></svg> |
Operators: == (equal), != (not equal), > (greater than (numbers)), < (less than (numbers)), >= (greater than or equal (numbers)), <= (less than or equal (numbers)), contains (a list holds a value, or a text holds another text), and (both are true), or (either is true), not (the opposite, written before a value).
Everything printed with {{ }} is HTML-escaped (& < > " and ' become entities), so text from settings or products never becomes markup. | raw prints a value as HTML; the section's markup, raw parts included, keeps a custom HTML block's rules (no scripts, event handlers or inline styles; links only to your store, https, mailto: and tel:).
In a condition, false, empty (null), an empty text, 0 and an empty list count as false; everything else counts as true. Printing empty prints nothing; printing true or false prints the word; printing an image, product or collection prints nothing (print one of its fields instead).
Text outside {{ }} and {% %} is printed exactly as written, spaces and new lines included.
Objects
settings (every section): This instance's settings by key. Product, collection and image settings are objects (empty when none is chosen); a list setting is a list of items.
| Field | Type | Meaning |
|---|---|---|
| <key> | setting | One setting's value, typed by its definition. |
Example: <h2>{{ settings.heading }}</h2> renders <h2>Winter sale</h2>.
section (every section): This instance and where it sits.
| Field | Type | Meaning |
|---|---|---|
| id | text | The instance id, unique on the page; an id attribute shows as fhc-<id>. |
| type | text | The section's id. |
| version | text | The section version being shown. |
| index | number | Its position among the page's sections, from 1. |
| first | boolean | True for the page's first section. |
| last | boolean | True for the page's last section. |
Example: <div id="{{ section.id }}"> renders <div id="promo">.
store (every section): Your store.
| Field | Type | Meaning |
|---|---|---|
| name | text | The store's name. |
| headline | text | The store headline from the theme settings. |
| intro | text | The store introduction from the theme settings. |
| currency | text | The store currency, such as GBP. |
| locale | text | The store language and region, such as en-GB. |
| logo | image? | The store logo, when set. |
| socialProfiles | list<socialProfile> | The store's social profiles. |
| contact | contact | How to reach the store, as its footer shows it. |
Example: {{ store.name }} renders Hallo Ceramics.
page (every section): The page being shown.
| Field | Type | Meaning |
|---|---|---|
| kind | text | home, catalog, product, cart or page (a content page). |
| title | text | The page's title. |
| slug | text | A content page's slug; empty elsewhere. |
| url | url | The page's address. |
| product | product? | The product, on a product page. |
| collection | collection? | The collection, on a collection's listing. |
Example: {% if page.kind == "home" %}Welcome{% endif %} renders Welcome.
cart (every section): A summary of the shopper's cart.
| Field | Type | Meaning |
|---|---|---|
| itemCount | number | Units in the cart. |
| subtotal | money | The cart subtotal in minor units. |
| currency | text | The cart currency. |
Example: {{ cart.itemCount }} items renders 2 items.
request (every section): The address being shown.
| Field | Type | Meaning |
|---|---|---|
| path | text | The path of the page, such as /collections/mugs. |
Example: {{ request.path }} renders /.
routes (every section): Addresses of your store's own pages, so links keep working on any domain.
| Field | Type | Meaning |
|---|---|---|
| home | url | The home page. |
| catalog | url | Every product. |
| cart | url | The cart. |
| search | url | Search results; a form sends q. |
| account | url | The shopper's account: sign-in and orders. |
Example: <a href="{{ routes.catalog }}">Shop</a> renders <a href="/collections/all">Shop</a>.
navigation (every section): The store's menus: the merchant's links, or the theme's when none are set.
| Field | Type | Meaning |
|---|---|---|
| header | list<menuItem> | The header menu. |
| footer | list<menuItem> | The footer menu. |
| tracking | trackingLink? | Track your order, for a footer to print where it likes; a template that reads it gets footer without it. Empty when footer links there. |
Example: {% for link in navigation.header %}<a href="{{ link.href }}">{{ link.label }}</a>{% endfor %} renders <a href="/collections/all">Shop</a><a href="/pages/about">About</a>.
blog (every section): The store's latest published posts; empty without posts.
| Field | Type | Meaning |
|---|---|---|
| url | url | The blog page. |
| posts | list<post> | Up to 12, newest first. |
Example: {% for post in blog.posts limit: 3 %}<a href="{{ post.url }}">{{ post.title }}</a>{% endfor %} renders <a href="/blog/first-firing">The first firing</a>.
now (every section): Today, in UTC.
| Field | Type | Meaning |
|---|---|---|
| year | number | The year, such as 2026. |
| date | date | Today, such as 2026-09-27. |
Example: © {{ now.year }} {{ store.name }} renders © 2026 Hallo Ceramics.
builder (every section): Whether the page is drawn in the builder.
| Field | Type | Meaning |
|---|---|---|
| editing | boolean | True only in the builder: draw a placeholder for an empty state. False for shoppers and preview links. |
Example: {% if builder.editing %}Pick products{% else %}Shop{% endif %} renders Shop.
loop (inside {% for %}): Where the innermost for loop is.
| Field | Type | Meaning |
|---|---|---|
| index | number | The item's position, from 1. |
| index0 | number | The item's position, from 0. |
| first | boolean | True for the first item. |
| last | boolean | True for the last item. |
| length | number | How many items the loop shows. |
| itemId | text | Over a list setting, the item's id (tiles-2) for data-fh-item; styles read its fields as var(--fh-i-<field>). |
Example: {% for product in settings.shelf.products %}{{ loop.index }}.{{ product.title }} {% endfor %} renders 1.Blue mug 2.Side plate .
image (image settings, store.logo, product.image, collection.image): An image from your store's media library.
| Field | Type | Meaning |
|---|---|---|
| src | url | The image's address on your store. |
| alt | text | Its description for screen readers; empty when none was given. |
| width | number? | Its width in pixels, when known. |
| height | number? | Its height in pixels, when known. |
Example: <img src="{{ settings.image \| image_url: 800 }}" alt="{{ settings.image.alt }}"> renders <img src="/media/products/mug.jpg?width=800" alt="A blue mug">.
product (product settings, page.product, collection.products): One of your products.
| Field | Type | Meaning |
|---|---|---|
| title | text | The product's name. |
| handle | text | Its handle, the last part of its address. |
| url | url | Its page on your store. |
| price | money | Its price in minor units; print it with | money. |
| compareAtPrice | money? | The earlier price shown struck through, when set. |
| priceFrom | money? | The lowest option price, when the product has options. |
| hasVariants | boolean | True when shoppers pick options such as size. |
| available | boolean | True when it can be bought now. |
| onSale | boolean | True when compareAtPrice is higher than price. |
| isNew | boolean | True while the store's New badge applies to it. |
| image | image? | Its main image. |
| images | list<image> | Every image, main image first. |
| description | text | Its description as plain text. |
| tags | list<text> | Its tags. |
| createdAt | date | When it was added; print it with | date. |
| discountPercent | number | The sale as a whole percentage, as the theme's badge shows it; 0 when not on sale. |
| inventory | number? | Units in stock; empty when unknown. |
| lowStock | boolean | True when in stock with 10 or fewer left. |
Example: {{ settings.featured.title }}: {{ settings.featured.price \| money }} renders Blue mug: £24.00.
collection (collection settings, page.collection): One of your collections. A collection setting set to "all" is every product, newest first, and its url is routes.catalog.
| Field | Type | Meaning |
|---|---|---|
| title | text | The collection's name. |
| handle | text | Its handle. |
| url | url | Its page on your store. |
| description | text | Its description as plain text. |
| image | image? | Its image, or its first product's. |
| productCount | number | How many products it holds. |
| products | list<product> | Its products in the collection's order, at most 50. |
Example: {{ settings.shelf.title }} ({{ settings.shelf.productCount }}) renders Mugs (2).
menuItem (navigation.header, navigation.footer): One link of a menu.
| Field | Type | Meaning |
|---|---|---|
| label | text | The link's text. |
| href | url | Where it goes. |
| current | boolean | True when it points at the page being shown. |
Example: {% for link in navigation.header %}{% if link.current %}<span aria-current="page">{{ link.label }}</span>{% endif %}{% endfor %} renders <span aria-current="page">Shop</span>.
trackingLink (navigation.tracking): The order tracking link; print it as a marked element.
| Field | Type | Meaning |
|---|---|---|
| label | text | Its text. |
| url | url | The tracking page. |
| current | boolean | On that page. |
Example: {% if navigation.tracking %}<a data-fh-el="tracking" href="{{ navigation.tracking.url }}">{{ navigation.tracking.label }}</a>{% endif %} renders <a data-fh-el="tracking" href="/orders/track">Track your order</a>.
post (blog.posts): A published post.
| Field | Type | Meaning |
|---|---|---|
| title | text | Its title. |
| url | url | Its page. |
| image | image? | Its cover. |
| excerpt | text | Its summary. |
| date | date | Published on. |
Example: {% for post in blog.posts %}<p>{{ post.date \| date: "long" }}: {{ post.excerpt }}</p>{% endfor %} renders <p>2 September 2026: How the new glaze came out.</p>.
contact (store.contact): The store's contact details from its store profile; each part is empty until the merchant gives it.
| Field | Type | Meaning |
|---|---|---|
| legalName | text | The business's legal name. |
| text | The support email address. | |
| phone | text | The phone number. |
| address | address | The postal address. |
Example: {% if store.contact.email %}<a href="mailto:{{ store.contact.email }}">{{ store.contact.email }}</a>{% endif %} renders <a href="mailto:hello@hallo.example">hello@hallo.example</a>.
address (store.contact.address): A postal address; all of it is empty until a first line and a country are given.
| Field | Type | Meaning |
|---|---|---|
| line1 | text | The first line. |
| line2 | text | The second line. |
| city | text | The town or city. |
| region | text | The county, state or region. |
| postalCode | text | The postcode. |
| country | text | The country, as the store profile holds it. |
Example: {{ store.contact.address.city }} {{ store.contact.address.postalCode }} renders Leeds LS1 1AA.
socialProfile (store.socialProfiles): One of the store's social profiles.
| Field | Type | Meaning |
|---|---|---|
| platform | text | instagram, tiktok, x and so on: also its icon's name. |
| label | text | Its name, such as Instagram. |
| url | url | The profile's https address. |
Example: {% for profile in store.socialProfiles %}<a href="{{ profile.url }}">{{ profile.label }}</a>{% endfor %} renders <a href="https://instagram.com/hallo">Instagram</a>.
Filters
| Filter | Takes | Arguments | What it does | Example | Renders |
|---|---|---|---|---|---|
| upper | text | none | Capital letters. | {{ settings.heading | upper }} | WINTER SALE |
| lower | text | none | Small letters. | {{ settings.heading | lower }} | winter sale |
| truncate | text | length (optional) | Shortens text to a length, ending with … when it was cut. | {{ store.headline | truncate: 10 }} | Stoneware… |
| money | money | format (optional) | A price in minor units in the store currency, written exactly as the theme writes prices ($42.00, £24.00). | {{ cart.subtotal | money }} | £48.00 |
| date | date | format (optional) | A date written out. | {{ settings.featured.createdAt | date: "long" }} | 20 September 2026 |
| image_url | image, product or collection | width | The address of an image (a product's or collection's main image) for a given width. Empty when there is no image. | {{ settings.featured | image_url: 400 }} | /media/products/mug.jpg?width=400 |
| url | product, collection or text | none | The page address of a product or collection; a link text passes through when it is a safe link. | {{ settings.featured | url }} | /products/blue-mug |
| default | anything | fallback | A fallback for an empty value. | {{ store.logo.alt | default: store.name }} | Hallo Ceramics |
| size | list or text | none | How many items a list holds, or characters a text holds. | {{ settings.shelf.products | size }} | 2 |
| join | list of text | separator (optional) | One text from a list. | {{ settings.featured.tags | join: ", " }} | mugs, blue |
| first | list | none | The first item. Without a loop, a path takes one: list.first, list.last, list.0. | {{ settings.featured.tags | first }} | mugs |
| last | list | none | The last item of a list. | {{ settings.featured.tags | last }} | blue |
| reverse | list | none | The list backwards. | {{ settings.featured.tags | reverse | join: ", " }} | blue, mugs |
| fill | text | value | Puts a value where the text says {n}. | {{ "Only {n} left" | fill: settings.discount }} | Only 20 left |
| initials | text | none | A name's initials (first two words), in capitals. | {{ store.name | initials }} | HC |
| plus | number | amount | Adds a number. | {{ settings.discount | plus: 5 }} | 25 |
| minus | number | amount | Takes a number away. | {{ settings.discount | minus: 5 }} | 15 |
| times | number | factor | Multiplies. | {{ settings.discount | times: 2 }} | 40 |
| divided_by | number | divisor | Divides, keeping decimals; add | round for a whole number. | {{ settings.featured.price | divided_by: 100 }} | 24 |
| round | number | places (optional) | Rounds to the nearest. | {{ settings.discount | divided_by: 3 | round: 1 }} | 6.7 |
| floor | number | none | Rounds down to a whole number. | {{ settings.discount | divided_by: 3 | floor }} | 6 |
| ceil | number | none | Rounds up to a whole number. | {{ settings.discount | divided_by: 3 | ceil }} | 7 |
| abs | number | none | The number without its sign. | {{ settings.discount | minus: 25 | abs }} | 5 |
| raw | text | none | Prints the value as HTML instead of escaping it. It still follows the same rules as a custom HTML block, so it can format text but never run code. | {{ settings.note | raw }} | <strong>Free</strong> delivery |
Widgets
| Widget | What it is | Arguments |
|---|---|---|
| newsletter | The store's newsletter sign-up: an email field and a button. Addresses are confirmed by email before anything is sent. | label What screen readers call the form. (Newsletter); placeholder The hint inside the email field. (Email address); button The button's text. (Subscribe); note The consent line under the form. (Marketing email only. Unsubscribe any time.); fieldLabel A visible label for the email field (fh-widget-field-label, after the field); none when empty.; noteLinkLabel A link in the note, such as your privacy page: its text; it goes where the note says {link}, or after the note.; noteLinkHref Where that link goes: a page of your store (/pages/privacy).; noteLink2Label A second link in the note, at {link2}, or after the first.; noteLink2Href Where the second link goes: a page of your store. |
| search | A search box that opens the store's search results; it shows what the shopper searched for. | label What screen readers call the search. (Search); placeholder The hint inside the field. (Search products); button The button's text. (Search) |
| cart-button | A link to the cart with the number of items in it (shown when there are any). | label The link's text. (Cart); icon An icon name to show before the text; none when empty. |
| account | A link to the shopper's account: sign-in, orders and addresses. | label The link's text. (Account); icon An icon name to show before the text; none when empty. |
| quick-add | One click adds a named product (or one of its options) to the cart, with the store's own add form: a product setting or a handle, never a price. The shopper stays on the page and the cart count updates; on the cart page the page reloads with the new totals; without a script the form still posts. A product with options needs variant:, else it links to its page. | product The product: a product setting (settings.gift), the page's product, or a handle in quotes.; variant The option to add, by id, for a product with options (such as product.selectedVariant.id).; quantity How many, 1 to 10. (1); label The button's text. (Add to cart); addedLabel What the button's status says once it is added. (Added); chooseLabel The link's text for a product with options and no variant. (Choose options); soldOutLabel What it says when nothing is left. (Sold out); el An element key: the merchant can select, move and hide it in the builder. |
Recipes
search: The search widget with its own hint; on the search results page it shows what was searched for.
Example: {% widget "search" placeholder: "Find a mug" %} renders <form class="fh-widget fh-widget-search" data-fh-widget="search" method="get" action="/search" role="search" aria-label="Search"><input class="fh-widget-input" type="search" name="q" maxlength="80" autocomplete="off" placeholder="Find a mug" aria-label="Find a mug"><button class="fh-widget-button" type="submit">Search</button></form>.
social-icons: Each social profile as its platform's icon, announced by its name.
Example: {% for profile in store.socialProfiles %}<a href="{{ profile.url }}">{% icon profile.platform size: 20 label: profile.label %}</a>{% endfor %} renders <a href="https://instagram.com/hallo"><svg class="fh-icon fh-icon-instagram" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Instagram" focusable="false"><path d="M4 8a4 4 0 0 1 4 -4h8a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-8a4 4 0 0 1 -4 -4l0 -8"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/><path d="M16.5 7.5v.01"/></svg></a>.
item-colours: A list whose items each have their own colour: give the list a colour field, put data-fh-item on each item's element, and read the field in styles as var(--fh-i-<field>). No style attribute needed.
Example: {% for tile in settings.tiles %}<li data-fh-item="{{ loop.itemId }}">{{ tile.title }}</li>{% endfor %} renders <li data-fh-item="tiles-1">Mugs</li><li data-fh-item="tiles-2">Plates</li>.
fonts: A font setting names a family from the font list; the section loads only the weights the setting declares and exposes the family as var(--fh-s-<setting>), so styles write font-family:var(--fh-s-heading-font,inherit). Printing it gives the family's name.
Example: <p>Set in {{ settings.headingFont }}</p> renders <p>Set in Fraunces</p>.
header: A section with slots: ["header"] (or "footer") placed in that slot replaces the theme's own header (or footer) on every page but the payment page. Build it from store.logo, navigation, the cart-button, search and account widgets, and details and summary for a menu that opens on small screens. schema.sticky: true keeps a header at the top of the window while the page scrolls.
Example: <a href="{{ routes.home }}">{{ store.name }}</a>{% widget "cart-button" %} renders <a href="/">Hallo Ceramics</a><a class="fh-widget fh-widget-cart-button" data-fh-widget="cart-button" href="/cart" aria-label="Cart (2)"><span class="fh-widget-label">Cart</span><span class="fh-widget-count" aria-hidden="true">2</span></a>.
main-heading: A section may hold one h1, outside loops, for the page's main heading (a hero's). The page keeps the first h1 it meets and shows any later one as an h2 with the same attributes; on catalog, product and content pages the theme's own title is the h1, so a section's h1 shows as an h2 there.
Example: <h1 data-fh-el="heading">{{ settings.heading }}</h1> renders <h1 data-fh-el="heading">Winter sale</h1>.
one-item: One item of a list without a loop, such as the first product of a collection as the page's h1: .first, .last or a position from 0 (up to 99) as a step of the path. An item that is not there prints nothing.
Example: <h1>{{ settings.shelf.products.first.title }}</h1><p>{{ settings.shelf.products.1.title }}</p> renders <h1>Blue mug</h1><p>Side plate</p>.
all-products: A collection setting set to "all" (a default, a preset or the merchant's choice) is the whole catalogue, newest first, capped like any collection; loop over its products with limit: and offset:.
Example: <a href="{{ settings.everything \| url }}">{{ settings.everything.title }}</a> renders <a href="/collections/all">All products</a>.
sale-badge: The theme's sale pill, from the product's own numbers.
Example: {% if settings.featured.onSale %}<span class="pill">-{{ settings.featured.discountPercent }}%</span>{% endif %} renders <span class="pill">-20%</span>.
field-label: fieldLabel gives the newsletter's email field a visible label (fh-widget-field-label, after the field inside fh-widget-field). Without a placeholder of its own the field's placeholder is then a space, so styles can float the label: .fh-widget-input:not(:placeholder-shown)+.fh-widget-field-label.
Example: {% widget "newsletter" fieldLabel: "Email" %} renders <form class="fh-widget fh-widget-newsletter" data-fh-widget="newsletter" method="post" action="/api/newsletter" aria-label="Newsletter"><span class="fh-widget-field"><input class="fh-widget-input" id="fh-promo-w1" type="email" name="email" required maxlength="254" autocomplete="email" placeholder=" "><label class="fh-widget-field-label" for="fh-promo-w1">Email</label></span><input class="fh-widget-trap" name="company" tabindex="-1" autocomplete="off" aria-hidden="true"><button class="fh-widget-button" type="submit">Subscribe</button><p class="fh-widget-note">Marketing email only. Unsubscribe any time.</p></form>.
Not available
- Other files: no include, render, layout or import.
- Code: no scripts, event handlers or javascript: links. Use details and summary, or a section script.
- Forms, inputs and svg: use {% widget %} and {% icon %}. Section templates may write buttons for interaction controls: type=button, class, aria and inert data markers survive; submit behaviour and form attributes are refused.
- Inline styles: removed. Use the section's styles and CSS variables.
- Network: a template fetches nothing; it sees only the objects here.
- Variables: no assign or capture. Use filters in place, and list.first for one item.
- Other stores' data, the shopper's personal details, orders and customer records.
Headings
A section may hold one <h1>, outside any {% for %}: the page's main heading, such as a hero's. To print one item of a list outside a loop (the first product of a collection as the h1), take it with a step of the path: .first, .last, or a position from 0 up to 99. <h1>{{ settings.shelf.products.first.title }}</h1> prints the first product's name; an item that is not there prints nothing. A page keeps only one: the first h1 it meets, in page order, and every later section h1 is written as an h2 with the same attributes, so two hero sections never make two main headings. On catalog, product, cart and content pages, and on the home page while the theme's own hero is on it, the theme already draws the page's h1, so a section's h1 there is written as an h2. A page body section (§6a) is the exception: the theme no longer draws that page's heading, so the body's own h1 stays the page's h1. Mark it (data-fh-el) and style it exactly as you would an h2. A custom HTML block still starts at h2.
Every product, today and the store's details
- A
collectionsetting can be"all": the store's whole catalogue, newest first, as the catalogue page shows it, capped like any collection. Use it as a default, in a preset or from the builder, where the collection picker offers All products first.{% for product in settings.shelf.products limit: 8 offset: 8 %}pages through it, and{{ settings.shelf | url }}is the catalogue page (routes.catalog). now.yearandnow.dateare today, in UTC:© {{ now.year }} {{ store.name }}. A page that shows them is never served from yesterday's copy.store.contactis how to reach the store, exactly what the theme's footer shows:legalName,email,phoneandaddress(line1,line2,city,region,postalCode,country), each empty until the merchant fills in the store profile. The address is empty until it has a first line and a country.- A product knows its sale as
product.discountPercent(the theme's-20%, 0 when not on sale), its stock asproduct.inventory(empty when unknown) andproduct.lowStock(10 or fewer left). | moneywrites a price exactly as the theme does ($42.00,£24.00), so a section's prices and the theme's agree. Every currency is read in the unit checkout charges in: whole units for yen and the like, thousandths for BHD, JOD, KWD, OMR and TND, hundredths for the rest.plus,minus,times,divided_by,round,floor,ceilandabsdo arithmetic:{{ product.price | divided_by: 100 | round }}. Dividing by zero gives 0, and a result is always a number.fillputs a value into a text where it says{n}:{{ settings.stockLine | fill: product.inventory }}with "Only {n} left" prints "Only 3 left", escaped like any value.{% for post in blog.posts | reverse %}walks a list backwards.| reverseis the one filter a loop takes, written straight after the list;offset:andlimit:then apply to the turned list, so{% for item in settings.steps | reverse limit: 3 %}shows the last three items, last first. (This differs from some other template languages, which cut first and turn after.) Each item keeps its ownloop.itemId.builder.editingis true only when the page is drawn in the builder (its edit mode, or the dashboard's own preview), so a section can show the merchant a placeholder where a shopper would see nothing:{% if settings.shelf.products %}…{% elsif builder.editing %}<p class="hint">Pick a collection</p>{% endif %}. It is false on every shopper's page, every preview link and every cached page.- The
hiddenattribute is kept on any element of a template or a custom HTML block, written bare (<p hidden>, whatever value it came with): the element is not shown until a script or a style shows it. initialsis a name's monogram, the first letters of its first two words in capitals:{{ store.name | initials }}is "OB" for Oak & Bloom.reverseturns a list round:{{ settings.featured.tags | reverse | join: ", " }}.blog.postsis the store's latest published posts, newest first, at most 12, on any page: each hastitle,url,image(its cover, empty without one),excerptanddate(print it with| date).blog.urlis the blog's page. With no published posts the list is empty, so wrap a journal band in{% if blog.posts %}. The posts are read only for a page whose sections readblog.- A menu link's
currentis true on the page it points at. A link with a query string (/collections/all?sort=newest) is current only when the page's query matches too, in any order; a link without one is current on its path whatever the query.
Accessibility and loading
A template may write aria-label, aria-labelledby, aria-describedby, aria-controls, aria-hidden, aria-expanded, aria-current, role (from a fixed list of landmarks and plain roles) and tabindex (0 or -1) on any element, and loading (eager or lazy), fetchpriority and decoding on an image; each value is checked, exactly as for a custom HTML block. An image without loading gets loading="lazy"; write loading="eager" fetchpriority="high" on the picture at the top of the page. A link to a page of your own store (a path or #anchor) carries no nofollow.
Store controls and icons
A template cannot write a form, an input or an svg: use the widgets and icons below. It may write a script control button: <button type="button" class="next" aria-label="Next slide">Next</button>. Only class and supported ARIA attributes survive, and its type is always button; it cannot submit a form. Merchant custom HTML blocks still refuse buttons.
{% widget "newsletter" %},{% widget "search" %},{% widget "cart-button" %}and{% widget "account" %}place your store's own sign-up form, search box, cart link (with the number of items in it) and account link, working exactly as the theme's do. They take text arguments (label:,placeholder:,button:,note:,icon:, each quoted text or a setting) and carry classes such asfh-widget-inputandfh-widget-button, so the section's styles decide how they look. The newsletter's consent line can link to your own pages:noteLinkLabel:andnoteLinkHref:(a path on your store, such as/pages/privacy), and a second pairnoteLink2Label:andnoteLink2Href:. Each link goes where the note says{link}({link2}), or after the note:note: "By subscribing you agree to our {link} and {link2}." noteLinkLabel: "Privacy policy" noteLinkHref: "/pages/privacy" noteLink2Label: "Terms" noteLink2Href: "/pages/terms". A link to anything but a path on your store is left out. The newsletter also takesfieldLabel:, a visible label for the email field (<label class="fh-widget-field-label">, right after the field insidefh-widget-field); without aplaceholder:of its own the field's placeholder is then a space, so styles can float the label with.fh-widget-input:not(:placeholder-shown)+.fh-widget-field-label. At most four per template, never inside a loop.{% widget "quick-add" product: settings.gift label: "Add to cart" el: "gift" %}puts one named product in the cart with one click:product:is a product setting, the page's product, or a handle in quotes (product: "linen-tote");variant:names one option of a product with options (variant: product.selectedVariant.id; without one the widget links to the product's page, sayingchooseLabel:), andquantity:is 1 to 10. It is your store's own add form, carrying the handle, the option and the quantity and never a price: the store prices it from the catalogue, exactly as the add-to-cart button does. With the store's script the shopper stays on the page, the cart count updates and the button's status saysaddedLabel:("Added"); on the cart page the page reloads with the new totals; without a script the form posts as usual. It works in any section, on any page but the payment page, where it draws nothing; in the builder it is an inert group. Classes:fh-widget-button,fh-widget-label,fh-widget-status,fh-widget-sold-out.{% widget "buy-now" product: settings.product quantity: 1 discount: "FLASH20" label: "Buy now" el: "buy" %}takes the shopper straight to checkout with one named product, past the cart: a flash sale on the home page, or any page.product:is a product setting, the page's product, or a handle in quotes;variant:names one option of a product with options (without one the button becomes a link to the product's page, with the same classes, sayingchooseLabel:or the label);quantity:is 1 to 10 and never more than the store has;discount:is a discount code the checkout applies when it is valid for this order, and ignores (full price) when it is not. It is the same form the product page's Buy now posts, carrying the handle, the option, the quantity and the code, never a price or an amount: the checkout refuses a post from another site, prices the product from the catalogue and checks the stock and the code again. It draws nothing when the product cannot be bought: sold out, not published, a handle that names nothing, or a store not taking payments yet; nothing on the payment page, and an inert group in the builder. Withoutproduct:it is the product page's own button, joined to the page's product form, as in "Page bodies". Its root is the product page's (fh-widget fh-widget-buy-now,data-fh-widget="buy-now"), its buttonfh-widget-button fh-widget-buy; withel:the merchant selects, moves, resizes and layers it like any element. Not inside a loop.{% icon "star" size: 20 %}draws one of more than a hundred icons (arrows, carts, bags, delivery, social networks and more;describe_sections { parts: ["icons"] }lists them) in the colour of the text around it. Give itlabel:when it means something on its own, and screen readers read the label; without one they skip it.- Artwork. A section can carry its own drawings (a design kit's icons, ornaments and small illustrations) in the package:
"artwork": { "leaf": "<svg viewBox=\"0 0 32 32\">…</svg>" }, names of lower-case letters, digits and hyphens, at most 32 drawings, each at most 32 KB and 160 KB together.{% icon "art:leaf" %}draws it inline;size:is its width in pixels (8 to 1600, the height follows the viewBox), without it the drawing keeps its own size, andlabel:works as for icons. Colours are drawn as written: writecurrentColorwhere the drawing should take the text colour. Each drawing is checked when the section is saved and again when it is drawn: no script, style element, text, image, link, animation, event attribute or reference outside the drawing (onlyurl(#id)andhref="#id"), and a drawing that does not pass is refused with the reason (artwork_invalid,artwork_limit). The page gets the drawing rewritten: its own root attributes replaced (classfh-art fh-art-<name>, size, viewBox, its fill and stroke kept) and every id inside it given a prefix, so two drawings never share a gradient or clip path. It is decoration, never an element the merchant edits; style it with.fh-art(for examplemax-width:100%;height:auto). A setting's value always names a built-in icon, never artwork. A store control that takesicon:takes a drawing too:{% widget "cart-button" icon: "art:bag" %}draws it at its own size in the icon's place.
A header section is usually store.logo or store.name, a loop over navigation.header (each link knows whether it is the page being shown), the search, cart and account widgets, and details and summary for a menu that opens on a phone. A footer section loops over navigation.footer and store.socialProfiles, whose platform is also the name of its icon.
The order tracking link. Shoppers who lost their shipping email find their order on the store's tracking page, so every footer offers "Track your order". A footer prints it where its design has a place, as navigation.tracking (label, url, current), marked so the merchant can move or hide it:
{% for link in navigation.footer %}<a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}
{% if navigation.tracking %}<a data-fh-el="tracking" href="{{ navigation.tracking.url }}">{{ navigation.tracking.label }}</a>{% endif %}
with { key: "tracking", label: "Track your order link", type: "link" } in schema.elements. A template that reads navigation.tracking anywhere gets navigation.footer as the merchant's links alone (the theme's defaults when there are none). A template that never reads it gets the footer menu followed by the tracking link, exactly as every footer did before 2026-09-27, so no published footer changed. navigation.tracking is empty when the footer menu already links to the tracking page. The theme's own footers always show it.
Your home page's module slot. A section for the home page may place {% slot "home.below" %}, where the store's home modules show (the collection grid, add-on blocks): they then show there instead of below the page's sections, once. A page body places its own page's slots the same way (§6a).
Ids and anchors. An id a template writes is shown with fhc- in front, so no section can take an id the page already uses: id="{{ section.id }}" is id="fhc-promo" on the page. A link inside a section to #promo is written #fhc-promo the same way, so it keeps working; a menu link or another page writes the prefix itself: /#fhc-promo.
5. Editable elements
A merchant edits a section in the builder the way they would in a design tool: they select one element inside it (a heading, a paragraph, a button, a picture) and type into its text, or align, resize, hide, reorder or nudge it, separately for phones if they like. They can only select what the template marks, so marking is part of writing a section, not an extra.
In the builder a merchant selects one element inside a section and types into its text, or aligns, resizes, hides, reorders or nudges it, on desktop and on phones. They can only select what the template marks. Define the marks correctly and the merchant can change your section's words and positions without you; leave them out and the section can only be moved as a whole.
- Put data-fh-el="<setting key>" on the element that prints each text, textarea, rich text, image, link or url setting: the h2 that prints settings.heading, the img that shows settings.image, the a that uses settings.ctaLink. The mark's own attributes count as inside it, so alt="{{ settings.imageAlt }}" on a marked img and href on a marked a need nothing more.
- A button or link whose label is a setting of another name, or a card that moves as one, is declared in schema.elements { key, label, type: text | richtext | image | button | link | group, setting?, style?, group? } and marked with data-fh-el="<key>".
- Put data-fh-group="<key>" on a container whose marked direct children the merchant may reorder, and make that container display:flex or display:grid in the styles; order then moves them. A group that is neither gets a note, because reordering would move nothing.
- A mark is a fixed key, never an expression, and names a setting or a declared element; anything else is refused with its template line (element_unknown, element_invalid). At most 40 elements per section.
- save_section refuses a new package that prints a text, image or link setting only outside marked elements: elements_unmarked when nothing is marked, and element_unmarked for each setting, with the line of its first print. Sections already saved keep working, and rollback_section, fork_section and copies between environments are not held to it.
- The one way out, written on purpose: list the setting in schema.fieldOnly (for example fieldOnly: ["imageAlt"] when the text is only ever an attribute of an unmarked element). The merchant still edits it in the section's fields; it just cannot be selected on the page.
- List items ({% for item in settings.questions %}) print item fields, not settings: they are edited in the section's fields and are outside the rule. Settings a tag only tests ({% if settings.image %}) or hands to a widget are not prints.
- Styles for a marked element should not depend on its position (no :first-child on marked children, no absolute placement of a group's members): the merchant's order and nudges must still read well. Test at phone width (390px).
What save_section and preview_section answer about it:
| Field | Means |
|---|---|
editable | Every element the builder can select: { key, type, label, setting?, group?, style }. style lists the knobs offered (align, size, visible, spacing); order, offset, width, height and layer are offered on every element. |
builder | One sentence to relay to the merchant: what they can select and change in this section. |
notes | Advice, never a refusal: a declared element no template line marks, a group that is not flex or grid, a setting printed outside its mark as well. |
A complete package that passes, a hero whose four elements sit in one group the merchant can reorder:
{
"schemaVersion": 1,
"id": "hero-stack",
"name": "Hero",
"description": "A heading, a line of text, a button and a picture, stacked.",
"category": "hero",
"version": "1.0.0",
"origin": "store",
"schema": {
"settings": [
{
"key": "heading",
"type": "text",
"label": "Heading",
"default": "Made by hand, made to last",
"maxLength": 120,
"inlineEditable": true
},
{
"key": "body",
"type": "textarea",
"label": "Text",
"default": "Small batches from our workshop.",
"maxLength": 400,
"inlineEditable": true
},
{
"key": "ctaLabel",
"type": "text",
"label": "Button label",
"default": "Shop now",
"maxLength": 40
},
{
"key": "ctaLink",
"type": "link",
"label": "Button link"
},
{
"key": "image",
"type": "image",
"label": "Picture"
},
{
"key": "imageAlt",
"type": "text",
"label": "Picture description",
"maxLength": 160
},
{
"key": "background",
"type": "color",
"label": "Background"
}
],
"elements": [
{
"key": "cta",
"label": "Button",
"type": "button",
"setting": "ctaLabel"
}
],
"maxInstancesPerPage": 2,
"pages": [
"home",
"page"
]
},
"template": "<div class=\"hero\" data-fh-group=\"stack\">\n <h2 data-fh-el=\"heading\">{{ settings.heading }}</h2>\n <p data-fh-el=\"body\">{{ settings.body }}</p>\n {% if settings.ctaLabel %}<a class=\"hero__button\" data-fh-el=\"cta\" href=\"{{ settings.ctaLink | default: routes.catalog }}\">{{ settings.ctaLabel }}</a>{% endif %}\n {% if settings.image %}<img class=\"hero__image\" data-fh-el=\"image\" src=\"{{ settings.image | image_url: 1200 }}\" alt=\"{{ settings.imageAlt }}\">{% endif %}\n</div>\n",
"styles": ":scope{background:var(--fh-s-background,var(--fh-background,transparent));color:var(--fh-foreground,inherit)}\n.hero{display:flex;flex-direction:column;align-items:center;gap:1rem;max-width:760px;margin-inline:auto;padding:clamp(2.5rem,8vw,5rem) 1.25rem;text-align:center}\n.hero>*{margin:0}\n.hero h2{font-family:var(--fh-font-heading,inherit);font-size:clamp(2rem,6vw,3.5rem);line-height:1.1}\n.hero__button{display:inline-block;padding:.8rem 1.6rem;border-radius:999px;background:var(--fh-accent,currentColor);color:var(--fh-on-accent,#fff);text-decoration:none}\n.hero__image{width:100%;height:auto;border-radius:12px}\n"
}
Changing one element of a placed instance:
- Text, images and links live in the instance's settings: { type: "update-section", instanceId, settings: { heading: "New words" } }.
- { type: "set-element-style", instanceId, key, align?: "left" | "center" | "right", size?: "s" | "m" | "l" | "xl", visible?, spacing?: 0-4, order?: -20 to 20, offsetX?: -400 to 400 px or { value, unit: "cqw" } -100 to 100 (a share of the section width, two decimals), offsetY?: -400 to 400 px, width?, height?: { value, unit: "px" } 8 to 2000, { value, unit: "%" } 1 to 100 of the parent or { value, unit: "cqw" } 0.5 to 100 of the section width, layer?: -20 to 20 (in front of or behind the other elements of the section), mobile?: { order?, offsetX?, offsetY?, align?, visible?, width?, height?, layer? }, tablet?: the same } changes one element of a placed instance; null takes a knob off. Geometry (order, offsets, width, height, layer) is per device: the top level applies from 1024px, tablet from 641px to 1023px, mobile at 640px and below, and a device without its own shows the section layout; align and visible a device leaves out come from desktop. Shares of the section width scale with it. A moved element may cover its neighbours; it never rises above another section or the buy and consent controls. Width never exceeds the parent; text, buttons and groups use height as a minimum; an image keeps its proportions unless both are set (then it is cropped to fill).
- { type: "set-element-order", instanceId, group, order: ["cta", "heading", "body"], mobile? } numbers a group's elements in one step.
- The theme's own sections are edited through their areas: set-element and set-element-style with areaId.
[
{
"type": "update-section",
"instanceId": "hero-stack",
"settings": {
"heading": "New season"
}
},
{
"type": "set-element-style",
"instanceId": "hero-stack",
"key": "heading",
"size": "xl",
"align": "left",
"mobile": {
"align": "center"
}
},
{
"type": "set-element-order",
"instanceId": "hero-stack",
"group": "stack",
"order": [
"image",
"heading",
"body",
"cta"
]
},
{
"type": "set-element-style",
"instanceId": "hero-stack",
"key": "cta",
"offsetY": 12
}
]
The builder changes only data: the instance's settings (the words, pictures and links) and its element overrides (align, size, visibility, spacing, order, position). The store turns the overrides into CSS confined to the instance; your template and styles are never rewritten. An instance with no overrides renders exactly as the template says.
A custom HTML block has no elements: the builder can move, hide or remove it, but nobody can select or edit anything inside it. Build a section whenever the merchant should be able to change the words or the layout later.
6. Styles
A section's styles are CSS under the same rules as a block's CSS in custom code: the same at-rules are kept, the same things are removed. Every rule is confined to the section, and :scope or & means the section itself:
:scope{background:var(--fh-s-background);color:var(--fh-s-foreground)}
.promo a{color:inherit;text-decoration:underline}
:scope and & at the start of a selector stand for the section itself, and what follows keeps its meaning: :scope > .row (its own rows), :scope * and :scope a (anything, or any link, inside it), &.is-dark and :scope:hover (the section in that state). A list inside :is(), :where(), :not() or :has() keeps its own commas. Two things stay out of section styles: @container queries (use @media), and arrows that move a carousel. Use CSS scroll snap for a row, or a sandboxed section behaviour for controls and accessible interaction; legacy section scripts remain a separate, more powerful option.
Fixed layers. A section whose schema says fixed: true may use position: fixed in its styles: a full-screen menu on a phone, a purchase bar along the bottom. The store keeps shoppers safe around it:
- Every
z-indexin that section's styles is at most 100 (a higher one is written as 100, and one that is not a whole number or a keyword, such asvar(), is left out), so a section's layer sits above a sticky header section (50) and below the store's own layers: the skip link, a script's overlay, the builder's outlines. Apositionother than a plain keyword is left out too. - On the cart page
position: fixedis left out of every section's styles, header and footer included, so nothing can cover the checkout button or the totals. Write the fallback first and it applies there:position:absolute;position:fixed. - In the builder a fixed layer is written as
position: absolute: it scrolls with the page, and the builder's outlines and selection still reach everything under it.
Custom HTML blocks, the store stylesheet and every section without fixed: true never use position: fixed. Everywhere, with or without fixed: true, a position has to be a plain word (static, relative, absolute, sticky, and fixed where allowed): a value built with var(), env(), attr() or any other function, or written with a backslash escape, is left out, since it could still come out as fixed (--p: fixed; position: var(--p) keeps only --p). A property name written with a backslash escape is left out too, and nested rules follow the same rules (custom code §2).
Styles never contain {{ }}. Instead, every colour, font, number, range, select, on/off and image setting arrives as a CSS variable on the section: --fh-s- followed by the setting's key in kebab case. minHeight becomes var(--fh-s-min-height), with its unit when the setting has one; an on/off setting is 1 or 0; an empty colour is left out, so give a fallback (var(--fh-s-accent,inherit)) and the theme's own colour shows. The theme's colour and font tokens (--fh-accent, --fh-font-heading and the rest) work inside a section as they do anywhere else. For a button on the brand colour use background:var(--fh-accent);color:var(--fh-on-accent): the store computes a readable text colour for whatever accent the owner picks. Never paint a button background:currentColor without a colour of its own.
A colour can be see-through: #rrggbbaa, where the last pair is the opacity from 00 (clear) to ff (solid), such as #00000080 for a half-dark overlay on a photo.
Fonts. A font setting names a family from the font list (describe_sections { parts: ["fonts"] }: about three hundred of the most used open font families; some 1,400 more open families are accepted by their exact name without being listed, so a design's own face can be used) and declares the weights your styles use, at most four (weights: [400, 700]), and italic: true to load the family's italics in those weights too when it has them. The section loads that family in those weights only, and the variable holds the family with a fallback, so h2{font-family:var(--fh-s-heading-font,inherit)} uses it and falls back to the theme's font while the setting is empty. The store's own font pairing is unchanged; a section's font applies inside that section only. The store's own typefaces (the custom pairing) load 400 to 700 unless their weights are named: set-fonts { pairing: "custom", headingFamily: "Playfair", headingWeights: [300, 400, 800], headingItalic: true } loads any weights the family has, at most six, so a section can set var(--fh-font-heading) at 300 or 800 without a font setting of its own.
A picture in the styles. An image setting arrives as url("<its path in your media library>"), at its original size, so styles can lay it as a tiled background (background-image:var(--fh-s-grain,none)) or draw a one-colour drawing in the text colour (background:currentColor;mask:var(--fh-s-mark,none) center/contain no-repeat, with the -webkit-mask twin). An empty setting writes no variable, so give the fallback none. An image field of a list item arrives the same way as --fh-i-<field>. Only a path in the store's own media library is ever written.
List items with their own colours. Colour, number, range, select and on/off fields of a list's items become CSS variables on each item's own element: put data-fh-item="{{ loop.itemId }}" on it inside {% for %}, and the item's background field is var(--fh-i-background) there. No style attribute is needed, and each tile of a row can have its own colour:
.tile{background:var(--fh-i-background,var(--fh-accent))}
6a. Page bodies: the product, catalog and cart pages
The product page, the catalog page (every product, each collection and the search results, one layout), the cart page, each content page (/pages/<slug>), the blog list (/blog) and every post (/blog/<slug>) can be drawn by a section your store owns instead of the theme's shared code. Such a section says schema.body: "product" (or "catalog", "cart", "page", "blog", "post") and stands in the page's body (main), reading the page's own data (product, listing, the whole cart, a content page's page.html, the blog's blog.posts with its paging, a post's post) and placing the page's controls with widgets. A content page body is placed on one content page at a time (page:<slug>): it draws that page's title band and text in your layout, with no theme breadcrumb or title. The blog list and the posts have one layout each, the pages blog and post, where sections sit before and after the body as on any page. The contact page's form is not part of page.html, so keep the theme's own body there. Everything that posts (add to cart, the discount code, checkout) is still the store's own markup: a section decides how the page looks, never where anything goes or what it costs. Checkout, payment, order and account pages stay the platform's.
- A section whose schema.body is "product", "catalog", "cart", "page", "blog" or "post" is that page's body. Its schema.pages is exactly that kind, schema.slots is empty and maxInstancesPerPage is 1. A "page" body is a content page's: it stands in the body of the content page (page:<slug>) it is placed on, so each content page has its own; add it with add-section { sectionId, position: { target: "start", page: "page:<slug>" } }.
- On that page the body is the theme's own (main) or exactly one body section, in its place; other sections and blocks may sit before and after. add-section of a body section takes main's place; remove-section puts main back where it stood; add-section { sectionId: "main", position: { target: "start", page } } puts the theme's own back in the body's place. A second body is refused.
- A hidden or switched-off body section, or one whose section is gone, shows the theme's own body in its place: a page is never without one.
- The catalog page is one layout for every product, each collection and the search results; listing.kind says which.
- The body holds the page's one h1 (the theme no longer draws it there, nor a content page's breadcrumb); every other section's h1 on the page is written as an h2.
- A content page body reads page.title, page.slug, page.url and page.html, the page's text as the theme prints it, through the same allow-list as your markup: print it with {{ page.html | raw }}. Declare the title or the text in schema.elements (type text, no setting) to let the merchant style, move and hide them.
- A "blog" body is the blog list (/blog, layout page blog): blog.posts is this page's posts (12 a page, newest first), with blog.page, blog.pages, blog.previousUrl and blog.nextUrl. A "post" body is every post (/blog/<slug>, layout page post): post.title, post.date, post.image, post.tags, post.author, post.excerpt and post.html (print it with | raw), and blog.posts the other newest posts for a row of them. Name a loop's item other than post in a post body ({% for other in blog.posts %}).
- A template writes no form, input or script; inert buttons are allowed. Forms are widgets: platform markup posting to the store's own addresses with the theme's fields, styled by your styles through its classes. Widget arguments are escaped text capped at 120 characters; el: "<key>" makes the wrapper selectable in the builder.
- At most 16 widgets in a page body (4 elsewhere). Widgets never repeat inside {% for %}, except line-quantity and line-remove, which go inside {% for line in cart.lines %} as line: line.
- Prices come from the page's data; a template cannot change what is posted or charged. On the product page mark your own price elements with data-fh-variant-price (the price), data-fh-variant-compare and data-fh-variant-compare-amount (the struck price), data-fh-variant-percent (the sale percentage) and data-fh-variant-stock with data-fh-stock-in, -low, -out ({n} is the count): the buy box updates them as the shopper picks options. Leave the struck price and percentage empty when not on sale and hide them with :empty; a variant without a sale hides them with the hidden attribute, so keep [hidden]{display:none} in your styles.
- {% slot "<id>" %} places the page's module slot (installed modules and add-on blocks such as reviews) where the template wants it, once. A slot the template leaves out stays where the theme puts it: catalog.toolbar just above the body, the others just below.
- Text that comes from data (a product's title, a price) can still be an element: declare it in schema.elements with type text and no setting. The merchant styles, hides, reorders and nudges it; it is never typed into.
- The cart is the shopper's own: a cart page is never cached or shared, and section scripts run there wherever the merchant switched them on, unless they turned the cart page off. The checkout button always goes to the store's own checkout, with each line's notes.
- preview_section { page: "product" | "catalog" | "cart" | "page:<slug>" | "blog" | "post", sample } previews a body against sample: { product: "<handle>" }, { collection: "<handle>" }, { cart: [{ product, quantity?, variant? }] }, { page: "<slug>" } or { post: "<slug>" }, else the store's own newest data (the content page named by page:<slug>, the first page of posts, the newest post), else a built-in sample. In the builder the cart page shows sample lines; a content page, the blog and a post show the store's own.
The product page
product (sections whose schema.pages is ["product"]): The product the page shows, with everything the theme's product page shows.
| Field | Type | Meaning |
|---|---|---|
| title | text | The product's name. |
| handle | text | Its handle. |
| url | url | This page's address. |
| descriptionHtml | html | The description as the merchant formatted it, reduced to paragraphs, emphasis, links, lists and small headings; print it with | raw. |
| description | text | The description as plain text. |
| price | money | The price the page opens with: the preselected option's, for a product with options. The buy box keeps a price marked data-fh-variant-price up to date. |
| compareAtPrice | money? | The earlier price, shown struck through, when set. |
| onSale | boolean | True when compareAtPrice is higher than price. |
| discountPercent | number | The sale as a whole percentage; 0 when not on sale. |
| priceFrom | money? | The lowest option price, when the product has options. |
| hasVariants | boolean | True when shoppers pick options such as size. |
| available | boolean | True when something of it is in stock. |
| inventory | number? | Units in stock of what the page opens with. |
| lowStock | boolean | True when in stock with 10 or fewer left. |
| isNew | boolean | True while the store's New badge applies to it. |
| image | image? | Its main image. |
| images | list<image> | Every image, main image first (at most 9). |
| options | list<productOption> | Its options (Size, Colour), in order; empty for a plain product. |
| variants | list<variant> | Every combination on sale; empty for a plain product. |
| selectedVariant | variant? | The combination the page opens with: the first in stock. |
| sku | text | Its stock keeping unit. |
| vendor | text | Its brand or maker. |
| productType | text | Its type. |
| tags | list<text> | Its tags. |
| kind | text | physical, digital, service or subscription. |
| unitPrice | text | The price per unit (per kg, per litre) when set; empty otherwise. |
| taxHint | text | incl. tax or excl. tax when the store charges tax, as the theme prints it beside a price; empty otherwise. |
| quantityMax | number | The most one order may take (10, or the stock when lower). |
| collections | list<collectionLink> | The collections it is in. |
| related | list<product> | The products the theme's product page shows as related: the merchant's picks, then the same collection, the same tags, the newest; never sold out. |
| createdAt | date | When it was added. |
Example: <h1 data-fh-el="title">{{ product.title }}</h1><p data-fh-variant-price="">{{ product.price \| money }}</p> renders <h1 data-fh-el="title">Linen shirt</h1><p data-fh-variant-price="">£45.00</p>.
productOption (product.options): One option of a product, such as Size. name (text) Its name. position (number) From 1. values (list<optionValue>) Its values, in order.
optionValue (productOption.values): One value of an option. label (text) The value, such as M. available (boolean) True when some combination with it is in stock. selected (boolean) True for the value the page opens with.
variant (product.variants, product.selectedVariant): One combination of option values on sale. id (text) Its id. title (text) Its values joined, such as M / Blue. values (list<text>) One value per option. price (money) Its price. compareAtPrice (money?) Its earlier price. onSale (boolean) True when on sale. discountPercent (number) The sale as a whole percentage. available (boolean) True when in stock. inventory (number) Units in stock. image (image?) Its own image, when it has one. sku (text) Its stock keeping unit. selected (boolean) True for the one the page opens with.
collectionLink (product.collections, listing.collections): A collection, as a link. title (text) Its name. handle (text) Its handle. url (url) Its page. current (boolean) True when it is the listing shown.
| Widget | What it is | Arguments | Classes |
|---|---|---|---|
| buy-box | Everything that buys the product, as the theme's product page has it: an option picker per option (size, colour) that updates the price, the stock line, the buttons and the picture as the shopper chooses, the quantity, Add to cart, Buy now and the sold out and checkout-not-open states. | addLabel The Add to cart button. (Add to cart); buyLabel The Buy now button. (Buy now); buyNow show, or hide to leave Buy now out. (show); quantityLabel The quantity's label. (Qty); soldOutLabel What the stock line and the disabled button say when nothing is left. (Sold out); lowStockLabel The stock line with 10 or fewer left; {n} is the number. Empty shows nothing.; inStockLabel The stock line when in stock; {n} is the number. Empty shows nothing.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-buy-box, fh-widget-options, fh-widget-option, fh-widget-option-label, fh-widget-select-wrap, fh-widget-select, fh-widget-option-buttons, fh-widget-option-button, fh-widget-actions, fh-widget-quantity, fh-widget-label, fh-widget-button, fh-widget-add, fh-widget-buy, fh-widget-sold-out, fh-widget-stock, fh-widget-note, fh-widget-closed |
| variant-picker | The option pickers alone, for a layout that puts them apart from the buttons. Shares one form with quantity, add-to-cart, buy-now and stock. | labelPrefix Words before each option's name ("Select" shows "Select Size"); none when empty.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-variant-picker, fh-widget-options, fh-widget-option, fh-widget-option-label, fh-widget-select-wrap, fh-widget-select, fh-widget-option-buttons, fh-widget-option-button |
| quantity | The quantity alone: 1 up to 10, or the stock when lower. | label Its label. (Qty); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-quantity, fh-widget-label, fh-widget-select-wrap, fh-widget-select |
| add-to-cart | The Add to cart button alone; disabled and saying so when sold out or while the store cannot take money. | label Its text. (Add to cart); soldOutLabel What it says when nothing is left. (Sold out); icon An icon name beside the text; none when empty.; iconPosition after (the default) or before the text. (after); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-add-to-cart, fh-widget-button, fh-widget-add, fh-widget-label, fh-widget-sold-out, fh-widget-note, fh-widget-closed |
| buy-now | The Buy now button: straight to the store's checkout with the product, past the cart. On the product page it joins the page's product form (the option and quantity the shopper picked). With product: it works on any page, such as a flash sale on the home page: that product, its variant, quantity and discount code, never a price. Nothing when the product cannot be bought (sold out, not published, a handle that names nothing, checkout not open); a product with options and no variant: becomes a link to its page, same look. | product Any page: the product, as a product setting (settings.product), the page's product, or a handle in quotes. Left out on a product page: the page's product form.; variant With product: the option to buy, by id (such as product.selectedVariant.id); needed for a product with options, else the button links to its page.; quantity With product: how many, 1 to 10, never more than in stock. (1); discount With product: a discount code to apply at checkout. The store checks it there; one that is not valid is ignored and the product is sold at full price.; chooseLabel With product: the link's text for a product with options and no variant; the label when empty.; label Its text. (Buy now); icon An icon name beside the text; none when empty.; iconPosition after (the default) or before the text. (after); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-buy-now, fh-widget-button, fh-widget-buy, fh-widget-label |
| stock | The stock line alone, updated as the shopper picks options. | soldOutLabel When nothing is left. (Sold out); lowStockLabel With 10 or fewer left; {n} is the number.; inStockLabel When in stock; {n} is the number.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-stock, fh-widget-stock |
| gallery | The product's pictures: the main one and a thumbnail per picture that shows it, without a script, as the theme's gallery does. Follows the store's gallery settings (fit, corner radius, thumbnails) and the picture of the option the shopper picks. | fit store (the default): the store's gallery fit; section: your styles decide object-fit. (store); radius store (the default): the store's gallery corners; section: your styles decide border-radius. (store); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-gallery, fh-widget-gallery-thumbs, fh-widget-gallery-thumb, fh-widget-gallery-main, fh-widget-gallery-slide, fh-widget-gallery-empty |
Slots: product.options Add-on blocks made for beside the product's options. product.below Under the product: add-on blocks such as reviews, and the modules made for there.
A body to adapt:
<div class="product" data-fh-group="info">
{% widget "gallery" el: "gallery" %}
<h1 data-fh-el="title">{{ product.title }}</h1>
<p class="price" data-fh-el="price"><span data-fh-variant-price="">{{ product.price | money }}</span> <s data-fh-variant-compare="" data-fh-variant-compare-amount="">{% if product.onSale %}{{ product.compareAtPrice | money }}{% endif %}</s></p>
<p data-fh-el="description">{{ product.description }}</p>
{% widget "buy-box" addLabel: settings.addLabel el: "buy" %}
</div>
{% slot "product.below" %}
The catalog page
listing (sections whose schema.pages is ["catalog"]): What the catalog page lists: every product, one collection or search results, one page of them, with the sorts, filters and pages as links.
| Field | Type | Meaning |
|---|---|---|
| kind | text | all (every product), collection or search: the catalog, a collection and the search results are one page. |
| title | text | The collection's title, Results for the search, or All products. |
| description | text | The collection's description; empty elsewhere. |
| image | image? | The collection's image, when it has one. |
| query | text | What was searched for. |
| total | number | How many products match, over every page. |
| count | number | How many products this page shows. |
| sort | text | The sort in use: newest, price-asc, price-desc or name. |
| sorts | list<sortOption> | Every sort, each with the address that applies it. |
| page | number | This page's number. |
| pages | number | How many pages there are. |
| previousUrl | url | The page before; empty on the first. |
| nextUrl | url | The page after; empty on the last. |
| pageLinks | list<pageLink> | Every page, for a numbered pager; empty on a single page. |
| filters | list<filterGroup> | The filters the store offers here (tags, options, brand, type, availability), each value with its count and the address that adds or removes it. |
| filtered | boolean | True when a filter is chosen. |
| clearUrl | url | This listing with every filter taken off. |
| priceMin | money? | The lowest price here, when prices differ. |
| priceMax | money? | The highest price here, when prices differ. |
| collections | list<collectionLink> | The store's visible collections, current when it is this one. |
| allUrl | url | Every product. |
| url | url | This listing's own address. |
| empty | boolean | True when there is nothing to show. |
| taxHint | text | incl. tax or excl. tax when the store charges tax, as the theme prints it beside each card's price; empty otherwise. |
| products | list<product> | This page's products, in the listing's order, as many as the theme's page shows. |
Example: {% for sort in listing.sorts %}<a href="{{ sort.url }}"{% if sort.current %} aria-current="true"{% endif %}>{{ sort.label }}</a>{% endfor %} renders <a href="/collections/shirts" aria-current="true">Newest</a><a href="/collections/shirts?sort=price-asc">Price: low to high</a><a href="/collections/shirts?sort=price-desc">Price: high to low</a><a href="/collections/shirts?sort=name">Name</a>.
collectionLink (product.collections, listing.collections): A collection, as a link. title (text) Its name. handle (text) Its handle. url (url) Its page. current (boolean) True when it is the listing shown.
sortOption (listing.sorts): One way to sort a listing. value (text) newest, price-asc, price-desc or name. label (text) What the theme calls it. current (boolean) True for the sort in use. url (url) This listing sorted that way, filters kept.
filterGroup (listing.filters): One filter: tags, an option, brand, type or availability. key (text) Its address key, such as tag or opt.size. label (text) Its name. options (list<filterOption>) Its values.
filterOption (filterGroup.options): One value of a filter. value (text) The value. label (text) What it says. count (number) How many products carry it. selected (boolean) True when chosen. url (url) This listing with it added, or taken off when chosen.
pageLink (listing.pageLinks): One page of a listing. number (number) From 1. url (url) Its address. current (boolean) True for the page shown.
| Widget | What it is | Arguments | Classes |
|---|---|---|---|
| sort | The sort control: a list that re-sorts the listing as soon as it changes, keeping the search and the filters. listing.sorts has the same as links. | label Its label. (Sort by); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-sort, fh-widget-label, fh-widget-select-wrap, fh-widget-select, fh-widget-button |
| price-filter | The price range: from and to boxes and Apply, keeping the search, the sort and the filters. Nothing when every product costs the same. | fromLabel The first box's label. (Price from); toLabel The second box's label. (Price to); button The button. (Apply); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-price-filter, fh-widget-field, fh-widget-label, fh-widget-input, fh-widget-button |
Slots: catalog.toolbar Above the products: the Filters and sorting module (collection chips, filters with counts, sort, the count). catalog.below Under the products: the pager of the Filters and sorting module, and add-on blocks.
A body to adapt:
<h1 data-fh-el="title">{% if listing.kind == "all" %}{{ settings.heading }}{% else %}{{ listing.title }}{% endif %}</h1>
{% widget "sort" el: "sort" %}
{% slot "catalog.toolbar" %}
<ul class="grid" data-fh-el="grid">{% for product in listing.products %}<li><a href="{{ product.url }}">{{ product.title }} {{ product.price | money }}</a></li>{% endfor %}</ul>
{% if listing.nextUrl %}<a href="{{ listing.nextUrl }}">Next</a>{% endif %}
{% slot "catalog.below" %}
The cart page
cart (sections whose schema.pages is ["cart"]; elsewhere cart is the summary (itemCount, subtotal, currency)): The shopper's cart, whole: its lines and totals, the codes on it and what it still needs. Never cached, never shared.
| Field | Type | Meaning |
|---|---|---|
| itemCount | number | Units in the cart. |
| subtotal | money | What the lines cost, with the store's rules and the discount code already taken off. |
| currency | text | The cart currency. |
| lines | list<cartLine> | One line per product, or per option of a product. |
| empty | boolean | True when it holds nothing. |
| total | money | What the cart shows as its total: the subtotal; shipping and tax come at checkout. |
| compareSubtotal | money | The subtotal before the store's rules and group prices. |
| savings | money | compareSubtotal minus subtotal; 0 when nothing came off. |
| discount | cartDiscount? | The discount code the shopper entered, when there is one. |
| appliedRules | list<appliedRule> | The store's price rules that took something off. |
| giftCard | giftCard? | The gift card the shopper attached, when there is one. |
| freeShipping | freeShipping? | How far the cart is from free shipping, when the store offers it. |
| shippingEstimate | boolean | True when the store needs the destination before checkout: place the shipping-estimate widget. |
| destination | destination? | Where the shopper said it goes, when they did. |
| adjusted | boolean | True when quantities were lowered to the stock on hand. |
| currencyMismatch | boolean | True when lines are in different currencies and cannot be paid together. |
| canCheckout | boolean | True when the checkout button will take the shopper to pay. |
| checkoutOpen | boolean | False while the store cannot take money yet. |
| orderNote | boolean | True when the store asks for a note for the seller; the checkout button carries the box. |
| taxNote | text | Prices include tax. or Tax is calculated at checkout. when the store charges tax, as the theme prints it under the subtotal; empty otherwise. |
| notes | list<text> | The sentences the theme's cart shows about the cart itself: quantities lowered to the stock, lines in different currencies. |
| offers | list<offer> | The offers in effect for this cart (also readable as offers): rules marked show on storefront, the cheapest free shipping, store functions' offers and the code on the cart. The offers widget draws them. |
| progress | progress? | Progress towards the nearest threshold offer not reached yet, else the highest reached; empty without one. The progress widget draws it. |
Example: {% for line in cart.lines %}{{ line.title }} × {{ line.quantity }}: {{ line.lineTotal \| money }}{% endfor %} renders Linen shirt × 2: £90.00.
cartLine (cart.lines): One line of the cart. key (text) The line's own key; per-line widgets read it. product (product) The product, at the chosen option's price. title (text) The product's name. variantTitle (text) The chosen options, such as M / Blue; empty for a plain product. quantity (number) How many. unitPrice (money) The price of one. lineTotal (money) The price of all of them. image (image?) The chosen option's image, else the product's. url (url) The product's page. maxQuantity (number) The most this line may hold. lowStock (boolean) True when 10 or fewer are left. label (text) The label a store function gave the line, such as Free tote; empty otherwise. addedBy (text) The store function that added the line or made it free; empty for the shopper's own. gift (boolean) True when some of its units are a free gift. giftUnits (number) How many of its units are free. removable (boolean) False for a gift the store keeps in the cart; line-remove then shows nothing. options (list<lineAttribute>) The chosen options by name, as key (Size) and value (M); empty for a plain product. attributes (list<lineAttribute>) The shopper's notes on the line (a gift message), as key and value; checkout carries them to the order.
lineAttribute (cartLine.attributes): One note on a cart line, such as a gift message. key (text) Its key, such as gift_message. value (text) What the shopper wrote.
offer (offers, cart.offers (the cart page)): One offer in effect for the cart: display only, from the same evaluation as the total. id (text) Its id: free-shipping, rule-<id>, code, or a store function's own. source (text) rule, shipping, function or code. kind (text) threshold, quantity_break, gift, bundle or custom. label (text) Its name, such as Free Standard shipping. detail (text) A second line; often empty. met (boolean) True when the cart has it. threshold (money?) The subtotal it needs, for a threshold. remaining (money?) What is left to add; 0 once met. progress (number?) How far along, 0 to 100, for a threshold. text (text) The sentence the offers widget writes: Add £10.00 more, or Unlocked.
progress (cart.progress (the cart page)): Progress towards a threshold offer, such as free shipping: for a bar of your own. offer (text) The offer's id. label (text) The offer's name. threshold (money) The subtotal it needs. remaining (money) What is left; 0 once reached. progress (number) How far along, 0 to 100, for a width or a progress value. reached (boolean) True once reached. text (text) The progress widget's sentence: Add £10.00 more for Free Standard shipping.
cartDiscount (cart.discount): The discount code on the cart. code (text) The code as entered. applied (boolean) True when it is taking money off. amount (money) What it took off. freeShipping (boolean) True when it makes shipping free. label (text) The code and what it does, as the theme's chip says it. message (text) The store's sentence about it, when there is one.
appliedRule (cart.appliedRules): A price rule that took something off the cart. name (text) The rule's name. amount (money) What it took off.
giftCard (cart.giftCard): The gift card on the cart: a way of paying, used at the payment step. applied (boolean) True when the store recognised it. last4 (text) Its last four characters. balance (money) What is on it. message (text) The store's sentence about it.
freeShipping (cart.freeShipping): How far the cart is from the store's free shipping. threshold (money) What a cart has to reach. remaining (money) What is left to add; 0 once it qualifies. rateName (text) The rate that becomes free. qualifies (boolean) True once it does. text (text) The theme's sentence, such as Add £10.00 more for free Standard shipping.
destination (cart.destination): Where the shopper said the order goes. country (text) The country code, such as GB. countryName (text) Its name. postalCode (text) The postal code.
| Widget | What it is | Arguments | Classes |
|---|---|---|---|
| line-quantity | One line's quantity and its Update button. Only inside {% for line in cart.lines %}, as line: line. | line The loop's line.; label The quantity's label. (Qty); button The button. (Update); style "stepper": a minus button, the number and a plus button, each press saving; else a list and the button.; minusIcon The stepper's minus: an icon name; a minus sign when empty.; plusIcon The stepper's plus: an icon name; a plus sign when empty. | fh-widget, fh-widget-line-quantity, fh-widget-label, fh-widget-select-wrap, fh-widget-select, fh-widget-button, fh-widget-stepper, fh-widget-minus, fh-widget-plus, fh-widget-quantity |
| line-remove | One line's Remove button. Only inside {% for line in cart.lines %}, as line: line. | line The loop's line.; label Its text (read out when an icon shows it). (Remove); icon An icon name to show instead of the text; none when empty. | fh-widget, fh-widget-line-remove, fh-widget-button, fh-widget-label |
| discount-code | The discount code box: a field and Apply while no code holds, the code with what it takes off and Remove once one does, and the store's sentence about it. | heading The line above it. (Discount code); placeholder The hint in the field. (Enter code); button Apply. (Apply); removeLabel Remove. (Remove); icon An icon name to show at the start of the field; none when empty.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-discount-code, fh-widget-heading, fh-widget-field, fh-widget-input, fh-widget-button, fh-widget-chip, fh-widget-note |
| gift-card | The gift card box: the card shows as its last four characters and its balance, used at the payment step. | heading The line above it. (Gift card); placeholder The hint in the field. (Enter gift card code); button Apply. (Apply); removeLabel Remove. (Remove); icon An icon name to show at the start of the field; none when empty.; el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-gift-card, fh-widget-heading, fh-widget-field, fh-widget-input, fh-widget-button, fh-widget-chip, fh-widget-note |
| shipping-estimate | Where the order goes (country and postal code), for the stores that need it before checkout: place it inside {% if cart.shippingEstimate %}. | heading The line above it. (Ship to); button The button. (Update); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-shipping-estimate, fh-widget-heading, fh-widget-field, fh-widget-label, fh-widget-select, fh-widget-input, fh-widget-button, fh-widget-note |
| offers | The offers in effect for this cart, one item each: the automatic discounts you marked show on storefront, your cheapest free shipping, your store functions' offers and the code on the cart, with how far the cart is from each threshold. Drawn from the same evaluation as the cart's total. Nothing when there are none. | heading A line above the list; none when empty.; source Only offers from these, comma separated: rule, shipping, function, code; every one when empty.; metLabel The status of an offer the cart already has. (Unlocked); pendingLabel The status of a threshold not reached yet; {amount} is what is left to add. (Add {amount} more); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-offers, fh-widget-heading, fh-widget-offers-list, fh-widget-offer, fh-widget-offer-label, fh-widget-offer-detail, fh-widget-offer-status; each item has data-fh-offer-kind and data-fh-offer-state (met or pending) |
| progress | Progress towards a threshold offer, such as free shipping: a sentence, a bar and the reached state, from the same evaluation as the cart's total. Follows offer: when given, else the nearest threshold not reached yet, else the highest one reached. Nothing when the store has no threshold offer. | offer The offer's id (free-shipping, rule-<id>, or a function's own id); the nearest when empty.; pendingLabel The sentence before it is reached; {amount} is what is left, {offer} the offer's name. (Add {amount} more for {offer}.); reachedLabel The sentence once it is reached; {offer} is its name. (You have unlocked {offer}.); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-progress, fh-widget-progress-text, fh-widget-progress-bar (a progress element); the wrapper has data-fh-progress-state (pending or reached) and --fh-progress, the share done as a percentage, for a bar of your own |
| checkout-button | Check out: always the store's own checkout, with the cart, the code and the gift card on it, and the note for the seller when the store asks for one. Disabled while the cart cannot be paid. | label Its text. (Check out); icon An icon name after the text; none when empty.; noteLabel The note's label. (Note for the seller); notePlaceholder The hint in the note. (Anything we should know about this order?); el An element key: the merchant can select, move and hide the widget in the builder (data-fh-el on its wrapper). | fh-widget, fh-widget-checkout-button, fh-widget-button, fh-widget-label, fh-widget-order-note |
Slots: cart.below Under the cart: Add to your order (product recommendations), trust badges and add-on blocks.
A body to adapt:
<h1 data-fh-el="title">{% if cart.empty %}{{ settings.emptyHeading }}{% else %}{{ settings.heading }}{% endif %}</h1>
{% for line in cart.lines %}<div class="line">{{ line.title }} {{ line.variantTitle }} {{ line.lineTotal | money }}
{% widget "line-quantity" line: line %}{% widget "line-remove" line: line icon: "trash" %}</div>{% endfor %}
{% if cart.empty == false %}<p data-fh-el="subtotal">{{ settings.subtotalLabel }} {{ cart.subtotal | money }}</p>
{% widget "discount-code" el: "discount" %}{% widget "checkout-button" el: "checkout" %}{% endif %}
{% slot "cart.below" %}
A content page
page (sections whose schema.pages is ["page"]; elsewhere page has no html): The content page shown (/pages/<slug>), with its text: everything the theme's content page shows.
| Field | Type | Meaning |
|---|---|---|
| title | text | The page's title. |
| slug | text | Its slug (/pages/<slug>). |
| url | url | This page's address. |
| html | html | The page's text as the merchant wrote it, formatted as the theme prints it (headings, paragraphs, links, lists, quotes); print it with | raw. |
| kind | text | Always "page". |
| product | product? | Empty here. |
| collection | collection? | Empty here. |
Example: <h1 data-fh-el="title">{{ page.title }}</h1><div data-fh-el="text">{{ page.html \| raw }}</div> renders <h1 data-fh-el="title">About us</h1><div data-fh-el="text"><p>We make <strong>small</strong> batches.</p></div>.
A body to adapt:
<header class="band" data-fh-group="band">
<h1 data-fh-el="title">{{ page.title }}</h1>
<p data-fh-el="intro">{{ settings.intro }}</p>
</header>
<div class="text" data-fh-el="text">{{ page.html | raw }}</div>
The blog list
blog (sections whose schema.pages is ["blog"] or ["post"]; elsewhere blog is the latest posts (url, posts)): The blog: on the blog list (/blog), one page of posts with the paging; on a post, the other newest posts.
| Field | Type | Meaning |
|---|---|---|
| url | url | The blog list (/blog). |
| posts | list<blogPost> | On the blog list, this page's posts, newest first (12 a page). On a post, the other newest posts, without this one. |
| page | number | This page, from 1. |
| pages | number | How many pages. |
| previousUrl | url | The newer page's address, or empty on the first. |
| nextUrl | url | The older page's address, or empty on the last. |
Example: {% for post in blog.posts %}<a href="{{ post.url }}">{{ post.title }}</a>{% endfor %}{% if blog.nextUrl %}<a href="{{ blog.nextUrl }}">Older</a>{% endif %} renders <a href="/blog/first-firing">The first firing</a><a href="/blog?page=2">Older</a>.
blogPost (blog.posts on the blog list and on a post): A published post, on the blog pages. title (text) Its title. url (url) Its page (/blog/<slug>). date (date) Published on. excerpt (text) Its summary. image (image?) Its cover. tags (list<text>) Its tags. author (text) Its author, or empty when not given.
A body to adapt:
<h1 data-fh-el="title">{{ settings.heading }}</h1>
<ul class="posts" data-fh-el="posts">{% for post in blog.posts %}<li>
{% if post.image %}<img src="{{ post.image.src }}" alt="{{ post.image.alt }}">{% endif %}
<a href="{{ post.url }}">{{ post.title }}</a> <time>{{ post.date | date: "long" }}</time>
<p>{{ post.excerpt }}</p></li>{% endfor %}</ul>
<nav data-fh-el="pager">{% if blog.previousUrl %}<a href="{{ blog.previousUrl }}">Newer</a>{% endif %}
{{ blog.page }} / {{ blog.pages }}{% if blog.nextUrl %} <a href="{{ blog.nextUrl }}">Older</a>{% endif %}</nav>
A post
post (sections whose schema.pages is ["post"]): The post shown (/blog/<slug>), with its text.
| Field | Type | Meaning |
|---|---|---|
| title | text | Its title. |
| url | url | Its page (/blog/<slug>). |
| date | date | Published on. |
| excerpt | text | Its summary. |
| image | image? | Its cover. |
| tags | list<text> | Its tags. |
| author | text | Its author, or empty when not given. |
| html | html | The post's text as the merchant wrote it, formatted as the theme prints it; print it with | raw. |
Example: <h1 data-fh-el="title">{{ post.title }}</h1><p>{{ post.date \| date: "long" }}</p>{{ post.html \| raw }} renders <h1 data-fh-el="title">The first firing</h1><p>2 September 2026</p><p>How the new glaze came out.</p>.
blogPost (blog.posts on the blog list and on a post): A published post, on the blog pages. title (text) Its title. url (url) Its page (/blog/<slug>). date (date) Published on. excerpt (text) Its summary. image (image?) Its cover. tags (list<text>) Its tags. author (text) Its author, or empty when not given.
A body to adapt:
<p data-fh-el="tags">{% for tag in post.tags %}{{ tag }} {% endfor %}</p>
<h1 data-fh-el="title">{{ post.title }}</h1>
<p data-fh-el="date">{{ post.date | date: "long" }}{% if post.author %} · {{ post.author }}{% endif %}</p>
{% if post.image %}<img data-fh-el="cover" src="{{ post.image.src }}" alt="{{ post.image.alt }}">{% endif %}
<div class="text" data-fh-el="text">{{ post.html | raw }}</div>
<ul data-fh-el="more">{% for other in blog.posts limit: 3 %}<li><a href="{{ other.url }}">{{ other.title }}</a></li>{% endfor %}</ul>
What your store's code puts in the cart. Your store's own code can change a cart without a script on the page: a store function on cart.transform adds a free gift or a bundle's parts and sets notes on a line, one on cart.price lowers prices and takes amounts off the order, and the automatic discounts you mark show on storefront are offers every shopper may read. The cart page draws all of it from the one evaluation the checkout charges: a line a function added carries its label (line.label, "Free tote", also printed in the variant-title slot), its free units (line.gift, line.giftUnits) and whether the shopper may remove it (line.removable; line-remove draws nothing for a gift your store keeps in the cart); the shopper's own notes on a line are line.attributes and the checkout button carries them to the order exactly as the theme's own form does; order discounts show under their own labels in cart.appliedRules; and the offers in effect are offers (also cart.offers), with the nearest threshold as cart.progress. Store code names products and quantities, never prices: every number here is the store's own arithmetic.
Offers, progress and a one-click add on the cart page. {% widget "offers" %} lists the offers in effect for this cart: the rules you marked show on storefront, your cheapest free shipping, your store functions' offers and the code on the cart, each with its status ("Unlocked", or "Add £10.00 more"); source: "rule, function" keeps to some of them. {% widget "progress" %} draws progress towards a threshold offer: a sentence, a <progress> bar and the reached state, following offer: "free-shipping" (or rule-<id>, or a function's own offer id) or, left out, the nearest threshold not reached yet. Both are drawn by the store with the cart, from the same evaluation as its total (the cart page is never cached), and draw nothing when there is nothing to show, so a body that places them looks exactly like the theme's page for a store with no offers. To draw your own, loop over offers (offer.label, offer.met, offer.remaining, offer.progress from 0 to 100, offer.text) or read cart.progress (progress, remaining, reached, text). {% widget "quick-add" %} (§4) adds a suggested product. The Bold Street cart body places all three: the offers under the codes, a progress bar and a suggested product you switch on in its settings.
The stock line's state. The stock line (stock, and the buy box's) carries data-fh-stock-key="in", "low" or "out", written by the store and kept up to date as the shopper picks options, so your styles can colour it: [data-fh-stock-key="low"]{color:#b45309}.
Module slots inside your body. A slot the template places ({% slot "product.below" %}) is drawn inside your section, so your styles reach the modules in it: a rule such as [hidden]{display:none} also hides a module's own controls. Write rules for your own markup (.gallery [hidden]), or leave the slots out of a broad rule: every module in a slot sits in an element with data-fh-slot, so :scope [hidden]:not([data-fh-slot] *) touches only yours.
The gallery and the store's gallery settings. The gallery follows the store's gallery fit, corner radius and thumbnails. {% widget "gallery" fit: "section" radius: "section" %} leaves the fit, the corners or both to your styles (object-fit, border-radius); what you leave as store still follows the store.
7. Limits
| What | Limit |
|---|---|
| Template | 32 KB |
| Styles | 16 KB |
| Artwork drawings per section | 32, each 32 KB, 160 KB together |
| Settings per section | 40 |
| Groups per section | 8 |
| Presets per section | 12 |
| Options in one select setting | 24 |
| Fields in one list item | 8 |
| Items in one list setting | 24 |
| Instances on one page | 60 |
| Instances in the header or footer slot | 40 |
| The most maxInstancesPerPage may be | 60 |
| Sections per store | 100 |
| Versions kept per section | 20 |
| Nested if and for tags | 8 |
| Expressions and tags in one template | 1,500 |
| Characters in one expression | 300 |
| Filters in one expression | 6 |
| Items one loop shows | 50 |
| Loop steps in one render | 1,000 |
| Products one collection offers a template | 50 |
| Product and collection settings one instance may use | 24 |
| Render time for one instance | 10 ms |
| Render time for every section on a page | 100 ms |
| Markup one instance may produce | 64 KB |
| Markup every section on a page may produce | 512 KB |
| Image widths image_url accepts | 64 to 2,400 pixels |
A section over a render limit is left out of the page rather than slowing it down, and the store's event log records why.
8. Build one with an agent
describe_sections: Read the rules: the package format, the setting types, the template language, the limits and the refusal codes.save_section: Save your package { package }, with data-fh-el on every element that prints a text, image or link setting (describe_sections { parts: ["editable"] }). It is checked whole; a refusal names the section, setting, preset or template line to fix, and the answer lists what the merchant can select (editable). Saving again with changes makes a new version.preview_section: Render one instance { id, settings } exactly as shoppers would see it, before it is on any page.apply_storefront_commands: Place it on the draft: { revision, commands: [{ type: "add-section", sectionId, position, settings }] }; update-section, apply-section-preset, duplicate-section, set-section-visibility and remove-section change it later.get_preview_link: Open the draft at desktop and phone width (about 390px).publish_storefront: Publish { revision }. Nothing a shopper sees changes before this.
A worked round, with a test token on the sandbox:
describe_sections
save_section { package: { schemaVersion: 1, id: "promo-band", name: "Promo band",
schema: { settings: [...], pages: ["home", "catalog"] }, template: "...", styles: "..." } }
preview_section { id: "promo-band", settings: { heading: "20% off everything", code: "WINTER20" } }
get_storefront_draft
apply_storefront_commands { revision, commands: [
{ type: "add-section", sectionId: "promo-band",
position: { placement: "after", target: "hero" },
settings: { heading: "20% off everything", code: "WINTER20" } } ] }
get_preview_link
publish_storefront { revision }
A package that is not right is refused whole, with every problem on its own line, each naming exactly where it is:
Section promo-band, setting heading: the default does not fit the setting: use at most 100 characters.
Section promo-band, template line 4: "include" is not available in a section.
Fix them all and save again. preview_section takes a package you have not saved yet too, so a template can be tried before it is stored.
Instances are changed with editor commands on the draft, like everything else on a page: update-section { instanceId, settings } (a null value puts a setting back to its default), apply-section-preset, duplicate-section, set-section-visibility, move-section, set-layout and remove-section, and one element at a time with set-element-style { instanceId, key, … } and set-element-order { instanceId, group, order } (§5). Each refusal names its command: Command 2 (add-section, winter): Section promo-band, instance winter on page home: this section may appear at most 3 times on a page.
9. Examples
These packages are complete and pass every check; start from one.
A promo band, with presets and styles
{
"schemaVersion": 1,
"id": "promo-band",
"name": "Promo band",
"description": "A coloured strip with an offer, an optional code and a link.",
"category": "promotion",
"version": "1.2.0",
"author": {
"kind": "owner",
"name": ""
},
"origin": "store",
"schema": {
"settings": [
{
"key": "heading",
"type": "text",
"label": "Offer",
"default": "Free delivery over £50",
"maxLength": 100,
"inlineEditable": true
},
{
"key": "text",
"type": "text",
"label": "Small print",
"maxLength": 160,
"inlineEditable": true
},
{
"key": "code",
"type": "text",
"label": "Discount code",
"help": "Shown in a box shoppers can copy by hand.",
"maxLength": 32
},
{
"key": "linkLabel",
"type": "text",
"label": "Link label",
"default": "Shop the sale",
"maxLength": 40
},
{
"key": "link",
"type": "link",
"label": "Link",
"default": "/collections/all"
},
{
"key": "showBadge",
"type": "boolean",
"label": "Show a Sale badge",
"default": true
},
{
"key": "background",
"type": "color",
"label": "Background",
"default": "#111111"
},
{
"key": "foreground",
"type": "color",
"label": "Text colour",
"default": "#ffffff"
}
],
"elements": [
{
"key": "cta",
"label": "Link",
"type": "link",
"setting": "linkLabel"
}
],
"maxInstancesPerPage": 3,
"pages": [
"home",
"catalog",
"product",
"page"
],
"slots": [
"header.top",
"footer.top"
]
},
"template": "<div class=\"promo\" data-fh-group=\"band\">\n {% if settings.showBadge %}<span class=\"promo__badge\">Sale</span>{% endif %}\n <strong data-fh-el=\"heading\">{{ settings.heading }}</strong>\n {% if settings.text %}<span data-fh-el=\"text\">{{ settings.text }}</span>{% endif %}\n {% if settings.code %}<code data-fh-el=\"code\">{{ settings.code | upper }}</code>{% endif %}\n {% if settings.link %}<a data-fh-el=\"cta\" href=\"{{ settings.link }}\">{{ settings.linkLabel }}</a>{% endif %}\n</div>\n",
"styles": ":scope{background:var(--fh-s-background);color:var(--fh-s-foreground)}\n.promo{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;justify-content:center;padding:.75rem 1rem;font-size:.95rem}\n.promo a{color:inherit;text-decoration:underline}\n.promo code{border:1px dashed currentColor;padding:.1rem .4rem;border-radius:4px}\n.promo__badge{text-transform:uppercase;font-size:.75rem;letter-spacing:.08em;border:1px solid currentColor;padding:.1rem .5rem;border-radius:999px}\n",
"presets": [
{
"id": "announcement",
"name": "Announcement",
"settings": {
"showBadge": false,
"background": "#f4f1ea",
"foreground": "#1a1a1a"
}
},
{
"id": "code",
"name": "With a code",
"settings": {
"heading": "20% off everything",
"code": "WINTER20",
"text": "Until Sunday."
}
}
]
}
Questions and answers, with a list setting
{
"schemaVersion": 1,
"id": "faq-accordion",
"name": "Questions and answers",
"description": "Questions shoppers ask, each opening to its answer.",
"category": "text",
"version": "1.0.0",
"origin": "store",
"schema": {
"settings": [
{
"key": "heading",
"type": "text",
"label": "Heading",
"default": "Questions",
"maxLength": 120,
"inlineEditable": true
},
{
"key": "questions",
"type": "list",
"label": "Questions",
"itemLabel": "Question",
"minItems": 1,
"maxItems": 24,
"fields": [
{
"key": "question",
"type": "text",
"label": "Question",
"maxLength": 200,
"required": true,
"inlineEditable": true
},
{
"key": "answer",
"type": "richtext",
"label": "Answer",
"maxLength": 2000,
"inlineEditable": true
}
],
"default": [
{
"question": "How long does delivery take?",
"answer": "<p>Two to four working days in the UK.</p>"
},
{
"question": "Can I return something?",
"answer": "<p>Yes, within 30 days. See our <a href=\"/pages/returns\">returns page</a>.</p>"
}
]
},
{
"key": "openFirst",
"type": "boolean",
"label": "Open the first answer",
"default": false
}
],
"maxInstancesPerPage": 2,
"pages": [
"home",
"product",
"page"
]
},
"template": "<div class=\"faq\">\n <h2 data-fh-el=\"heading\">{{ settings.heading }}</h2>\n {% for item in settings.questions %}\n <details class=\"faq__item\"{% if loop.first and settings.openFirst %} open{% endif %}>\n <summary>{{ item.question }}</summary>\n <div class=\"faq__answer\">{{ item.answer | raw }}</div>\n </details>\n {% endfor %}\n</div>\n",
"styles": ".faq{max-width:760px;margin-inline:auto;padding:2rem 1rem}\n.faq__item{border-bottom:1px solid rgba(127,127,127,.3);padding:1rem 0}\n.faq__item summary{cursor:pointer;font-weight:600;list-style:none}\n.faq__item[open] summary{margin-bottom:.5rem}\n",
"presets": []
}
10. Versions, drafts and copies
Every save is a version, and versions never change. Saving a section again writes a new version. Leave version out and save_section chooses it from what changed:
| What changed | The new version |
|---|---|
| A setting removed or its type changed, a select option or list field removed, a kind of page or slot no longer allowed, or fewer instances allowed per page | A new major version (2.0.0) |
| A setting, option, list field, preset, kind of page or slot added | A new minor version (1.1.0) |
| The template, the styles, labels, help text or defaults | A patch (1.0.1) |
If you give a version yourself, it has to be at least that step; a smaller one is refused with the version to use. The answer to every save says the version, the step and what changed.
Saving changes the draft, publishing changes the storefront. A new version is what the draft renders at once; the live storefront keeps the version it was published with until publish_storefront. Restoring an earlier storefront version (restore_storefront_version) brings back the section versions it used as well.
Nothing on a page breaks when a setting goes. An instance written against an older version keeps rendering: a value for a setting the new version dropped is ignored, and a value that no longer fits shows the default instead.
Rollback is a new version. rollback_section { id, version } saves that version's content again as the next version, so nothing is lost and you can roll forward the same way. The 20 most recent versions of each section are kept.
Make it mine. fork_section { id, newId } makes your own copy of an add-on's section (or of one of yours) under a new id, starting at 1.0.0 and remembering where it came from. The copy is yours to change. Pass repoint: true to move every instance in the draft onto the copy; otherwise they stay on the original. Built-in sections cannot be copied yet.
Deleting. delete_section is refused while the draft or the live storefront uses the section, and the refusal says where. Remove those instances first, or pass force: true: each instance then stays where it is and shows nothing, the builder marks it, and saving a section with the same id brings it back.
11. Sections from add-ons
An add-on can ship up to six sections. They are named after the add-on (faq--accordion, promo--band), checked exactly as save_section checks yours, and placeable as soon as the add-on is installed. They are read-only in your store: to change one, fork_section it. They need no grant, because a section only reads what every template reads.
Two add-ons in the catalogue ship sections today:
| Add-on | Sections |
|---|---|
| Questions and answers | faq--accordion: a heading and questions that open to their answers |
| Promo bands | promo--band: an offer band with a code and a link, for any page and the header or footer; promo--stats: two to six big numbers with a line each |
When you uninstall an add-on, every instance of its sections stays where you put it and shows nothing; the builder marks those places. Install the add-on again and they come back exactly as they were, settings and all.
If you write an app yourself, its manifest takes sections: an array of whole packages, each with origin: "app", app set to the app's name and an id of <app>--<name>. describe_apps and describe_sections have the details.
12. Refusals
Every refusal is one sentence per problem, naming the section and then, as they apply, the page or slot, the instance, the preset, the setting, the list item, the field, the template line or the styles. Each comes with its code:
| Code | Means |
|---|---|
section_invalid | The section package does not match the section format. |
section_id_invalid | A section id is 2 to 40 lower-case letters, digits and single hyphens, starting with a letter. |
section_id_reserved | That id belongs to a built-in section or a layout keyword. |
section_id_taken | Your store already has a section with that id. |
section_not_found | No section with that id is in your store. |
section_read_only | Built-in, add-on and catalogue sections cannot be edited; make your own copy first. |
section_in_use | The section is still placed on a page; remove its instances first. |
section_limit | Your store already holds the most sections it can. |
fork_not_allowed | This section cannot be copied. |
version_invalid | A version looks like 1.4.0. |
version_not_newer | A new version has to be higher than the current one. |
version_bump_required | The change needs a bigger version step (for example a removed setting needs a new major version). |
setting_invalid | A setting value does not fit its definition. |
setting_unknown | The section does not declare that setting. |
setting_definition_invalid | A setting definition is not valid. |
setting_type_reserved | That setting type is only available to built-in sections. |
setting_limit | A section declares at most 40 settings. |
group_unknown | A setting names a group the section does not declare. |
preset_invalid | A preset holds a value its setting does not accept. |
preset_limit | A section has at most 12 presets. |
template_too_large | A section template is at most 32 KB. |
template_syntax | The template has a syntax error. |
template_unknown_tag | The template uses a tag sections do not have. |
template_unknown_object | The template names something sections cannot read. |
template_unknown_setting | The template reads a setting the section does not declare. |
template_unknown_filter | The template uses a filter sections do not have. |
template_forbidden | The template contains something sections never allow (scripts, event handlers, inline styles, other files). |
template_nesting | Tags are nested more than 8 deep. |
template_loop_limit | A loop asks for more than 50 items. |
behavior_invalid | A section behaviour must be a self-contained default mount(api) function using only the worker bridge. |
behavior_too_large | A section behaviour is at most 32 KB. |
template_expression_limit | The template has too many expressions, or one that is too long. |
template_missing | The section has no template; only built-in sections render without one. |
element_unknown | The template marks an element (data-fh-el) that is neither a setting of the section nor declared in schema.elements. |
element_invalid | An element declaration or a data-fh-el or data-fh-group mark is not valid. |
elements_unmarked | The section prints text, image or link settings but marks no element with data-fh-el, so the builder cannot select anything inside it. |
element_unmarked | A text, image or link setting is printed only outside elements marked with data-fh-el; mark the element that prints it, or list the setting in schema.fieldOnly. |
styles_too_large | Section styles are at most 16 KB. |
styles_template_syntax | Styles cannot contain template expressions; read settings through the section's CSS variables. |
artwork_invalid | A drawing in the section's artwork has a name or markup sections do not accept; the message says what to remove. |
artwork_limit | A section carries at most 32 drawings, each at most 32 KB and 160 KB together. |
instance_invalid | The instance does not match the instance format. |
instance_id_invalid | An instance id is 1 to 32 lower-case letters, digits and hyphens, starting with a letter or digit. |
instance_id_reserved | That instance id is a built-in section id or a layout keyword. |
instance_id_taken | Another instance already uses that id. |
instance_not_found | No instance with that id is on the storefront. |
instance_limit | The page already holds as many instances of this section as it allows. |
page_instance_limit | A page holds at most 60 section instances. |
page_not_allowed | The section cannot be placed on that page. |
slot_not_allowed | The section cannot be placed in that slot. |
home_empty | The home page has to show at least one visible section or block. |
collection_required | The older name of home_empty: the home page has to show at least one visible section or block. |
main_required | Every page other than home has its own body exactly once, and home has none. |
render_timeout | The section took too long to render and was left out of the page. |
render_too_large | The section produced too much markup and was left out of the page. |
render_loop_limit | The section looped over too many items and was left out of the page. |
13. Agent tools
| Tool | Scope | Does |
|---|---|---|
describe_sections | storefront:read | Everything needed to write a package, as data: the package fields, the setting types, the template language, the limits and the rules. parts fetches how to make a section editable in the builder, the JSON schema, the refusal codes, a complete example, the widgets with recipes, the icon names, every example with its output, or the font list |
list_sections | storefront:read | The built-in sections and the store's own, add-on and catalogue sections, with versions, whether you may change them and where each is placed |
get_section | storefront:read | One whole package, its kept versions and where it is placed; version for an older one |
save_section | storefront:write | Creates a section or saves a new version, checked whole, including the editable elements rule (§5); answers the version, the step, what changed and what the merchant can select (editable, builder, notes) |
preview_section | storefront:read | Renders one instance of a saved section, or of a package not saved yet, with the settings you give; changes nothing |
fork_section | storefront:write | Your own copy of a section under a new id, optionally moving the draft's instances onto it |
list_section_versions | storefront:read | The kept versions, newest first, and which one the draft and the live storefront render |
rollback_section | storefront:write | Saves an earlier version's content as a new version |
delete_section | storefront:write | Deletes a section; refused while it is placed, unless force |
Placing, changing and removing instances is apply_storefront_commands on the draft, and publish_storefront (storefront:publish) is still the only thing that reaches a shopper. Every tool has a matching REST route: /api/sections and /api/sections/preview, in the API reference.