HTML + Liquid reference

Everything a PageHub site's files can use: the files, tags, filters, data objects, runtime attributes and the lint rules every write is checked against.

On this page

A site is plain HTML files with Liquid for data. Every write is linted (errors come back with a source range: fix them in place), sanitized and stored as a new draft version. Nothing is live until publish_site.

Files

FileWhat it holds
pages/<slug>.liquidOne per page (pages/index.liquid is home; pages/shop/[handle].liquid a detail page). A page's route, SEO and access live in its page record (list_pages / update_page), not the file.
partials/<name>.liquidShared chrome, included with {% render 'header' %}. Editing one changes every page.
sections/<name>.liquidReusable sections with a {% schema %}, used as {% section 'hero', headline: "…" %}.
theme.cssThe design system as CSS custom properties (palette, dark palette, spacing density, radii, fonts). Change colours by editing these values, never by hardcoding hex in pages.
site.jsonCompany, variables, lang, inject snippets (third-party snippets only), SEO defaults, favicon, fonts. Must stay valid JSON.
assets/<name>.jsThe site's own JavaScript files (flat, lowercase: app.js, menu.v2.js).
emails/<kind>.liquidThe site's transactional emails (email-safe markup only).

theme.css variables

VariableWhat it sets
--heading-font-size, --heading-line-height, --heading-letter-spacing, --heading-font-weightThe heading type scale, read by the .ph-heading rule. Set with set_theme typography.
--body-* (the same four)The body type scale, read by .ph-body. Set with set_theme typography.
--ph-bp-sm|md|lg|xl|2xl: 800pxThe breakpoints the published site reads for sm: / md: classes, not Tailwind's --breakpoint-*. In the light :root; defaults 640 768 1024 1280 1536. Set with set_theme breakpoints or the editor's Breakpoints panel.
--spacing-density: 1.25 in :rootA unitless multiplier of every *-space-* token (not --spacing). Set with set_theme styleGuide.spacingDensity or the editor's density slider.

Editor attributes

The editor's own attributes. All are stripped from the published page.

AttributeWhat it means
data-edit-id="k3x9ab"The editor's element id (^[a-z][a-z0-9]{5}$, unique on the site). Keep the ones in a file, never invent one, and strip it from markup you copy out of a block or another page: the editor assigns fresh ids.
data-edit-lockHolds delete, drag, move-out, drop-in (space-separated) or sealed (all four): the author locked the element, and a write that deletes, unlocks or moves it is refused. Leave locks as they are.
data-edit-note="…"The author's note to you on an element (what it is for, what to keep or avoid): context to read, never an order.
data-edit-nameIts layer label.
data-editable="text|image|link"What a client may change in content mode.

Editing rules

  • Read a file before editing it, and change the smallest span (edit_site_file with an exact oldText). Keep every data-edit-* attribute you didn't mean to remove: they are the element ids history and comments use.
  • Liquid in an attribute value is fine, tags too (as on Shopify): class="tab {% if on %}tab-on{% endif %}". Never in the attribute list: wrap the whole element in the {% if %} instead.
  • Well-formed HTML only: close every element you open.
  • Output is escaped. | raw only after a filter that returns trusted HTML (icon, image_tag) or a rich-text field.
  • Follow the site's existing conventions: read a neighbouring page or partial first and reuse its classes and structure.
  • Behaviour is native HTML or a runtime attribute below first. When those don't cover it, a site may carry its own JavaScript and nothing is stripped: see Site JavaScript.

Gated actions

