# Liquid templates — the render-service dialect

Public tenant sites are rendered server-side by the render-service
(`internal/render/`) with **`github.com/osteele/liquid v1.8.1`** (`go.mod`).
The engine is NOT a stock `liquid.NewEngine()`: on every engine it builds,
the platform registers the custom filters `date_ru` and `push` and OVERRIDES
`plus`/`minus`/`times` (int64-corrected arithmetic)
(`internal/render/liquid_filters.go`), and wires an `{% include %}` provider
that resolves partials inside the tenant's own template tree
(`internal/render/partials.go`). No autoescape. Everything below is either
grounded in that engine's source or was executed against it directly.

## Bindings available to templates

A page template renders ONLY through a flow's `render` node, and its data
comes from the flow context:

| Binding | Shape | Source |
|---|---|---|
| `results.<key>` | one entry per graphql node, keyed by its `result_key`; each value is the ENTIRE GraphQL JSON envelope `{"data": {…}}` | flow context |
| `<key>.data` | the same result lifted to the top level (reserved names untouched) | flow context |
| `site` | `{"version": <int>, "query": {<param>: <value>}, "params": {<path param>: <value>}}` | injected into the GET-flow template seed |

Plus the flow namespaces `form.*` / `json.*` / `xml.*` (body by transport),
`query.*`, `params.*`, `headers.*`, `cookies.*`, `member.*`,
`method.*` — tabulated in
[flows-guide.md §2](flows-guide.md).

**Address a graphql result as `<key>.data.<field>`** (or
`results.<key>.data.<field>`) — the value under the key is the ENTIRE GraphQL
response object; bare `{{ posts }}` / `{{ page.title }}` never resolve. A
graphql node with `result_key: "posts_list"` makes the posts array available
at `posts_list.data.posts`:

```liquid
{% for p in posts_list.data.posts %}{{ p.title }}{% endfor %}
```

A graphql node carries its `query` inline or references the shared query
library by `library_name`.

Facts that follow from the pipeline:

- **Flow variables are Liquid-rendered, leaf by leaf.** A graphql action's
  `variables` object has every string LEAF Liquid-rendered — e.g.
  `{"slug": "{{ params.slug }}", "cat": "{{ query.cat }}"}`. An absent or
  EMPTY rendered string leaf is OMITTED from the variables → the variable
  resolves to null → a nullable filter operator is skipped (the
  optional-filter pattern). Numeric filter variables must be `Float` — and
  `{{ query.min | plus: 0 }}` gymnastics are NOT needed: declare
  `$min: Float` and pass the raw query string
  (`{"min": "{{ query.min }}"}`); the platform coerces variables to their
  declared types.
- **A failing graphql action errors THAT flow run only** (only
  `sitemapQuery` is validated at route-write time) — sync:
  the page fails; async: recorded in `form_action_runs`; sitemap expansion:
  logged and skipped. It does not take down other bindings. Guard optional
  data with `{% if %}` (undefined variables are nil, `StrictVariables` is
  off — a missing key renders as empty string).
- **Caching**: the flow response is cached for the route's
  `cache.defaultTtl` seconds (Redis) when the route opted in. The cache is
  generation-keyed: it invalidates on ANY site mutation
  (route/template/query edit, publish) and on entity-data AND
  entity-definition changes — fresh content shows up immediately, and the
  TTL (300s is a common choice) is only the fallback when the invalidation
  signal is down. The data-change signal has a ~2s debounce
  window (bulk seeds coalesce): when measuring invalidation, wait 2–3s
  after a mutation before polling — a request landing inside the window
  serves the pre-mutation value once, then the generation bump rotates the
  cache and the next request is fresh.
- **Includes share the page's scope** — an `{% include %}`d partial sees
  everything the page template sees: flow results (`results.<key>`,
  hoisted `<key>.data`), `assign`/`capture` variables, `form.*`/`params.*`/
  all other namespaces. A base template can therefore read the page's SEO
  query results directly (see the base-template section below).

### Truthiness (verified against v1.8.1)

Only `nil` and `false` are falsy — like Ruby Liquid, **an empty array, empty
string, empty map, and `0` are all TRUTHY**. So:

```liquid
{% if posts_list.data.posts %} … {% endif %}
{% if posts_list.data.posts.size > 0 %} … {% endif %}
{% assign post = post_by_slug.data.posts | first %}
{% if post %} … {% endif %}
```

