Cheatsheet

Everything you need to build a complete Crisptastic site, on one page. Written to be pasted into an LLM context or kept open in a tab.

This is the entire authoring surface of Crisptastic in one page: enough to generate a complete multi-page site with layouts, dynamic routes, Markdown content, styling and a sitemap. Capabilities (auth, data, payments, files) are intentionally left out here — see Capabilities.

1. The whole workflow

crisptastic init my-site      # scaffold: index.html.crisp, assets/, crisptastic.toml
cd my-site
crisptastic dev               # live server on http://127.0.0.1:6174, watches files
crisptastic check .           # compile, report diagnostics, write nothing
crisptastic routes .          # list every resolved route
crisptastic build capsule . --out site.capsule   # one immutable artifact

Mental model: a folder of pages is the site. Each *.html.crisp / *.md.crisp file is a route. Pages are server-rendered per request. Missing data is null, never an error. There is no client runtime unless you add one.

2. Project layout

directory layout
my-site/
  crisptastic.toml          # manifest (required)
  index.html.crisp          # ->  /
  about.html.crisp          # ->  /about
  contact/index.html.crisp  # ->  /contact/
  blog/
    index.html.crisp        # ->  /blog/
    [slug].html.crisp       # ->  /blog/:slug   (dynamic segment)
  sitemap.xml.crisp         # ->  /sitemap.xml
  robots.txt.crisp          # ->  /robots.txt
  _partials/                # private: reusable fragments, never routed
    _layout-open.html.crisp
    _layout-close.html.crisp
  _errors/                  # private: error pages
    404.html.crisp
    500.html.crisp
  assets/                   # served from the site root
    style.scss              # ->  /style.css   (compiled)
    logo.svg                # ->  /logo.svg     (copied as-is)
SuffixRouteType
.html.crispas shown aboveHTML
.md.crispsame, but body is rendered from MarkdownHTML
.json.crisp.json keptJSON
.txt.crisp.txt kepttext
.xml.crisp.xml keptXML

Rules: a path segment starting with _ is private. [name] is one dynamic segment, read as request.route.params.name. Static routes beat dynamic ones.

3. Manifest (crisptastic.toml)

crisptastic.toml
name = "my-site"
root = "."
assets = "./assets"
bind = "127.0.0.1:6174"

Plain build-time values, readable as context.env.KEY in any page.

[env] SITE_NAME = "My Site" BASE_URL = "https://example.com"

4. Output & interpolation

Plain text and markup are emitted literally.

{{ user.name }}          HTML-escaped value
{{= user.name }}         identical, explicit form (preferred)
{{= 1 + 2 }}             any expression
{{= title | upper }}     pipeline, left to right
{{~ trusted_html }}      raw, NOT escaped -- only for strings you trust

{{-- this is a comment, emitted nowhere --}}

Missing keys are null and render as empty. Booleans and numbers render with str(). Arrays/objects need explicit json(...).

5. Expressions

null  true  false
42   3.14   -7
"a string"
[1, 2, 3]
{ "key": "value", "n": 1 }

a.b.c          object path (missing -> null)
items[0]       array index
list["key"]    dynamic key

Operators:  and  or  not  !  -  *  /  +  -  <  <=  >  >=  =  !=
"+" adds numbers; if either side is a string, both are str()-cast and joined.

Built-in functions

upper(s)  lower(s)  trim(s)  len(v)  contains(hay, needle)
starts_with(s, p)  ends_with(s, p)
str(v)  num(v)  bool(v)  json(v)
exists(v)   -- false only for null
empty(v)    -- true for null, "", [], {}
merge(a, b) -- shallow object merge, b wins
sda(value, "query")  -- data shaping, see /docs/data-shaping/

6. Directives

@if (user.admin) {
  <span>Admin</span>
} @else {
  <span>Member</span>
}

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

@for ((key, value) of settings) {
  <dt>{{= key }}</dt><dd>{{= value }}</dd>
}

@switch (page.kind) {
  @case ("home")  { <h1>Welcome</h1> }
  @case ("post")  { <h1>{{= page.title }}</h1> }
  @default        { <h1>Page</h1> }
}

Loop over a literal array to drive a list without any data service:

@for (item of [
  { "label": "Home",  "href": "/" },
  { "label": "Blog",  "href": "/blog/" },
  { "label": "About", "href": "/about" }
]) {
  <a href="{{= item.href }}">{{= item.label }}</a>
}

Notes: use of, not in. Keep @if/@for blocks multi-line. The loop variable is scoped to the block.

7. Includes, layouts & components

@include("path") splices another file in at build time. Paths are static string literals, resolved relative to the current file, and may use ../ inside the project. Includes take no arguments.

There is no slot mechanism, so a layout is an open partial and a close partial that a page sits between:

contact/index.html.crisp
@include("_partials/_layout-open.html.crisp")

<h1>Contact</h1> <p>Say hello.</p>

@include("_partials/_layout-close.html.crisp")

_partials/_layout-open.html.crisp
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{= context.env.SITE_NAME }}</title>
  <link rel="stylesheet" href="/style.css">
