Skip to main content
Block tags are written in braces: {if}, {each}, {/each}. The lexer recognises 14 names directly after a {. Eleven are usable tags, two are branch markers legal only inside another tag, and one (with) is recognised by the lexer but has no parser behind it, so every {with} is a hard error.

The complete table

Four more constructs are brace-delimited but are not block keywords. They are listed here because they are written the same way:
A branch marker takes no closing tag of its own. {/empty}, {/else}, {/loading} and {/error} are all parse errors. A branch runs until the next branch or the enclosing block’s close.

{if}

Renders the first branch whose condition is truthy.
Any number of {else if}, at most one {else}. Neither takes a closing tag; {/if} is required. Truthiness: null, false, the number 0, the empty string, the empty array and the empty object are falsy. Everything else is truthy, including the strings "0" and "false". Note that an empty array is falsy here and truthy in JavaScript.

{each}

required
The binding for the current element. It comes first.
Optional, after a comma. A 0-based number.
required
The literal keyword in. Omitting it is UnexpectedToken.
required
Any expression. Filters after it are loop filters, a different table from the value filters.

Loop variables

Four variables are bound automatically inside the body. These four names are resolved by a flat-key fast path, so exactly loop.index, loop.count, loop.first and loop.last are shadowed inside a loop. Any other property of a real loop object in the context still resolves normally.

Collection semantics

Each loop is capped at 1000 items. Over the cap the loop truncates, records an IterationLimitExceeded warning, and the build still succeeds. A render is additionally capped at 1000000 iterations in total, which is what bounds nested loops.
A loop-filter error EMPTIES the collection, which then renders the {empty} branch. A broken limit() therefore looks like “there is no data” rather than like an error.

{match}

There is no {case} keyword. An arm is a bare pattern, then ->, then a body. Writing {case "a"}A{/case} produces a cascade of UnexpectedToken errors starting with Expected match pattern, found LBrace. Patterns are a string literal, a number literal, a boolean literal, or _ for the wildcard. Arms are self-delimiting: a body ends at the next pattern token, so no per-arm closing tag exists. Matching is type-strict. A "5" pattern never matches a numeric 5.
With no matching arm and no _, the block renders nothing.
Inside {match} the lexer tokenises bare quotes, digits, -> and _ so it can read arm patterns. A stray quote in an arm body can therefore misparse, even though the same quote is harmless elsewhere in a template.

{fetch}

The compiler performs no network request. It reads name out of the render context and picks a branch. The host is responsible for putting the data there.
The branch order is fixed: body, then {loading}, then {error}, then {empty}. Out of order is not recovered. Writing {error} before {loading} reports Unclosed {fetch} block plus Unknown block: {loading}, which points nowhere near the real mistake.

The type hint

The syntax is name: Type or name: Type[]. The [] is only legal after a colon and a type name. {fetch products[] from "/api"} is a parse error (Expected 'from' in {fetch}). The hint is recorded in the AST and has no effect on rendering. Nothing is validated against it, and it does not change which branch is chosen.

Options

Options follow the URL expression. Only the parenthesised call form parses. An unrecognised option, and any colon form such as cache:300, is silently dropped. abortOnNavigate is accepted as an alternative spelling of the last one. All of these are instructions for the optional browser runtime; the server ignores them.

{repeat}

The count is any expression, but only a number counts. A string, null, an array or an object all yield 0 iterations, so {repeat "5"} renders nothing. A float is truncated toward zero, a negative is 0, and the count is capped at 1000.

{try}

The server always renders the body and never the {error} branch. This is purely a client-side boundary and does nothing without the browser runtime.

{html}

Raw, unescaped output. This is the only way to bypass escaping.
sanitize runs an ammonia allowlist. Allowed tags:
Everything else is removed by absence rather than as a special case, including <script>, <iframe>, <form> and <style>. Attributes work the same way. URL schemes are limited to http, https and mailto.
Every sanitized <a> gains rel="noopener noreferrer", not only one that carries a target. That is stricter than the requirement, and a test pins it.srcset is not on the attribute allowlist: the scheme check does not parse its comma-separated candidate grammar.

{html} in attribute position

{html expr} also works as an attribute spread, including a conditional form:
That path is sanitized differently: the spread is re-parsed into whole attributes, and any attribute with an unsafe name (on*, x-html, x-data, x-init, x-effect) or a script-scheme value is dropped.

{$let}

Seeds CLIENT state. Server-side it emits only a JSON payload.
It creates no template binding. Writing {$let n = 5} and then ${ n } renders the empty string. Note also the 0.0: a numeric literal round-trips through f64, so an integer comes back out as a float.
Object and array literals are not valid values, because the language has no such literals. {$let cfg = {a: 1}} is a lex error. Pass the value through the render context instead.

{slot} and {layout}

Only two slot names are ever filled: default and head. Any other name renders as a literal <slot name="x"> element in the output.
The two layout forms are not equivalent. With {layout "..."} the {head} children are extracted and placed in the layout’s head slot. With the element form <layout src="..."> they are not, and render inline in the body wherever they were written.
There is no layout frontmatter directive. A layout: line in frontmatter is silently ignored.

Slot fallback

{slot}fallback{/slot} renders its fallback only when nothing filled the slot at all.
Inside a component or a layout the caller’s children are ALWAYS supplied, even when there are none, so the fallback of the default slot is unreachable there. <Card /> against a component containing <slot>fallbk</slot> renders nothing in that position, not fallbk.
A named slot always falls through, because only default and head are ever filled, so <slot name="other">OTHER-FB</slot> renders as <slot name="other">OTHER-FB</slot> in the output: the literal element, with the fallback inside it.

{sections} and {blocks}

Host integration points. Neither takes a closing tag. {sections} emits pre-rendered section HTML the host injected under the reserved context key __slurp_sections_html. {sections "header"} reads __slurp_section_group_header_html instead, which is how global chrome lives in a layout. The page-level key is stripped when entering a component or layout scope, so a stray {sections} in a component cannot duplicate the page. Group keys are not stripped. {blocks} renders the child blocks of the nearest enclosing section or block, each from its own blocks/<type>.slurp file, preferring an enclosing block over an enclosing section so nesting works. Each child is wrapped for the editor:
Guards on that path: a block type must match [A-Za-z0-9_-]+, because it arrives from saved editor state and is used as a registry path; a block whose file hosts its own type is skipped by a cycle guard; and every block is charged against the shared iteration budget.
Neither tag produces anything under slurp build, because the CLI supplies no section state. A page with a section { } schema built from the CLI renders ${ section.settings.heading } as the empty string, not as the schema default. Defaults are applied by the host’s render_section call. See Schemas.

{debug}, {redirect}, {next}

{debug expr} pretty-prints a value into a visible <pre data-slurp-debug> in DEVELOPMENT mode and is stripped with no trace in production. slurp build always uses production mode, so {debug} never reaches a build. {redirect "path"} and {next} are middleware-only signals that render nothing. Using either outside middleware is a RedirectOutsideMiddleware error. Middleware is a compile option, not a directory convention, so the CLI treats every file as non-middleware unless told otherwise with --middleware.

{with} does not exist

with is in the lexer’s keyword table but no parser supports it, so it is a hard error rather than an unknown-tag no-op:
There is no scoping tag in Slurp. Write the full path, or bind the value in the host’s render context.