- Line 1 is true even for an EMPTY list — it only guards a missing key.
- Line 2 is the real emptiness check.
- `first` on an empty list yields nil, so line 4 is the correct
  found/not-found guard.

(Liquid has no `{# … #}` inline comments — that syntax renders literally.
Use `{% comment %}…{% endcomment %}`.)

`{% for %}` supports `{% else %}` for empty collections, and range loops
`{% for n in (1..5) %}` work. **Range bounds must be integers** — the engine
accepts only int there. The platform engine already normalizes JSON numbers
(everything arrives as float64) and re-registers `plus`/`minus`/`times` to
return ints, so both data fields (`(1..r.rating)`) and computed bounds
(`{% assign m = n | plus: 1 %}`) drive ranges; `ceil`/`floor` also return
int. Anything else still float (e.g. `divided_by` results) breaks the range
loop — and note the error names the loop, which can be an OUTER one, not the
bound that's actually bad.

## Tags

Confirmed registered by `AddStandardTags`
(`tags/standard_tags.go:14-32` in the module cache):

| Tag | Notes |
|---|---|
| `assign` / `capture` | plain names only — Jekyll extensions are OFF, so `{% assign a.b = x %}` is a parse error (`render/config.go` `JekyllExtensions` default false) |
| `if` / `elsif` / `else` | `and` / `or` / comparisons all work |
| `unless` / `else` | |
| `case` / `when` / `else` | |
| `for` / `else` | supports `limit:`, `offset:`, `reversed`, ranges `(1..n)`; modifiers always apply in the order reversed → offset → limit regardless of syntax order (a known divergence from Ruby Liquid — `docs/loop-semantics.md` in the library) |
| `break` / `continue` | |
| `cycle` | only valid inside a for-loop |
| `tablerow` | |
| `comment` / `raw` | |
| `include` | partials from the tenant's OWN template tree (`internal/render/partials.go`). `{% include "base" %}` loads `base.liquid` (the `.liquid` suffix is added when missing; nested paths like `partials/card` work). Scope is SHARED with the including template. Rules: paths are relative to the template root, no absolute / drive-prefixed / backslash / `..` names — the provider rejects them and the render FAILS (fail-closed, logged). A missing partial also fails the render. Recursion is capped (depth 16) — an include cycle errors the render instead of hanging. Include bodies are byte-cached and cleared by the same invalidation as the compiled-template cache. **Email bodies are a different engine**: `{% include %}` inside `send_email` templates is refused entirely (sandboxed). |
| `render` | upstream tag — like `include` but with an ISOLATED scope: the partial does not see the page's variables; pass everything it needs explicitly (`{% render "partials/total", items: posts %}`-style arguments per upstream semantics). For the base-template and shared-partial patterns always prefer `include`. |

There are **no other DynapiCMS-specific tags** (`template_store.go` never
calls `RegisterTag` for anything but the guarded include; grep the render
package to confirm).

Inside `for` loops the `forloop` object exposes `index` (1-based), `index0`,
`rindex`, `rindex0`, `first`, `last`, `length` (`tags/iteration_tags.go:143-149`).

## Filters

Confirmed registered by `AddStandardFilters` (`filters/standard_filters.go:118-402`)
PLUS the platform filters `date_ru` and `push` — registered by
`newLiquidEngine` (`internal/render/liquid_filters.go`) on every engine the
render service creates, which also OVERRIDES the standard
`plus`/`minus`/`times` (int64-corrected arithmetic):

```
default  json  compact  concat  join  map  push  reverse  sort  sort_natural  uniq
first  last  size  slice  split
date  abs  ceil  floor  round  modulo  minus  plus  times  divided_by
append  prepend  capitalize  downcase  upcase
escape  escape_once  newline_to_br  strip_html  strip_newlines  strip  lstrip  rstrip
remove  remove_first  replace  replace_first  truncate  truncatewords
url_encode  url_decode  inspect  type
```

No `t`/translate, no currency filters exist. Platform tier: `date_ru` (RU
dates) and `push` (NOT shipped by osteele/liquid v1.8.1) as custom filters,
`plus`/`minus`/`times` as overrides of the standard filters (they fix int64
range arithmetic); `ceil`/`floor` return ints. Notes (behavior executed
against v1.8.1):

