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
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)
| Suffix | Route | Type |
|---|---|---|
.html.crisp | as shown above | HTML |
.md.crisp | same, but body is rendered from Markdown | HTML |
.json.crisp | .json kept | JSON |
.txt.crisp | .txt kept | text |
.xml.crisp | .xml kept | XML |
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)
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:
@include("_partials/_layout-open.html.crisp")
<h1>Contact</h1>
<p>Say hello.</p>
@include("_partials/_layout-close.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>
</main>
<footer>© {{= 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:
@include("_partials/_layout-open.html.crisp")
@include("_partials/_about-body.md.crisp")
@include("_partials/_layout-close.html.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 -> /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
@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
<?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>
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
@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
.crispsource. Use&mdash;&rarr;&copy;etc. — a stray—byte will fail the build. - Use
ofin@for, notin. - Keep
@if/@for/@switchblocks multi-line; put the closing brace and} @else {on their own lines. @includeis build-time and takes no arguments.- A
.md.crispfile can only include other.md.crispfiles. - Inside
<script>and<style>, directives are ignored but{{ ... }}is still interpolated — avoid literal double braces there. - Missing keys evaluate to
null; guard withexists()/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
@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.