Crisp is a template-to-source compiler.
Crisp is easiest to understand like this:
- Handlebars-shaped in authoring feel
- JSP-shaped in compilation model
- native source code as the output
For the interpreter/server-side execution profile, see COMPATIBILITY.md.
So instead of:
- interpreting templates at runtime
- or precompiling into a framework-specific artifact
Crisp takes a mostly-literal template and emits ordinary host-language code:
.js.rs.java.c
If JSP compiled into servlets felt like the right idea, but you wanted that idea rebuilt for modern languages with a much smaller surface area, that is the pitch.
Most templating systems stop at one of two places:
- they interpret templates at runtime
- they precompile, but still into a framework-specific runtime format
Crisp pushes all the way through.
It takes a .crisp template and emits normal host-language source:
- C
- Rust
- JavaScript
- Java
That means the thing you ship, read, debug, profile, and build is not a hidden template artifact. It is just code.
If you like the ergonomics of Handlebars-style authoring, but you want the result to feel more like a compiler output than a templating engine, that is the space Crisp is aiming at.
Another way to say it:
Crisp is closer to JSP compiling into servlets than to a runtime template engine.
That mental model has turned out to be the right one for the project.
Crisp has a narrow thesis:
- author templates as mostly-literal text
- keep the language surface small
- compile ahead of time
- emit readable target-language code
- keep runtime support tiny and explicit
That is attractive for a few reasons:
- generated output can be inspected and reasoned about
- runtime costs are visible instead of magical
- the host toolchain stays in charge
- different backends can feel native in their own ecosystems
For JavaScript, that means plain JS modules. For Rust, that means ordinary Rust source and either flexible JSON-shaped context or typed context. For Java, that means servlet-shaped code. For C, that means plain C source targeting a reusable C runtime.
Another part is becoming more important as Crisp grows:
generic system data is almost never in the shape the page actually wants.
The current JS reference path is starting to attack that directly.
This:
<script>
const headers = {{~ sda(ctx.headers, "A |> normalizeUnique(_)") | json }};
</script>takes messy keyed input, normalizes it, serializes it, and hands it to browser code in one line.
And this:
<ul>
@for (value of sda(ctx.headers, "A |> normalizeUnique(_) |> values(_)")) {
<li>{{ value }}</li>
}
</ul>shapes the same input and renders it directly into HTML.
That is the real V2 bet:
- SDA shapes data
- Crisp renders it
- the helper/transformer graveyard shrinks dramatically
The strongest current example is a deliberately ugly joined-order payload in examples/data/nightmare_order.json.
The input looks like the kind of sludge people really get back from joins and aggregates:
{
"order": {
"customer_rows": [
{ "key": "id", "value": "cust_1001" },
{ "key": "name", "value": "Ada Lovelace" },
{ "key": "email", "value": "ada@example.com" },
{ "key": "email", "value": "ada@example.com" }
],
"shipping_rows": [
{ "key": "line1", "value": "42 Analytical Engine Way" },
{ "key": "city", "value": "la" },
{ "key": "country", "value": "US" }
]
}
}And the page can kill it in one block:
<script>
const customer = {{~ sda(ctx.order.customer_rows, "A |> normalizeFirst(_)") | json }};
const shipping = {{~ sda(ctx.order.shipping_rows, "A |> normalizeUnique(_)") | json }};
const payment = {{~ sda(ctx.order.payment_rows, "A |> normalizeLast(_)") | json }};
const featuredCities = {{~ sda(ctx.order_lines, "{ yield row<shipping_city> | row in A | row<payment_status> = \"captured\" and row<customer_active> = true and row<shipping_city> in Set{\"la\",\"ny\"} and row<line_qty> > 1 }") | json }};
</script>That is the sales pitch for SDA in Crisp. Real ugly data in, page-shaped data out, without writing a page-specific transformer module.
<h1>Hello {{ user.name }}</h1>
@if (user.is_admin) {
<p>Admin</p>
} @else {
<p>User</p>
}
<ul>
@for (item of featured.products) {
<li>{{ item }}</li>
}
</ul>And then:
crispc page.html.crisp --dialect rust
crispc page.html.crisp --dialect c
crispc page.html.crisp --dialect js
crispc page.html.crisp --dialect java
crispc templates/ --dialect rust --out ./generated
crispc page1.crisp page2.crisp --dialect js --out ./generatedThe output is an actual .rs, .c, .js, or .java file.
In batch mode, Crisp can also emit shared support only when it is needed:
- Rust batch output emits one
crisp_runtime.rsplusmod.rs - JS batch output emits one shared
crisp_runtime.mjs - JS batch output emits
crisp_sda.mjsonly if some template actually uses SDA - Java batch output emits one shared
CrispRuntime.java - C batch output emits
crisp_runtime.h,crisp_runtime.c, andcrisp_templates.h
This:
<h1>Hello {{ user.name }}</h1>can become JavaScript like:
export function render(ctx) {
let out = "";
out += "<h1>Hello ";
out += escapeHtml(lookupPath(ctx, ["user", "name"]));
out += "</h1>";
return out;
}or Rust like:
pub fn render_page(ctx: &Value) -> String {
let mut out = String::new();
out.push_str("<h1>Hello ");
write_escaped_value(&mut out, lookup_path(ctx, &["user", "name"]));
out.push_str("</h1>");
out
}The exact details vary by backend, but the point stays the same:
- the output is ordinary host-language source
- the control flow becomes host-language control flow
- the runtime support stays small and explicit
Crisp already has a working compiler prototype and multiple real backends.
Current implemented surface:
{{ symbol.path }}exists(...)for presence checks in expressions and conditionsempty(...),str(...),num(...), andbool(...)for generic-data coercion and hygiene- numeric indexers in expression backends such as
items[0]andusers[3].name - ordinary arithmetic, comparison, logical expressions, and grouping in JS, Rust, and Java backends
{{= expr }}{{~ expr }}@if (...) { ... }@else { ... }@for (item of source) { ... }@include("path")@code { ... }- template comments
- configurable delimiters
Current JS reference additions:
- built-in pipe transforms:
json,upper,lower,trim - custom pipe targets via
@code - helper predicates declared in
@codeand used in@if (...) sda(...)embedding in generated JavaScript- raw JSON handoff patterns such as
{{~ data | json }} - SDA handoff patterns such as
{{~ sda(ctx.data, "...") | json }}
Current Rust V2 additions:
- built-in pipe transforms:
json,upper,lower,trim - custom pipe targets by ordinary Rust function name
- helper predicates declared in
@codeand used in@if (...) - raw JSON handoff patterns such as
{{~ user.name | json }}
Rust now supports {{~ ... }}, built-in pipes, custom pipes, and sda(...).
One major track now matters for Rust adoption:
- optional typed context, so Crisp can match Askama where compile-time guarantees matter without losing the flexible JSON-shaped path
The design note for that is here:
- Typed Context For Crisp
- Crisp Rust Guide
- Crisp Java Guide
- Crisp C Guide
- Backend Output Showcase
- Example Showcase
The first bridge is already in:
--context-manifest <file>emits the symbol-path contract a template set expects--strict-context <file>fails the compile if current templates drift from that saved contract--context-type <rust::Type>enables typed Rust codegen for a single template--typed-context-manifest <file>enables typed Rust codegen for a batch compile--cargo-deps <file>emits the full template/include dependency set for Cargo rebuild tracking
For Rust integration from a packaged dist/, the recommended path is now binary-first:
- invoke the bundled
crispcbinary frombuild.rs - use
CRISPC_BINwhen the binary lives elsewhere - use source-checkout fallback only when Crisp is actually embedded as source
Directory batch mode treats only entry templates as compile targets by default:
- files under a
partials/directory are treated as include-only - files whose names start with
_are treated as include-only - manifest mode stays explicit and can still compile any
.crispfile you list
Current backends:
- JavaScript
- Rust
- Java
- C
Current demos:
- Node login demo
- Rust login demo
- Java Tomcat demo
- C CivetWeb demo
Not every backend is equally mature. JavaScript is the easiest and most forgiving reference backend. Rust is already useful as a runtime-design probe. Java proves the servlet-style mental model. C is the hardest backend and therefore the most revealing one.
Crisp is promising, but still early.
The project already proves the concept across multiple targets, but it is not pretending to be a finished production compiler yet.
What is real right now:
- the core compiler pipeline
- multiple working backends
- a reusable Rust runtime
- a reusable C runtime
- end-to-end demos
- a proper CLI
What is still evolving:
- backend-specific polish
- richer emitter/scaffolding generation beyond the first cross-backend starter set
- richer packaging/story for distribution
- bringing new V2 shaping features beyond the JS reference backend
If you want the current design direction, start with:
- USER_GUIDE.md
- Backend Feature Matrix
- Backend Output Showcase
- Example Showcase
- Context Conventions
- Crisp As A Wasm Service
- CONCEPT.md
- ARCHITECTURE.md
- V2.md
The older files:
should be read as design exploration, not as a frozen specification.
flowchart LR
A[".crisp template"] --> B["crispc"]
B --> C["include resolution"]
C --> D["lexer + parser"]
D --> E["AST / IR"]
E --> F["backend"]
F --> G["JavaScript"]
F --> H["Rust"]
F --> I["Java"]
F --> J["C"]
G --> K["native source file"]
H --> K
I --> K
J --> K
The key architectural rule is simple:
the compiler is the product; the runtime is just support code for generated output.
From a release package:
./bin/crispc demo/node-login/templates/login.html.crisp --dialect jsUseful commands:
./bin/crispc --help
./bin/crispc --version
./bin/crispc --licenseExample output targets:
./bin/crispc demo/node-login/templates/login.html.crisp --dialect js --out /tmp/login.js
./bin/crispc demo/rust-login/templates/login.html.crisp --dialect rust --out /tmp/login.rs
./bin/crispc demo/java-tomcat/templates/login.html.crisp --dialect java --out /tmp/LoginTemplate.java
./bin/crispc demo/c-civetweb/templates/login.html.crisp --dialect c --out /tmp/login.cCustom delimiters are supported for collision-heavy environments such as Vue:
./bin/crispc demo/node-login/templates/login.html.crisp --dialect js --open-delim "[[" --close-delim "]]"Compile and run the example templates:
./scripts/build_examples.sh
node ./scripts/run_examples.mjscd demo/node-login
npm run build
npm run startOpen http://localhost:3000 and use admin / crisp.
cd demo/rust-login
cargo runThis demo now uses:
- batch compilation from
crisp.manifest - typed Rust context mappings from
crisp.types - generated Rust modules in
OUT_DIR - plain Rust structs as template context
See demo/rust-login/README.md.
Open http://127.0.0.1:3001 and use admin / crisp.
cd demo/java-tomcat
CATALINA_HOME=/path/to/tomcat ./build.shThis builds a traditional WAR with Crisp-generated Java template classes and a small servlet.
cd demo/c-civetweb
CIVETWEB_ROOT=/path/to/civetweb ./build.sh
./build/crisp-civetweb-demoOpen http://127.0.0.1:8081 and use admin / crisp.
The most important thing about Crisp is what it refuses to do.
It is not trying to become:
- a universal runtime templating engine
- a giant abstraction layer across languages
- a fully portable expression language
- a framework that swallows the host ecosystem
Instead, it tries to do one thing well:
compile templates into normal source code for the target language.
That is why the project keeps coming back to the same values:
- compiler-first
- runtime-light
- host-native output
- readable generated code
That is also why the current mental model works so well:
Crisp = templates compiled into servlets/modules/renderers, not templates interpreted by an engine.
The CLI is now structured enough to feel like a real tool:
crispc --help
crispc --version
crispc page.html.crisp --dialect rust
crispc page.html.crisp --dialect java --out build/PageTemplate.javaThere is also release housekeeping support:
make bump
make distmake dist now acts like a release gate:
- runs the full test suite
- rebuilds and runs the example smoke flow
- only then produces the packaged binary and docs
That produces:
dist/bin/crispcdist/demodist/USER_GUIDE.mddist/RUST_GUIDE.md
and the binary version string comes from the project’s VERSION and BUILD files.
The licensing split is intentional:
crispcand core project files:GPL-3.0-or-later- libraries, runtimes, demos, examples:
MIT
See:
Generated output is not automatically claimed by the project license. In practical terms, the intent is the same as a compiler such as GCC: the output belongs to the user unless they choose to license it otherwise.
There is a gap between:
- runtime template engines
- macro-heavy compile-time systems
- handwritten render code
Crisp is trying to sit in that gap with a smaller, sharper proposition:
- template authoring that still feels like text
- compilation that is visible and debuggable
- output that belongs to the host language
If that sounds obvious, good. That is the point.
The next interesting work is not “add endless syntax.”
The next interesting work is:
- keep the CLI and packaging professional
- broaden emitter depth and polish beyond the current
nodejs,axum,tomcat, andcivetwebstarter set - tighten backend DX
- make generated code feel even more native per target
- keep the runtime contracts small and explicit
There are also deeper ideas for later:
- richer query-style traversal helpers
- more shaping/pipeline depth
- further emitter polish and framework integrations
Those are interesting, but the strength of Crisp so far has come from restraint.
There is a lot of software that wants templating without wanting a templating engine.
There is a lot of backend and systems code that would benefit from:
- pleasant template authoring
- ahead-of-time compilation
- native output
- minimal runtime cost
That is the opening Crisp is going after.
It is a small idea, but it sits in a surprisingly powerful spot.
If the project keeps its discipline, it can end up being one of those tools that feels obvious in hindsight:
of course templates can compile straight into normal code