</head>
<body>
  <header>
    <a href="/">{{= context.env.SITE_NAME }}</a>
    <nav><a href="/blog/">Blog</a> <a href="/about">About</a></nav>
  </header>
  <main>
_partials/_layout-close.html.crisp
  </main>
  <footer>&copy; {{= context.env.SITE_NAME }}</footer>
</body>
</html>

Components with varying data: since includes take no args, either (a) make the partial pure markup, or (b) @include it inside a @for loop so it can read the loop variable, or (c) bind a value with @shape just before the include (an include is spliced in, so bindings in scope at the include site are visible to it).

8. The request & context object

request.method              "GET"
request.path                "/blog/hello"
request.query.tag           value of ?tag=...   (missing -> null)
request.query_string        "tag=news&page=2"
request.headers["user-agent"]
request.cookies.session
request.body                parsed body (only on routes that declare one)
request.route.path          "/blog/[slug]"
request.route.params.slug   "hello"             (dynamic segment)

context.env.SITE_NAME       from [env] in crisptastic.toml
context.session             null, or your session object
data                        entry point for the data capability (null otherwise)

9. Markdown pages

A .md.crisp file runs as a page first (interpolation, includes, directives), then the result is rendered from Markdown to HTML. Tables and strikethrough are on. Raw HTML in the source passes through.

One rule: a .md.crisp file can only @include other .md.crisp files. To wrap Markdown in your HTML layout, make the page an .html.crisp file that includes a Markdown body:

about.html.crisp
@include("_partials/_layout-open.html.crisp")
@include("_partials/_about-body.md.crisp")
@include("_partials/_layout-close.html.crisp")
_partials/_about-body.md.crisp
# About {{= context.env.SITE_NAME }}

We build things. Contact us any time.

  • Fast
  • Simple

10. Styling & assets

Everything in assets/ is served from the site root. A .scss file that does not start with _ compiles to .css; _partial.scss files are includes only. Other files are copied byte-for-byte.

assets/style.scss
// assets/style.scss  ->  /style.css
$ink: #16201e;
$accent: #0d9488;

body { font: 16px/1.6 system-ui, sans-serif; color: $ink; max-width: 42rem; margin: 2rem auto; } a { color: $accent; }

11. Dynamic route example

blog/[slug].html.crisp
@include("../_partials/_layout-open.html.crisp")

<article> <h1>{{= request.route.params.slug }}</h1> <p>This post's URL slug is {{= request.route.params.slug | upper }}.</p> </article>

@include("../_partials/_layout-close.html.crisp")

12. Non-HTML routes

sitemap.xml.crisp
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@for (path of ["/", "/about", "/blog/"]) {
  <url><loc>{{= context.env.BASE_URL }}{{= path }}</loc></url>
}
</urlset>
robots.txt.crisp
User-agent: *
Allow: /
Sitemap: {{= context.env.BASE_URL }}/sitemap.xml

13. Redirects, status & headers

@redirect "/new-home/", status: 301

@status 404

@header "content-language" = "en"

@cache { kind: "public", max_age_seconds: 300 }

@session cart_id = "abc123"
@cookie theme = "dark", path: "/", max_age_seconds: 2592000

14. Error pages

_errors/404.html.crisp
@include("../_partials/_layout-open.html.crisp")
<h1>Page not found</h1>
<p>Nothing lives at {{= request.path }}. <a href="/">Go home</a>.</p>
@include("../_partials/_layout-close.html.crisp")

Handlers exist for 400, 403, 404, 405, 413, 429, 500 and default. They get a safe, secret-free context.

15. Gotchas

  • ASCII only in .crisp source. Use &amp;mdash; &amp;rarr; &amp;copy; etc. — a stray &mdash; byte will fail the build.
  • Use of in @for, not in.
  • Keep @if / @for / @switch blocks multi-line; put the closing brace and } @else { on their own lines.
  • @include is build-time and takes no arguments.
  • A .md.crisp file can only include other .md.crisp files.
  • Inside <script> and <style>, directives are ignored but {{ ... }} is still interpolated — avoid literal double braces there.
  • Missing keys evaluate to null; guard with exists() / empty().

16. A complete minimal site

site/
  crisptastic.toml
  index.html.crisp
  about.html.crisp
  blog/index.html.crisp
  blog/[slug].html.crisp
  sitemap.xml.crisp
  _partials/_layout-open.html.crisp
  _partials/_layout-close.html.crisp
  _partials/_about-body.md.crisp
  _errors/404.html.crisp
  assets/style.scss
index.html.crisp
@include("_partials/_layout-open.html.crisp")

<h1>{{= context.env.SITE_NAME }}</h1> <p>Notes and things.</p>

<ul> @for (p of [ { "title": "Hello world", "slug": "hello-world" }, { "title": "Second post", "slug": "second-post" } ]) { <li><a href="/blog/{{= p.slug }}">{{= p.title }}</a></li> } </ul>

@include("_partials/_layout-close.html.crisp")

That is a real, deployable Crisptastic site. Add a data capability when the post list should come from a CMS instead of a literal array — the page structure does not change.