Pages and templating

Interpolation, expressions, functions, control flow, includes and effects.

A Crisptastic page is a text file with the extension .crisp. It is ordinary markup (HTML, Markdown, JSON, ...) with two additions: interpolations ({{ ... }}) that insert values, and directives (@if, @for, ...) that control the output or perform work. The server runs the page once per request and sends the result.

There is no client-side runtime. Whatever your page prints is what the browser receives.

This chapter documents what the engine does today. Where the older specifications describe something different, see Known gaps and differences.

File names decide the output type

A page is named <name>.<format>.crisp. The <format> selects the Content-Type of the response.

FileServed as
index.html.crisptext/html
notes.md.crisptext/html (the page runs first, then its output is converted from Markdown)
data.json.crispapplication/json
robots.txt.crisptext/plain
feed.xml.crispapplication/xml
site.css.crisp, app.js.crisp, app.mjs.crisptext/css, application/javascript
logo.svg.crispimage/svg+xml
export.csv.crisptext/csv

Any other format is a compile error. A bare name.crisp is treated as HTML.

Nothing is serialized for you. A .json.crisp page prints text, so you build the JSON yourself (see JSON responses).

Interpolation

{{ user.name }}         escaped output of a dotted path
{{= expression }}       escaped output of any expression
{{~ expression }}       raw (unescaped) output of any expression
{{-- a comment --}}     produces no output
  • {{ ... }} accepts only a dotted path (user.profile.name) or an expression containing a pipe (name | trim | upper). Anything else, such as {{ 1 + 2 }} or {{ items[0] }}, is a compile error. Use {{= ... }} whenever you need more than a path.
  • Escaped output converts & < > " ' to HTML entities. Escaping applies to every output format, including .json.crisp.
  • null and missing values print as an empty string. Booleans print true or false. Arrays and objects print as compact JSON.
  • Whitespace and newlines inside the braces are allowed.

Use {{~ ... }} only for content you trust, such as the output of json(...) in a JSON response, or pre-rendered markup.