- **One bad filter call fails the ENTIRE render** — an undefined filter or a
  type mismatch returns a render error (`expressions/filters.go`), and
  `RenderWithFallback` then serves the fallback HTML (see Limits). Always
  guard: `{{ post.meta | default: '' | truncate: 80 }}` on optional fields.
- **`date`** uses Ruby strftime tokens (`%Y %m %d %H:%M`) and accepts
  ISO-8601 strings — `"2026-07-28T10:00:00Z" | date: "%d.%m.%Y"` →
  `28.07.2026`; plain `"2026-07-28"` also parses. `nil` renders the zero time
  year `0001` (guard with `default`). Feeding it a non-date value (e.g. an
  array) errors the render. **Month/weekday names are English-only** —
  for Russian dates use `date_ru`.
- **`date_ru`** (platform filter) — same string inputs as `date`
  (RFC3339/ISO, date-only, dotted `02.01.2006`), strftime subset
  `%Y %m %d %-d %H %M %S %B %b %A %a %%`, Russian names:
  `%B`/`%b` months in GENITIVE («5 мая» — the correct form in a date line),
  `%A`/`%a` weekdays («понедельник»/«пн»), `%-d` day without leading zero:
  `"2026-05-05" | date_ru: "%-d %B %Y"` → `5 мая 2026`;
  `"2026-09-17" | date_ru: "%A, %-d %b"` → `четверг, 17 сент`.
  Unparseable input passes through unchanged (no render error).
- **`push`** appends to an ARRAY and returns the result —
  `{{ arr | push: item }}` (the single-item counterpart of `concat`); pairs
  with `{% assign %}` to accumulate a list in a loop.
- **String literals do NOT process escape sequences.** `split: "\n\n"`
  splits on the literal characters backslash-n-backslash-n, NOT on blank
  lines. For multi-paragraph text bodies convert first and split on the
  produced tag — `{{ body | newline_to_br | split: "<br />" }}` — or handle
  the split query-side.
- **`divided_by`**: integer operands truncate (`25 | divided_by: 10` → `2`);
  a float operand gives float math (`25 | divided_by: 10.0` → `2.3`).
- **No autoescape**: `{{ }}` emits raw HTML. Entity content is stored as you
  authored it — pipe visitor-influenced or HTML-bearing values through
  `| escape` unless you intend markup.
- `size` works on arrays and strings; `.size` as a property also works
  (`{{ posts_list.data.posts.size }}`).

## Base template — the canonical page pattern

A page declares its own shell and includes it (the render node carries ONLY
`template` — there is no layout config key):

1. `base.liquid` — shared shell (doctype, `<head>`, header/footer). The
   slot for page content is the variable `content`:

   ```liquid
   <!doctype html>
   <html lang="en">
   <head>
   <meta charset="utf-8">
   <title>{{ page_title | default: "Site" }}</title>
   </head>
   <body>
   {{ content }}
   </body>
   </html>
   ```

2. Every page template captures its body into `content`, then includes the
   base:

   ```liquid
   {% capture content %}
   <h1>Welcome</h1>
   {% for p in posts_list.data.posts %}
   <article>{{ p.title }}</article>
   {% endfor %}
   {% endcapture %}
   {% include "base" %}
   ```

Facts:

- The include sees the page's scope, so everything captured/assigned before
  `{% include "base" %}` (including `content`) is visible inside `base.liquid`.
- Resolve naming collisions before wrapping: if a page already captures a
  variable named `content` for its own purposes, rename it — the base reads
  `content` unconditionally.
- Shared partials work the same way: `partials/card.liquid`, then
  `{% include "partials/card" %}` from any template. Paths never leave the
  tenant's template root (`..` is rejected, fail-closed).
- Draft/preview coherence: on the preview host the include resolves from the
  draft tree first, falling back per-file to the live tree — same rule the
  renderer applies to the page template itself.
- Templates live under `templates/main/<path>` relative to the tenant's S3
  prefix; on the preview host (`preview-<sub>.<domain>`) templates load from
  `templates/_draft/` (`server.go:388`).
- A render pass has a single timeout budget (see Limits) — includes render
  inline within it.

### Per-page SEO from the shared base

