Skip to content

frogfishio/crisp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

70 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Burn your pages to crisp

Crisp is a template-to-source compiler.

License: GPL-3.0-or-later Libraries: MIT Backends Status

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.


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.


Why It Is Interesting

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.


What Crisp Looks Like

<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 ./generated

The 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.rs plus mod.rs
  • JS batch output emits one shared crisp_runtime.mjs
  • JS batch output emits crisp_sda.mjs only if some template actually uses SDA
  • Java batch output emits one shared CrispRuntime.java
  • C batch output emits crisp_runtime.h, crisp_runtime.c, and crisp_templates.h

From Template To Code

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

What Exists Today

Crisp already has a working compiler prototype and multiple real backends.

Current implemented surface:

  • {{ symbol.path }}
  • exists(...) for presence checks in expressions and conditions
  • empty(...), str(...), num(...), and bool(...) for generic-data coercion and hygiene
  • numeric indexers in expression backends such as items[0] and users[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 @code and 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 @code and 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:

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 crispc binary from build.rs
  • use CRISPC_BIN when 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 .crisp file 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.


Status

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:

The older files:

should be read as design exploration, not as a frozen specification.


How It Works

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
Loading

The key architectural rule is simple:

the compiler is the product; the runtime is just support code for generated output.


Quick Start

From a release package:

./bin/crispc demo/node-login/templates/login.html.crisp --dialect js

Useful commands:

./bin/crispc --help
./bin/crispc --version
./bin/crispc --license

Example 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.c

Custom 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 "]]"

Demo Tour

JavaScript examples

Compile and run the example templates:

./scripts/build_examples.sh
node ./scripts/run_examples.mjs

Node demo

demo/node-login

cd demo/node-login
npm run build
npm run start

Open http://localhost:3000 and use admin / crisp.

Rust demo

demo/rust-login

cd demo/rust-login
cargo run

This 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.

Java Tomcat demo

demo/java-tomcat

cd demo/java-tomcat
CATALINA_HOME=/path/to/tomcat ./build.sh

This builds a traditional WAR with Crisp-generated Java template classes and a small servlet.

C CivetWeb demo

demo/c-civetweb

cd demo/c-civetweb
CIVETWEB_ROOT=/path/to/civetweb ./build.sh
./build/crisp-civetweb-demo

Open http://127.0.0.1:8081 and use admin / crisp.


The Core Design Choice

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.


CLI And Distribution

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.java

There is also release housekeeping support:

make bump
make dist

make 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/crispc
  • dist/demo
  • dist/USER_GUIDE.md
  • dist/RUST_GUIDE.md

and the binary version string comes from the project’s VERSION and BUILD files.


Licensing

The licensing split is intentional:

  • crispc and 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.


Why Now

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.


Roadmap Direction

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, and civetweb starter 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.


Why This Project Might Matter

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

Releases

No releases published

Packages

 
 
 

Contributors