Printing a literal {{ or @

The sequence {{ is always parsed as an interpolation, even inside <pre>, code fences and Markdown. To print it, emit it from a string:

{{~ "{{" }} name }}

The same trick prints a literal directive: {{~ "@" }}if (x) { renders as @if (x) {. In raw HTML (not inside Markdown code spans or fences), the HTML entities &#64; (@), &#123; and &#125; also work.

Values, truthiness and missing data

Values are JSON: null, booleans, numbers, strings, arrays, objects.

  • A missing path is null, never an error: {{ user.profile.name }} prints nothing when user has no profile.
  • Indexing something that is null or missing (missing[0]) is an error. Guard it with @if first.
  • Objects iterate and print in sorted key order, not insertion order.
  • Falsy values: null, false, 0, "", [], {}. Everything else is truthy, including the strings "0" and "false". (bool("false") is false; plain truthiness is not.)

Numbers

Numbers are IEEE binary64 values. A whole number prints without a decimal part whatever produced it: {{= 1 + 2 }} prints 3 and {{= 7 / 2 }} prints 3.5. Results beyond 2^53 lose precision rather than fail, so {{= 9223372036854775807 + 1 }} prints 9223372036854776000. Values from query strings and forms are always strings; convert with num(...).

Expressions

Expressions are used in {{= }}, {{~ }}, conditions and directive values.

KindSyntax
Literalsnull, true, false, 12, -3.5, "text"
Arrays / objects[1, 2], { a: 1, "b-c": 2 } (trailing commas allowed)
Pathsa.b.c, a[0], a["key"], a[i]
Arithmetic+ - * / (numbers only; + also concatenates when either side is a string)
Comparison< <= > >= (two numbers, two strings, or two booleans)
Equality= and != (structural). == is an error.
Logicand, or, not, ! (also &&, ||). They short-circuit and return booleans.
Callslen(x), x.len(), or x | len (all identical)
Grouping( ... )

Rules worth knowing:

  • Strings use double quotes only, with escapes \", \\, \n, \r, \t.
  • There are no exponents (1e3), single-quoted strings, ternaries, lambdas or template strings.
  • After an index or a literal you cannot continue with .name: items[0].title is a syntax error. Write items[0]["title"].
  • true, false and null are values wherever a path is accepted: @if (true) takes its branch, @if (false) does not, and {{ false }} prints false.
  • Operator precedence, loosest first: pipe |, or, and, = !=, comparisons, + -, * /, unary ! not -, calls and indexing. Because the pipe is loosest, num(a) + 1 | str means str(num(a) + 1).

Built-in functions

name(value, ...), value.name(...) and value | name(...) are equivalent. Inside a pipe, _ marks where the piped value goes when it is not the first argument: a | wrap("x", _) is wrap("x", a).

FunctionResult
upper(v), lower(v), trim(v)Convert to string, then change case / trim
len(v)Characters in a string, or entries in an array or object; 0 for anything else
contains(haystack, needle)Substring (string), element (array), or key (object)
starts_with(s, prefix), ends_with(s, suffix)Booleans
json(v)Compact JSON text (sorted keys). Integer literals in the page serialize as 1.0; numbers from data keep their form
str(v)String form; null becomes ""
num(v)Number; unparsable input becomes 0 (it never errors)
bool(v)"no", "off", "0", "false" give false; other non-empty strings true
exists(v)v is not null (so 0, "" and [] exist)
empty(v)null, "", [] or {}
merge(a, b)Shallow object merge; b wins; null counts as {}
form_urlencode(obj)a=1&b=x+y encoding of an object
sda(v, "query")Reshape data; see Fetching and shaping data
asset("alias:/path")Build an asset URL from context.env.alias
secure_token(n)Random hex string of n bytes (default 16, maximum 64)

Unknown function names are a runtime error ("no callback ... is registered"), not a compile error, so a typo is found only when the page is rendered.

You cannot define your own functions in a page. A site served by the crisptastic binary has exactly the functions above.

Control flow

@if / @else

@if (user) {
  <p>Hello, {{ user.name }}</p>
} @else {
  <p><a href="/login">Sign in</a></p>
}

The parentheses and braces are required. There is no else if; nest another @if inside the @else block.

@for

@for (post in posts) {
  <li>{{ post.title }}</li>
}

@for ((key, value) in settings) {
  <dt>{{ key }}</dt><dd>{{ value }}</dd>
}
  • Looping over an array visits elements in order.
  • The tuple form ((key, value) in object) visits keys in sorted order. Note the double parentheses. @for (k, v in x) is not recognised and prints as literal text.
  • Looping over a string, number, null or a single object runs zero times.
  • of is accepted as a legacy spelling of in.
  • The loop variable exists only inside the block. There is no built-in loop index.

@switch

@switch (order.status) {
  @case ("paid") { <b>Paid</b> }
  @case ("shipped") { <b>On its way</b> }
  @default { <b>Unknown</b> }
}

The first matching @case runs and there is no fall-through. At least one @case is required and @default must be last. Matching uses the same structural equality as =: numbers compare by value, so @case (3) matches a 3 from JSON data as well as 3.0, and a string never matches a number.

Block pitfalls

  • } always closes the innermost open block, even in the middle of text. To print a literal } inside a block, write &#125; or {{~ "}" }}.
  • A directive, comment, block opener or } that has a line to itself (only spaces or tabs around it) takes that whole line with it, indentation and newline included, so @let, @include and comment lines above <!doctype html> leave no blank lines and an @if (...) { / } pair on their own lines prints only the body. Anything sharing a line with text is left as written: <p class="a @if (b) {c}"> keeps its space, and @if (a) {x} @else {y} is unchanged. In a Markdown page, remember that a directive line therefore no longer separates two paragraphs.
  • Whitespace-only text immediately after a closing } of an @if (with no @else) is dropped.
  • Single-line blocks are fine: @if (a) {x} @else {y}.
  • Directives that consume the whole line (@status, @session, @cookie, @fetch, @call, @shape) swallow a closing } on the same line. Put each on its own line inside a block.
  • A syntax error inside the condition of @if, @for, @switch or @case (for example @if (a == b)) is reported with the directive's line and column like any other compile error: error:2:1: use '=' instead of '=='.

Local bindings: @let

@let name = expression evaluates an expression once and binds the result to name for the rest of the page, including after the block it was written in. It is how a page hands a plain value such as a title to the partials it includes:

@let title = "About"
@include("_partials/_head.html.crisp")
<title>{{ title }} ยท Example</title>
@let email = lower(trim(str(request.body.email)))
@if (len(email) > 3 and contains(email, "@")) { <p>Looks right.</p> }

The name must be a simple identifier; request, context and data cannot be rebound. Like the other line directives, @let consumes its whole line.

Includes and layouts