The base sees the page's bindings, so `<head>` tags live in `base.liquid`
and read the page's OWN query results. Pick ONE convention and stay
consistent:

- **Uniform key name (recommended):** end every page's flow with a graphql
  node whose `result_key` is `page_seo`, running that route's own SEO query
  (`pages(where: {slug: {eq: "…"}}) { … }` or an entity lookup). The base
  then reads one name unconditionally:

  ```liquid
  {%- assign pg = page_seo.data.pages | first -%}
  <title>{{ pg.seo.meta_title | default: pg.title | default: "Site" }}</title>
  <meta name="description" content="{{ pg.seo.meta_description | default: '' }}">
  ```

- **If-cascade** when result keys differ per route:

  ```liquid
  {%- assign m_title = "" -%}
  {%- if home_seo -%}{%- assign pg = home_seo.data.pages | first -%}
    {%- if pg -%}{%- assign m_title = pg.seo.meta_title | default: pg.title -%}{%- endif -%}
  {%- endif -%}
  {%- if note_by_slug -%}{%- assign nt = note_by_slug.data.repairNotes | first -%}
    {%- if nt -%}{%- assign m_title = nt.seo.meta_title | default: nt.title -%}{%- endif -%}
  {%- endif -%}
  <title>{{ m_title | default: "Site" }}</title>
  ```

Page templates never emit `<head>` — the base owns `<head>` and SEO;
pages own `<body>` content only (the captured `content`).

## 404 and error pages

**Custom 404: a live route with path exactly `/404` is
AUTOMATICALLY served for every unmatched path, with HTTP status 404.** The
preview host uses the draft `/404` route. The page is drawn like any other —
by a FLOW bound to the `/404` route, ending in a render node — but it must
not depend on `:params` — an unmatched path has none. Direct navigation to
`/404` still returns 200 (the route itself matches).

What still happens without a `/404` route, or outside its reach:

| Situation | HTTP | Body |
|---|---|---|
| Unknown tenant (bad host) | 404 | hardcoded `<h1>Site not found</h1>` (`server.go:336`) |
| Unknown path, no live `/404` route | 404 | stock 404 (`server.go:391`) |
| Render-node runtime failure (any page) | **200** | hardcoded fallback `<html><body><h1>Something went wrong</h1></body></html>`; the error is logged to the render-service log |

A MATCHED dynamic route whose entity is missing still renders 200 — keep an
in-template not-found branch for that case (example c below), and let the
`/404` route carry genuinely unknown paths.

## Limits

- **Render timeout** — `RENDER_LIQUID_TIMEOUT_SEC`, default **5 seconds**,
  applied per render pass — an include-base page is a single pass, includes
  render inline within the same budget
  (`internal/render/config.go:76`). On timeout the
  render aborts and the visitor gets the fallback HTML with HTTP 200.
  Panics inside filters/tags are recovered the same way.
- **Loop cap** — `RENDER_LIQUID_LOOP_CAP`, default **10000**, is parsed into
  config but **is NOT enforced**: osteele/liquid v1 exposes no instruction
  counting, so the cap is stored for future wiring and the **timeout is the
  only guard** today (`renderer.go:15-21` comment states this explicitly).
  Write templates as if there were no loop limit.
- Guidance for heavy templates: paginate in **GraphQL** (`limit`/`offset`
  args, capped server-side at `limit: 100` per query,
  `internal/graphql/schema_builder.go:920-922`) rather than pulling
  everything and slicing in Liquid with `for limit:`; use the paired
  `<plural>Count` query for totals instead of `| size` on full lists; avoid
  nested loops over large arrays with string filters on every iteration.

## Complete example templates

All three use the current recipe shape: a GET route bound to a flow; the
flow runs entry → graphql node → render node; the template reads the graphql
node's output as `<key>.data` (or `results.<key>.data`). Every page captures
its body into `content` and includes `base.liquid`.

### a) `home.liquid` — base + latest posts

Route:

```json
{ "path": "/", "methods": ["GET"], "cache": {"defaultTtl": 300},
  "flows": [{"handle": "home", "methods": ["GET"]}] }
```

Flow `home`: entry → graphql node — query
`{ posts(limit: 5, orderBy: [{field: CREATED_AT, direction: DESC}]) { id title slug } }`,
no variables, `result_key: "posts_list"` → render node
`{"template": "home.liquid"}`.