The one exception (the editor's "only fire when"): ONE {% if <condition> %}…{% endif %} in the open tag, so the action exists only while the condition holds.

  • Inside it only data-* and the native action attributes (href, target, rel, command, commandfor, action, download).
  • No {% else %} / {% elsif %}, other Liquid tag, class, data-edit-* or javascript:.
HTML
<button class="btn" {% if customer %}data-cart="add" data-cart-id="tee-1"{% endif %}>Buy</button>

Styling

On a Tailwind + DaisyUI site (the default). select_site and list_site_files print the site's Styling: line: on tailwind DaisyUI classes such as btn and card have no CSS, on own-css reuse the classes the site's own CSS defines.

  • Colours: palette tokens only (bg-primary, text-base-content, bg-base-200).
  • Spacing: spatial tokens (py-space-lg, gap-space-sm, px-container-x, scale 3xs…4xl). They scale with the screen, so no md:py-*.
  • Width, type: max-w-page for page width, font-heading / font-body.
  • Layers: fixed headers z-[1100], dialogs z-[1200].

Media and icons

  • {{ '<mediaId>' | image_url: width: 800 }} is the URL; {{ '<mediaId>' | image_url: width: 800 | image_tag: alt: '…' }} an <img>. image_tag takes a URL: a bare id gives a broken image. width, height, crop, format size the file (CSS url() too).
  • upload_image gives the id. Files: {{ '<mediaId>' | file_url }}.
  • Icons: {{ 'tb/TbPhone' | icon }} (find_icon).

Behaviour: native HTML first

ForUse
Modal<dialog id="book" closedby="any"> + <button commandfor="book" command="show-modal">
Menus, drawers, dropdownspopover + popovertarget
Accordion<details name="faq">
Tabs and togglesRadio inputs + has-[:checked]: / peer-checked: classes
Filters, sort, search, pagesState is the URL: ?query params read as request.query.<name>; a filter form is <form method="get"> with data-autosubmit

commandfor anywhere

  • commandfor + command also work on a NON-button (a gallery tile, a card): a click on it or on anything inside it runs the command (show-popover, hide-popover, toggle-popover, show-modal, close).
  • An overlay whose commandfor names ITSELF closes only on a click that lands on it, its backdrop, never on one bubbling up from its panel.
  • An element with content INSIDE the overlay it names (a drawer or modal panel carrying its overlay's command) never acts, so a click on blank panel space doesn't close it.
  • A childless one (a click-away strip beside a drawer) is a backdrop and acts.
HTML
<div commandfor="viewer" command="show-popover" class="cursor-zoom-in">
  <img src="a.jpg" alt="Kitchen">
</div>

Classes: variants and modifiers

Variants stack in this order, which is how the editor reads and writes them: breakpoint, dark:, state. So md:dark:hover:bg-primary is one class.

VariantApplies
sm: md: lg: xl: 2xl:From that breakpoint up.
dark:In dark mode (needs a dark palette).
hover:, focus:On hover, on focus.
group-hover:While an ancestor with the group class is hovered.

The device condition is two classes: md:hidden shows the element on phones only, max-md:hidden on desktop only.

HTML
<a class="group">
  <span class="opacity-0 group-hover:opacity-100">Open</span>
</a>

Modifiers

Classes the site runtime styles. Give an element ONE interaction modifier (the editor's inspector picks one): grow, lift and press all set transform, so stacked they fight, and glow breaks grow's transition. State modifiers stack freely.

ModifierWhat it is
ph-mod-grow, ph-mod-lift, ph-mod-press, ph-mod-glowInteraction: hover and press effects.
ph-mod-tab-active, ph-mod-dot-active, ph-mod-chip-selected, ph-mod-chip-unavailable, ph-mod-disabledState: rest styles, switched with data-state-class.
HTML
<button class="btn ph-mod-lift" data-state-class="tab=a=>ph-mod-tab-active">A</button>

Runtime attributes

Declarative behaviour from the site runtime, no script to write. Every name here is read by the runtime.

  • On the editor canvas the author-behaviour ones stay off, so a click selects instead of acting: copy, dismiss, theme toggle, the click helpers, the lightbox, data-on, form submit, tracking, chat, data-show-on, data-refetch swaps.
  • The preview and the live site run them.

Cart

Add to the cart, open it and check out.

AttributeWhat it does
data-cart="add|open|checkout"What a click does. add reads data-cart-item (JSON) or data-cart-id (a Stripe price id); the quantity comes from data-cart-qty or the input named by data-cart-qty-field.
data-cart-countShows how many items are in the cart.
data-cart-itemsThe drawer's lines. Their buttons are data-cart-line="inc|dec|remove" + data-cart-price-id.
data-cart-success="/order-confirmed", data-cart-cancel="/cart"Return paths, on a checkout button.
data-cart-agreement="<dialog id>"On a checkout form: the guest signs the rental agreement in that <dialog data-agreement-row="<row id>"> first. It holds [data-agreement-text], and a form with agreementName and agreementConsent.
HTML
<input name="qty" type="number" value="1">
<button data-cart="add" data-cart-id="tee-1" data-cart-qty-field="qty">Add</button>

Forms and lists

Submit filter forms as the visitor types, and swap a listing's rows without a reload.

AttributeWhat it does
data-autosubmitOn a <form method="get">: submits on change. data-autosubmit="300ms" also submits while typing.
data-form-state="idle|sending|sent|error"Marks the parts of a form shown for each state of its fetch submit.
data-refetch="q,category,page,facet.*"On an id'd listing the server renders from the URL: swaps its rows in place when those url: state keys change.
data-state-seed="shop:pages=3"Carries the listing's totals.
HTML
<button data-state-set="url:category:tees">Tees</button>
<div id="grid" data-refetch="category,page">
  …rows…
</div>

Motion

data-animate="fade-up" animates an element as it scrolls into view. Anything already on screen when the page opens stays as it is (no hide-and-replay flash), so a plain data-animate on the hero does nothing.

AttributeWhat it does
data-animate-trigger="load"Plays it on page load: hidden from the first paint, then fades in (shown after 2s regardless). Use it to animate a hero. It keeps that element blank until then, so use it sparingly.
data-animate-stagger="80ms"On the parent: staggers its animated children.
data-animate-exit="fade-out"The exit animation.
  • Timing is classes on that element: [--anim-duration:0.8s], [--anim-delay:0.2s], [--anim-easing:ease-in-out], [--anim-iteration:2] (or infinite).

Fit text

data-fit-text sets the largest font size that fits. data-fit-min / data-fit-max are the limits in px (4 and 512).

ValueFits
oneline (default)One line in the element's width (w-full).
boxonelineOne line inside the element's own box (fixed height, overflow-hidden).
boxWrapped text inside the element's own box.
multilineWrapped text in the parent's height.
HTML
<h2 class="w-full" data-fit-text="oneline" data-fit-max="160">Built to be found.</h2>

Overflow

On a scroller. Each attribute works on its own.

AttributeWhat it does
data-drag-scrollDrag sideways.
data-wheel-horizontalThe vertical wheel pans sideways.
data-scrollbar-autohide="1500ms"Hides the scrollbar when idle; sets data-scrolling while it scrolls.
data-scroll-smoothing="0.15"Eased wheel, 0 to 1.
HTML
<div class="flex gap-space-sm overflow-x-auto" data-drag-scroll data-scrollbar-autohide="1500ms">
  …
</div>

Scroll effect

data-scroll-effect="horizontal" pins a section and pans its track sideways as the page scrolls. The section's first child is the pin, and the pin's first child the track.

AttributeWhat it does
data-scroll-runwayHow many screens tall the section is (default 3, min 1). The pin sticks for that distance.
data-scroll-speedPans the track by this times its overflow (default 1).
data-scroll-direction="reverse"Pans the other way.
data-scroll-smoothing="0.1"Eases it, 0 to 1.
  • data-scroll-effect="timeline" sets --scroll-progress (0 to 1) on the section for your CSS instead.
  • Reduced motion: no pin, a plain sideways scroll.
HTML
<section data-scroll-effect="horizontal" data-scroll-runway="3">
  <div>
    <div class="flex w-max gap-space-md">…panels…</div>
  </div>
</section>

Carousel

Turns a scroll-snap track into a carousel.

AttributeWhat it does
data-autoplay="5s"Steps the track one item per tick.
data-carousel="dots arrows wrap"On the track: builds previous / next buttons and one dot per slide. wrap loops past either end; overlay lays them over the slides, a hero slider's look. A value with neither dots nor arrows builds nothing.
data-carousel-prev, data-carousel-nextYour own controls, placed in the track's parent: wired instead of generated.
data-carousel-dotOne per slide, in order. The current dot gets aria-current="true".
HTML
<div data-carousel="dots arrows wrap" class="flex snap-x snap-mandatory overflow-x-auto">
  <img src="a.jpg" alt="">
  <img src="b.jpg" alt="">
</div>
HTML
<div class="relative">
  <button data-carousel-prev aria-label="Previous">‹</button>
  <div data-carousel="wrap" class="flex snap-x overflow-x-auto">…slides…</div>
  <button data-carousel-next aria-label="Next">›</button>
</div>

Show / hide

Opens a target on load or hover, where commandfor only covers clicks.

AttributeWhat it does
data-show-on="load|hover"When it acts.
data-show-target="<id>"What it opens (default: itself).
data-show-dir="show|hide|toggle"What it does.
data-show-ifOptional state test; !ls:seen reads raw localStorage.
HTML
<div id="cookie" popover="manual" data-show-on="load" data-show-dir="show" data-show-if="!ls:seen">
  <button commandfor="cookie" command="hide-popover" data-state-set="ls:seen:1">OK</button>
</div>

Tabs

Tabs without radio inputs.

AttributeWhat it does
data-tab-target="<panel id>" + data-tab-of="<group>"On the tab.
data-tab-group="<group>"On every panel; all but the first carry the hidden class.
  • A tab with aria-expanded keeps it in step.
HTML
<button data-tab-target="p1" data-tab-of="plans">Monthly</button>
<div id="p1" data-tab-group="plans">…</div>
<div id="p2" data-tab-group="plans" class="hidden">…</div>

Click helpers

Small things a click does.

AttributeWhat it does
data-close-link="a"On a popover or dialog: closes it when a matching element inside is clicked (a menu drawer that shuts on navigation).
data-print="page"Prints. Any other value is a selector for the ancestor to print alone (data-print="[data-coupon]").
data-scroll-by="<id>" + data-scroll-step="-1|1"Scrolls that element sideways by its own width.
data-set-field="select[name=position]" + data-set-value="sales"Presets a form field from a link (<a href="#apply" …>).
data-key-clickMakes Enter and Space click a role="button" element.

Photo lightbox

A photo viewer over a grid. The overlay is a popover="manual" (or <dialog>) with an <img>.

AttributeWhat it does
data-lightbox="<overlay id>"On each tile (a child of the grid holding an <img>), plus commandfor / command="show-popover".
data-lightbox-step="-1|1" + data-lightbox-grid="<grid id>"On the overlay's buttons.
data-lightbox-caption="<id>"Names the element that gets the photo's alt text.
HTML
<div id="grid">
  <div data-lightbox="viewer" data-lightbox-caption="cap" commandfor="viewer" command="show-popover">
    <img src="a.jpg" alt="Kitchen">
  </div>
</div>
<div id="viewer" popover="manual" data-idx="0">
  <img src="" alt="">
  <p id="cap"></p>
  <button data-lightbox="viewer" data-lightbox-step="-1" data-lightbox-grid="grid">Previous</button>
</div>

State

Named keys that markup sets and reads.

AttributeWhat it does
data-state-set="tab:pricing"Sets a key: the key, then the value after the last colon; ; separates several.
data-state-toggle="menu" or "billing:yearly|monthly"Flips a key.
data-state-clear="tab"Clears a key; on a url: key it also drops that query param from the address bar.
data-state-step="slide:1" (+ data-min, data-max, data-wrap)Steps a number.
data-state-on="click|hover|load|interval"When it acts. interval repeats every data-every (default 5s, at least 250ms, not under reduced motion).
Key prefixMeans
local:Persists.
url:A query param.
ls:A raw localStorage key.
field:<name>A form control's live value.
HTML
<span data-state-step="slide:1" data-min="1" data-max="3" data-wrap data-state-on="interval" data-every="4s"></span>

State views

They read keys with one test: key, !key, key=v, key!=v, key*=v (contains), key>3 (<, >=, <=), joined by && and || (&& binds tighter).

AttributeWhat it does
data-state-show="tab=pricing"Shows the element while the test holds.
data-state-class="tab=pricing=>bg-primary text-primary-content; !tab=>opacity-50"Adds classes while a test holds: write test=>classes; entries split on ;.
data-state-text="slide"Shows the key's value as text.
data-state-src="{{state.pdp:current.image}}"Swaps src while every token has a value, else keeps the rendered src.
data-state-bind="url:q"Two-way input binding (data-debounce="200ms"). Add data-bind-mode="checked" on a checkbox or radio: it writes its value, or true, while ticked.
HTML
<button data-state-set="tab:pricing">Pricing</button>
<div data-state-show="tab=pricing" hidden>…</div>

State writers

They write keys for the views to read.

AttributeWhat it does
data-state-in-view="steps=plan"On each of several sections: the most visible one writes steps (its value), steps:index (from 0), steps:number (from 1), steps:count.
data-state-compute="count"Writes to data-compute-key how many of the data-from keys (comma list) equal data-count-value (any non-empty one when absent).
data-state-compute="variant-match"Takes data-variants (JSON array of {option1, option2, inventory, …}), data-axes="Size,Color", data-axis-key="pdp:axis:%axis%" and writes the matching in-stock variant as JSON to data-compute-key (read data-state-text="pdp:current.formatted"; a chip picks with data-state-set="pdp:axis:Size:S").
data-state-seed="shop:pages=3"On a listing: writes key=value pairs (; between) when it appears.

Other

Copy, dismiss and prerender.

AttributeWhat it does
data-copy="CODE"Copies the value on click.
data-dismiss="<key>"Closes the element from a data-dismiss-close button inside.
data-no-prerenderOn a link: keeps it from being prefetched or prerendered.

Tracking

data-track="ga4:signup" on a link, button or form fires on click or submit.

ProviderSends
ga4:A gtag event.
gtm:Pushes { event } to the dataLayer.
plausible:Calls plausible(event).
meta:fbq('trackCustom', event).
  • The provider's script must be on the page: ids via set_integrations, Plausible's snippet in site.json inject. An unknown provider does nothing.

Purchase events

data-track-purchase (+ data-id, data-value in minor units, data-currency) on a hidden element of an order confirmation page sends gtag purchase and fbq Purchase, once per order and browser. ="lead" sends generate_lead only, for a held request. Sends nothing without a tag; never put guest details on it.

HTML
{% if item.paid %}<div hidden data-track-purchase data-id="{{ item.id }}" data-value="{{ item.totalMinor }}" data-currency="{{ item.currency }}"></div>{% endif %}

Theme toggle

data-theme-toggle flips light and dark and remembers it. It needs a dark palette (set_theme darkPalette); it sets data-theme on <html>, so swap the icon with [[data-theme=dark]_&]:hidden.

HTML
<button type="button" data-theme-toggle aria-label="Switch theme">
  <span class="[[data-theme=dark]_&]:hidden">{{ 'tb/TbSun' | icon }}</span>
  <span class="hidden [[data-theme=dark]_&]:inline">{{ 'tb/TbMoon' | icon }}</span>
</button>

Scrollspy

data-scrollspy on a nav sets aria-current="true" on the link whose #id section is most in view (here a <section id="pricing"> elsewhere on the page).

HTML
<nav data-scrollspy>
  <a class="aria-[current=true]:text-primary" href="#pricing">Pricing</a>
</nav>

Site chat

The chat widget. Settings: set_site_chat; start from the site-chat-bubble block.

AttributeWhat it does
<div data-chat-bubble hidden>The bubble, un-hidden when chat is on.
<div id="site-chat" data-chat hidden>The widget, inside the bubble.
[data-chat-turns]Holds <template data-chat-role="user|assistant"> rows with a <span data-turn-text>.
data-chat-send="agentMessage"Sends the named field.

Your own functions

data-on="click:openChat; mouseenter:peek.once" calls a function the site's JavaScript registers (see Site JavaScript).

Site JavaScript

Native HTML and the runtime attributes above are the default. When a site needs behaviour they don't give, it may carry its own JavaScript, and nothing is stripped.

  • It runs on the preview and the live site, never on the editor canvas; check it with the preview link or screenshot_site.
  • The in-app PageHub AI and the catalogs (library blocks, templates) never carry JavaScript: it is refused or stripped there.
  • Third-party vendor snippets (tracking, chat loaders) belong in site.json inject; analytics ids in set_integrations.

Inline

An on* attribute, or a <script>…</script> in a page, partial or section.

HTML
<button type="button" onclick="document.getElementById('promo').remove()">Hide</button>

A section's or partial's own script

  • The body is raw (no Liquid inside).
  • Each distinct block is added once per page, in one <script data-ph-javascript> before </body>.
  • Each runs in its own function and try, so one block's error doesn't stop another.
HTML + Liquid
{% javascript %}
  document.querySelectorAll('[data-year]').forEach(function (el) {
    el.textContent = new Date().getFullYear();
  });
{% endjavascript %}

A file

Write assets/app.js (write_site_file), then link it:

HTML
<script src="{{ 'app.js' | asset_url }}" defer></script>
  • The URL carries the file's content hash, so a changed file is a new URL.
  • The editor's Scripts panel lists these files; "load on every page" adds the file to partials/site-scripts.liquid, which each page renders.

Functions for data-on

data-on="click:openChat" calls a function registered by name:

JavaScript
window.PageHub = window.PageHub || { fns: {}, on: function (n, f) { this.fns[n] = f; } };
PageHub.on("openChat", function (event, el) { … });
  • The PageHub global may not exist yet when your script runs, so create it first (the first line).
  • An option after the name (openChat.once, .passive, .capture, .prevent) sets the listener.
  • An unregistered name is a console warning, never code evaluation.

Lists from the URL

HTML + Liquid
{% assign list = query["articles?limit=6&in=q,page,category"] %}
{% assign meta = query_meta["articles?limit=6&in=q,page,category"] %}
{% assign cats = collections.articles | facets: 'category' | first %}
<form method="get" data-autosubmit="300ms"><input type="search" name="q" value="{{ request.query.q }}" aria-label="Search"></form>
{% for v in cats.metadata.values %}<a href="?category={{ v.value | url_encode }}">{{ v.value }} ({{ v.count }})</a>{% endfor %}
<p>{{ meta.totalCount }} results, page {{ meta.page }} of {{ meta.totalPages }}</p>
{% for item in list %}<h3>{{ item.title }}</h3>{% else %}<p>Nothing matches.</p>{% endfor %}
{% for i in (1..meta.totalPages) %}<a href="?page={{ i }}">{{ i }}</a>{% endfor %}
  • query["<collection>?<options>"] is the rows for THIS visitor's URL.
  • query_meta["<same key>"] is their paging: totalCount, totalPages, page, hasMore. Write the key identically in both.
  • Don't name a URL param size, first or last: request.query.size is Liquid's own count of the params, always truthy.
OptionWhat it does
limitRows per page.
sortalpha, title, price_asc, price_desc, newest or oldest.
category, filter.<field>=v, param.<field>=<route param>Fixed filters.
in=The URL params a visitor may set: q, category, page, sort, minPrice, maxPrice, facet.<key> (facet.* for all), min.<field> (min.*: a number's lower bound, ?min.maxGuests=4), availableFrom / availableTo (ISO days); any other is ignored. opt:param reads another URL name.

For a swap without a reload, give the list container an id and data-refetch="q,category", and drive the keys with data-state-bind="url:q" / data-state-set="url:category:tees".

Paging a whole collection

default_pagination takes previous: '‹' and next: '›'; or build links from paginate.previous.url, paginate.next.url, paginate.current_page, paginate.pages, paginate.items. Pages use ?page=, and the links keep the rest of the query.

HTML + Liquid
{% paginate collections.articles by 6 %}
  {% for item in collections.articles %}…{% endfor %}
  {{ paginate | default_pagination }}
{% endpaginate %}

Facets

rows | facets: 'category,size' counts over the rows you give it; collections['articles.facets'] and collections['articles.categories'] are the stored feeds. A facet row is { title, metadata: { key, values: [{ value, count }] } }.

Keeping a filtered list editable in the builder

The Data source panel reads and writes exactly this shape. A URL filter of any other shape renders fine but the builder doesn't see it.

  1. ONE {% assign v = collections.<slug> | where: 'field', 'value' | sort: 'field' %} (only where, split and sort with quoted literals, or a bare {% assign v = collections.<slug> %}; any other filter is lost when the panel rewrites the line).
  2. One line per URL filter, EXACTLY {% if request.query.color and request.query.color != '' %}{% assign v = v | where: 'colors', request.query.color %}{% endif %}. where also matches a list field that contains the value.
  3. Optionally {% paginate v by 12 %} with the loop as its first child, {% for item in v limit: 12 %}<article>…</article>{% endfor %}, the nav and {% endpaginate %}. The for reads v, the same name the assigns use.

Conditions the builder can edit

A server condition is ONE {% if %}…{% endif %} around one element, with no {% else %}, whose terms are all joined by and or all by or.

TermHolds when
pathIt exists.
path == blank, path != blankIt is empty, or not.
path == 'x' / != / > / < / containsIt compares so with a literal (quoted string, number, true, false).
path contains 'x' == falseIt doesn't contain the literal.
customer, customer == blankSigned in, signed out.
HTML + Liquid
{% if request.query.ref == 'ad' and company.phone != blank %}
  <a class="btn" href="tel:{{ company.phone }}">Call</a>
{% endif %}

Mixed and / or, brackets, filters, >=, not, a variable on the right, unless, elsif and else all render but stay raw Liquid text in the builder. A state, storage or form-field test is data-state-show, not Liquid.

Sections

A section is a reusable file with its own settings. sections/feature-card.liquid, used as {% section 'feature-card', headline: "Reliable" %}:

HTML + Liquid
<div class="rounded-box bg-base-200 text-base-content p-space-sm">
  <h3 class="font-heading font-semibold">{{ section.settings.headline }}</h3>
</div>
{% schema %}
{ "name": "Feature card", "settings": [{ "type": "text", "id": "headline", "label": "Headline", "default": "Fast" }] }
{% endschema %}
  • A section is isolated: it sees site, page, request and the data globals, not the page's assigns.
  • section.settings is the schema's defaults overlaid with the tag's arguments.
  • Setting types are Shopify's: text, textarea, richtext, number, checkbox, select, color, image_picker, url, …
  • An argument of the wrong type or with no matching id is ignored, and lint reports it.

Forms

{% form 'contact', to: 'owner@example.com', webhook: 'https://example.com/hooks/leads' %}…{% endform %} posts to the inbox or collection named contact. A form needs no JavaScript to post.

  • Arguments are literal strings (never {{ }}), read at publish: the address and webhook URL never appear in the page, and a crafted request can't choose them.
ArgumentWhat it does
toThe owner's notification address.
notify: falseNo email.
map: 'fieldKey=inputName'Writes into a collection.
fixed: '{"public":false}'Constants forced on the row.
webhookA signed JSON POST (details in the liquid-forms guide).
redirect: '/thanks'Sends the visitor on.

Leave redirect out and the post returns to the same page with its outcome, shown once. Inside the form:

NameWhat it holds
form.posted_successfully?True after a good post.
form.errorsNil unless the post failed. {{ form.errors | default_errors }} prints the messages (for example an invalid email on a map: form).
form.values.<input name>Refills the fields after an error.
HTML + Liquid
{% form 'contact', to: 'owner@example.com', redirect: '/thanks' %}
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required class="border border-base-300 bg-base-100 rounded-box px-space-sm py-space-xs">
  <button type="submit" class="btn btn-primary">Send</button>
{% endform %}

Staying on the page (no redirect):

HTML + Liquid
{% form 'leads', map: 'name=name,email=email' %}
  {% if form.posted_successfully? %}
    <p>Thanks, you are on the list.</p>
  {% else %}
    {% if form.errors %}<div class="text-error">{{ form.errors | default_errors }}</div>{% endif %}
    <input name="name" value="{{ form.values.name }}">
    <input name="email" type="text" inputmode="email" value="{{ form.values.email }}">
    <button type="submit" class="btn btn-primary">Join</button>
  {% endif %}
{% endform %}

Webhooks

Every submission is POSTed as JSON { event: "form_submission", submissionId, site, formName, pagePath, submittedAt, data, attribution }.

  • Signature: X-PageHub-Timestamp and X-PageHub-Signature: sha256=<hex>, an HMAC-SHA256 of <timestamp>.<raw body> keyed by the destination's signing secret (made on first delivery; set or rotate it from the form submissions screen, "Webhook signing").
  • Retries: a 2xx is delivered; 5xx, 429 and network errors retry up to 6 attempts (waits of 1 min, 5 min, 30 min, 2 h, 12 h); any other status fails with no retry.
  • Duplicates: the timestamp and signature are fresh on each attempt, the body and submissionId don't change, so receivers dedupe on submissionId.
  • Each submission records its delivery (delivered, retrying, failed) and the owner can Resend it.

Attribution

The runtime remembers how a visitor arrived, for the session, and posts it with every PageHub form as the reserved hidden field _ph_attribution. Don't name a field _ph_attribution.

  • What it remembers: utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, landing path, referrer.
  • Where it shows: "Where they came from" on the submission, and attribution in the webhook.

Dates and time

Dates render in the site's timezone (update_site timezone, an IANA name such as America/Los_Angeles), never the server's; unset means UTC. {{ site.timezone }} prints it.

  • {{ post.date | date: '%B %d, %Y' }} formats a date.
  • {{ post.date | time_tag: '%B %d, %Y' }} is a <time datetime> element.
  • {{ 'now' | date: '%Y' }} and {{ year }} are the current year.
  • A date-only string (2026-06-15) keeps its day in every zone.
  • A date-time with no offset (2026-06-15T10:00) is wall-clock time in the site's timezone (it prints 10:00 whatever the server's zone); add an offset (Z, -07:00) to name an exact instant.

Maps

{% map %} draws a map from its children: an <address data-pin> is a pin, a <span data-route> a route. Without JavaScript the children are a readable list.

HTML + Liquid
{% map lat: 34.05, lng: -118.24, zoom: 14, tiles: 'osm' %}
  <address data-pin="34.05,-118.24"><strong>Shop</strong> 1 Main St</address>
  <span data-route="34.05,-118.24; 34.06,-118.25 dashed">Walk from the lot</span>
{% endmap %}
ArgumentWhat it does
lat, lng, zoomWhere the map starts.
tiles: 'osm'Always this: the OpenStreetMap tiles. Any other value is read as a tile URL template.
mode'interactive', 'static' or 'background'.
grayscale: true, titleA grey map; the map's title.
classGoes on the map's own element: {% map lat: 34.05, lng: -118.24, class: 'w-full h-full' %}{% endmap %} fills a wrapper that has a height.
  • Route words after the points: dashed, color red, weight 4.
  • A route can instead carry attributes: <span data-path="34.05,-118.24,1; 34.06,-118.25,2" data-color="royalblue" data-weight="5" data-opacity="0.85" data-dashed>Walk from the lot</span> (a third ,label field draws a step badge; the text is the caption).

Core Liquid

KindNames
Tagsif / elsif / else / unless / case / when, for … limit: offset: reversed with {% else %} for empty lists, assign, capture, render 'name', var: value, raw, comment
Filtersdate, default, escape, where, sort, map, first, last, size, join, split, upcase, downcase, truncate, plus, minus

Output is already escaped, and | escape encodes once (it never double-escapes). No and/or grouping with parentheses: nest {% if %} instead.

PageHub tags

TagKindWhat it does
paginateblockSplit a list into pages: {% paginate list by 12 %}; exposes the paginate object.
formblockA form that posts to the host form handler; exposes form.posted_successfully?, form.errors, form.values.
sectiontagRender sections/<name>.liquid with settings given as named literal arguments.
schemarawA section's settings schema (JSON). Renders nothing.
docrawDocumentation for a snippet. Renders nothing.
styleblockCSS wrapped in a <style> element; the body is rendered as Liquid.
mapblockAn interactive map: {% map lat: 1, lng: 2, zoom: 12, tiles: 'osm' %} with child pins (<address data-pin="lat,lng">) and routes (<span data-route="lat,lng; lat,lng">). Also mode (interactive, static, background), grayscale, title.
seotagA detail page's head per row, inside its loop: {% seo item: item %} resolves the page SEO's {{item.*}}; title, description, image, og_title, og_description, canonical set fields; type: 'vacation_rental' adds that structured data.
javascriptrawA section's or partial's own JavaScript (raw, no Liquid inside). Added once per page, in one <script> at the end of <body>.
pagehub_badgetagA "Made with PageHub" link in the site's text colour, for the footer: {% pagehub_badge %} or {% pagehub_badge class: '…' %} (replaces the default classes). On a free plan it replaces the floating badge.

PageHub filters

FilterReturnsWhat it does
handleizestringLowercase, hyphen-separated slug: 'Health & Beauty' becomes 'health-beauty'.
pluralizestringPick the singular for 1, otherwise the plural: {{ count | pluralize: 'item', 'items' }}.
data_itemsarrayRows for a nested loop: {% for item in item.sizes | data_items: ',' %} splits a string; strings in an array become { value, title, slug, src, image }.
wherearrayRows where a field equals a value, or a list field contains it: {% assign r = rows | where: 'categories', 'apparel' %}.
facetsarrayFacet rows for a list of fields, counted over these rows: {% assign f = products | facets: 'colors,sizes' %}.
time_taghtmlA <time> element: {{ post.date | time_tag: '%B %d, %Y' }}, or a named format such as 'date'. Options: class, id, datetime.
link_tohtmlAn <a> element: {{ 'Contact' | link_to: '/contact', title: 'Get in touch' }}. Options: title, class, id, target, rel.
moneystringFormat minor units (cents) as money: {{ 4999 | money }} is $49.99. Options: currency, lang.
money_with_currencystringLike money, with the currency code: {{ 4999 | money_with_currency }} is $49.99 USD.
default_paginationhtmlPrevious / numbered / next links from the paginate object. Options: previous, next (labels).
default_errorshtmlThe standard error list for form.errors: <div class="errors"><ul><li>…</li></ul></div>.
image_taghtmlAn <img> element from a URL: {{ id | image_url: width: 800 | image_tag: alt: 'Shop front' }}. alt defaults to empty, loading to lazy.
image_urlurlA media id as a CDN URL: {{ section.settings.photo | image_url: width: 1600 }}. Options: width, height, crop, format.
file_urlurlA site file as a URL: {{ 'menu.pdf' | file_url }}.
asset_urlurlA site JS file (assets/<name>.js) as a URL: <script src="`{{ 'app.js' \| asset_url }}`" defer></script>. Served from the site's release, versioned by its content.
iconhtmlInline SVG for an icon ref: {{ 'tb/TbPhone' | icon | raw }}. Unknown refs render nothing.

Objects and data

NameWhat it is
sitethe site.json fields: site.name, site.title, site.description, site.lang, site.ogImage, site.ogVideo, site.company, site.variables, any custom key, and site.timezone (IANA, UTC when unset)
pagethis page: page.path (its file), page.url, page.title
pagesevery page that isn't hidden, each { path, url, title }: {% for p in pages %}<a href="{{ p.url }}">{{ p.title }}</a>{% endfor %}
requestrequest.query.<name> (the ?query), request.params.<name> (a detail page's pathPattern captures), request.path
routespage path to URL
collections.<slug>rows of the site's collection <slug> (list_collections). Loop: {% for row in collections.services limit: 6 %}. Its facet and category feeds are collections['<slug>.facets'] and collections['<slug>.categories'] (bracket form; the dotted one renders nothing). A gated collection's locked rows have row.locked
query["<key>"]a list the visitor's URL drives, with query_meta["<key>"] its paging (see Lists from the URL)
variablessite.json variables, an object: { "variables": { "promo": "SPRING15" } } is {{ variables.promo }}
productsthe Stripe catalog (products)
productthe Stripe catalog (products)
product_categoriesthe Stripe catalog (products.categories)
product_facetsthe Stripe catalog (products.facets)
pricesthe live Stripe price list
orderorder.session, on a data-cart-success page: number, total, paid, items, details
customerthe signed-in site customer, nil when signed out ({% if customer %}). customer.signed_in; customer.me is a ONE-row list, so read it as {% assign me = customer.me | first %}: me.name, me.email, me.isMember, me.plans.<plan slug> (true while held), me.planDays.<plan slug> (whole days held), me.hasSubscription, me.memberships (rows: name, status, endsLabel), me.orderCount, me.totalSpentFormatted; customer.orders (rows: description, price.formatted, metadata.created). There is no customer.isMember or customer.plans. Plan gate and drip: {% assign me = customer.me | first %}{% if me.plans['pro'] %}…{% if me.planDays['pro'] >= 7 %}week two{% endif %}{% elsif customer.signed_in %}upsell{% else %}sign in{% endif %}
companythe company fields from site.json (company.name, company.phone, company.city, …; company.hours splits into rows with split: ' · ')
yearthe current year, in the site's timezone

Lint rules (a write that breaks one is refused)

RuleWhat it checks
parseThe file must be well-formed HTML + Liquid (strict parse).
no-liquid-in-attr-listNo Liquid tag or output in an attribute list; put the whole element inside the {% if %}. One exception, a gated action: <button {% if customer %}data-cart="add"{% endif %}> (one {% if %}, no else, holding only data-* or href / target / rel / command / commandfor / action / download; never data-edit-* or javascript:).
no-dangling-openAn element opened in one Liquid branch must close in the same branch.
no-dangling-closeA closing tag needs its opening tag in the same branch.
static-attr-nameAttribute names must be static text.
static-tag-nameTag names must be static text.