@include("_partials/_header.html.crisp")
  • The path must be a quoted string literal, relative to the including file. .. is allowed. There are no arguments and no variables in the path.
  • Includes are resolved when the site is built. A missing file or a cycle is a compile error.
  • An included file is rendered in the same scope: it sees loop variables and anything bound by @fetch, @call or @shape earlier in the page, and its effects (@session, @status, ...) apply to the page.
  • Format rules: an HTML page can include HTML or Markdown partials; a Markdown page can include only Markdown partials; any other combination is an error.
  • A Markdown partial included from HTML is converted at build time and is limited to text, interpolation, comments, includes and @if / @for / @switch blocks that sit on lines of their own. Effect directives, and interpolation inside code spans, fenced code or link targets, are not allowed there.
  • @include( is detected by plain text search over the whole file, including comments, <script> and prose. To mention it in text, write {{~ "@" }}include(.

A conventional layout is a pair of open/close partials:

@include("_partials/_layout-open.html.crisp")
<h1>About</h1>
@include("_partials/_layout-close.html.crisp")

Files whose name begins with _ are never served as routes (see Project layout and routing).

Markdown pages

name.md.crisp pages run as templates first (so interpolation and directives work), then the result is converted to HTML. Tables and strikethrough are enabled; raw HTML passes through. Footnotes, heading attributes, task-list checkboxes and smart punctuation are not.

Because the template runs first, {{ and @if (...) { are active even inside Markdown code fences. Use the literal-output tricks above when documenting Crisptastic itself. Interpolated values are HTML-escaped but not Markdown-escaped, so a value containing * or _ will be formatted.

A common pattern is an HTML wrapper that includes Markdown content:

@include("_partials/_docs-open.html.crisp")
@include("_content.md.crisp")
@include("_partials/_docs-close.html.crisp")

<script> and <style>

Inside <script> and <style> blocks, directives and block braces are not interpreted, so JavaScript and CSS braces are safe. {{ ... }} and {{-- --}} are still interpreted. The match is a case-insensitive prefix on <script / <style and the block ends at the exact text </script> / </style>.

Do not put {{~ json(x) }} inside a <script> tag. json() does not escape <, so a value containing </script> breaks out of the tag. Put the value in an escaped attribute instead (<div data-x="{{ json(x) }}">) and read it with JSON.parse(el.dataset.x).

JSON responses

{{-- api/greeting.json.crisp --}}
{{~ json({ ok: true, name: request.query.name }) }}

Use {{~ json(...) }} (raw) so quotes are not converted to &quot;.

Effects: status, redirect, cookies and sessions

These directives change the response rather than print text. They are collected during rendering and applied after the page finishes; if the page fails, none are applied.

@status 404
@redirect "/dashboard"
@cookie theme = "dark"
@session cart_count = 3
DirectiveMeaning
@status exprInteger 200-599, at most once. The body is still rendered (204, 205 and 304 get an empty body).
@redirect exprAlways a 303 See Other. The target must be /absolute-path (not //host) or an http(s):// URL without credentials. Must come before any visible output. The first one wins and rendering stops.
@cookie name = exprSets a cookie to a string, number or boolean. null deletes it.
@cookie name deleteExpires the cookie; the same as assigning null.
@session key = exprSets a key in the session. Any JSON value, including null, which is stored as a value.
@session key deleteRemoves the key. Deleting from a session that does not exist does nothing.

Details that surprise people:

  • There are no options. @redirect "/x", status: 301 and @cookie a = "b", path: "/" are compile errors. Redirects are always 303 and cookies always use Path=/; HttpOnly; SameSite=Lax (plus Secure on a production host).
  • @header, @cache and @telemetry do not exist. A line starting with one of them is printed into the page as plain text.
  • The binding name on the left of @session, @cookie, @fetch, @call and @shape must be a simple identifier. Anything else silently turns the whole line into literal text.
  • Numbers assigned to cookies print as they do in a page: 5 becomes 5 and 2.5 becomes 2.5.

Sessions, cookies and CSRF are covered in Sessions, cookies and forms.

Things that are not directives

An @ that does not form a complete directive is just text, so me@example.com is safe. But some directive names break the build when they appear in prose on the same line as other words: @redirect, @status, @code {, and any of @session, @cookie, @fetch, @call, @shape followed by something shaped like x = .... Quote them with {{~ "@" }} or &#64; when writing documentation.

@code { ... } blocks, JavaScript-style method calls (x.toUpperCase(), JSON.stringify(x)) and custom pipe definitions belong to the standalone crispc compiler, not to the web framework.

Limits

Every render runs with fixed limits: 5 seconds, 1,000,000 instructions, 100,000 loop iterations in total, include depth 64, 8 MiB of output and 32 fetch or capability operations. Exceeding one produces language.limit_exceeded: <reason> and a 500 response. These are not configurable from crisptastic.toml.

Diagnostics

Errors have the form error:LINE:COL: message, and site compilation prefixes compile failed for source '<file>':. A failed compile writes nothing and, in dev, leaves the previous good version serving. Common messages:

MessageUsual cause
invalid symbol path '...'Used {{ ... }} with something that is not a plain dotted path; use {{= ... }}.
use '=' instead of '==' ...Wrote ==.
unsupported interpreted expression syntax near '...'Unsupported syntax, often .name after ] or an option list after @redirect or @cookie.
unterminated @if blockA block-closing } was swallowed by a same-line directive, or is missing.
@else without matching @if@else does not directly follow the } of an @if.
@include path must be a quoted string literal@include( appeared in prose or a comment, or the path is not quoted.
unsupported routed output type '.x'The format part of the file name is not one of the supported types.
@code is not supported by crisptasticA @code block from a crispc example.
no callback 'x' is registeredUnknown function name, found at render time.