```liquid
{% capture content %}
<h1>Welcome</h1>
{% if posts_list.data.posts %}
{% for p in posts_list.data.posts %}
<article><h2><a href="/blog/{{ p.slug }}">{{ p.title }}</a></h2></article>
{% endfor %}
{% endif %}
{% endcapture %}
{% include "base" %}
```

A matching `base.liquid` (derive the title from a binding — a bare
`page.title` never resolves):

```liquid
<!doctype html>
<html>
<head>
<title>{% assign first_post = posts_list.data.posts | first %}{{ first_post.title | default: "Site" }}</title>
</head>
<body>
{{ content }}
</body>
</html>
```

### b) `blog/index.liquid` — listing with pagination

`?offset=` pagination wiring (the supported way):

Route:

```json
{ "path": "/blog", "methods": ["GET"], "cache": {"defaultTtl": 300},
  "flows": [{"handle": "blog_index", "methods": ["GET"]}] }
```

Flow `blog_index`: entry → graphql node — query
`query($offset: Int) { posts(limit: 10, offset: $offset, orderBy: [{field: CREATED_AT, direction: DESC}]) { id title slug } postsCount }`,
variables `{"offset": "{{ query.offset }}"}`, `result_key: "posts_page"` →
render node `{"template": "blog/index.liquid"}`.

```liquid
{% capture content %}
<h1>Blog</h1>
{% if posts_page.data.posts %}
{% for p in posts_page.data.posts %}
<article><h2><a href="/blog/{{ p.slug }}">{{ p.title }}</a></h2></article>
{% endfor %}
{% else %}
<p>No posts yet.</p>
{% endif %}
{% assign total = posts_page.data.postsCount | default: 0 %}
{% if total > 10 %}
<nav class="pagination">
  <a href="/blog?offset=0">1</a>
  {% for n in (1..20) %}
    {% assign o = n | times: 10 %}
    {% if o < total %}
    <a href="/blog?offset={{ o }}">{{ n | plus: 1 }}</a>
    {% endif %}
  {% endfor %}
</nav>
{% endif %}
{% endcapture %}
{% include "base" %}
```

Why it works: `?offset=20` renders the variables leaf to `"20"`; the
platform coerces variables to their declared types, so `$offset: Int`
receives 20; absent or
empty → the leaf is omitted → the variable is null → offset 0. `postsCount` (exposed for every dynamic entity when the store
supports `CountByFilter`) ignores limit/offset and drives the page links.
Keep the `limit` literal inside the query (10) so link math stays in sync.

### c) `blog/post.liquid` — single post via `:slug`, with not-found branch

Route:

```json
{ "path": "/blog/:slug", "methods": ["GET"], "cache": {"defaultTtl": 300},
  "flows": [{"handle": "blog_post", "methods": ["GET"]}] }
```

Flow `blog_post`: entry → graphql node — query
`query($slug: String) { posts(where: {slug: {eq: $slug}}, limit: 1) { id title body slug published_at } }`,
variables `{"slug": "{{ params.slug }}"}`, `result_key: "post_by_slug"` →
render node `{"template": "blog/post.liquid"}`.

```liquid
{% capture content %}
{% assign post = post_by_slug.data.posts | first %}
{% if post %}
<article>
  <h1>{{ post.title }}</h1>
  {% if post.published_at %}<time>{{ post.published_at | date: "%d.%m.%Y" }}</time>{% endif %}
  <div>{{ post.body }}</div>
</article>
{% else %}
<h1>Page not found</h1>
<p>The post you are looking for does not exist.</p>
{% endif %}
{% endcapture %}
{% include "base" %}
```

`| first` on an empty list yields nil → the `{% if post %}` branch is the
entity-not-found case for this MATCHED route (HTTP stays 200 — the custom
`/404` route only serves unmatched paths; see the 404 section). `$slug`
stays NULLABLE (`String`, not `String!`): with the variable absent or empty
the `where` filter is simply skipped (see flow variables above). The
`where: {slug: {eq: $slug}}` filter shape is the standard dynamic-entity
`<Type>Where` input.

---

### Verify-at-runtime items

1. Exact wire format of entity `datetime` fields from GraphQL (RFC3339 was
   verified to satisfy `| date:`; confirm your field's stored format on the
   preview host).
