Stream: show and tell

Topic: mustache template preprocessor


view this post on Zulip Zak Kohler (Sep 10 2026 at 16:06):

https://github.com/y2kbugger/roc-templegen

zig-based code-gen tool to enable writing mustache template that are performant and compile time type checked.

Convert

<!doctype html>
<html>
    <head>
        <title>{{ title }}</title>
    </head>
    <body>
        {{> Nav}}
        <h1>{{ title | upper }}</h1>
        <p>{{ intro }}</p>
        <ul>
            {{#features}}
            <li><b>{{ name }}</b>: {{ blurb | truncate 60 }}</li>
            {{/features}}
        </ul>
        {{{ footer_html }}}
    </body>
</html>

into a typed stringbuilder

# Generated by mustache-roc from templates/Home.mustache.html. DO NOT EDIT.
import ../Formatters
import Nav

## Compiled from `templates/Home.mustache.html`. Call `Home.render` with a `Home.Ctx`.
Home :: [].{

    ## The context record `render` expects.
    Ctx : { features : List({ blurb : Str, name : Str }), footer_html : Str, intro : Str, nav : List({ active : Bool, href : Str, label : Str }), title : Str }

    ## Pins a record literal to `Ctx` (handy where `Ctx` cannot be named in an annotation).
    ctx : Ctx -> Ctx
    ctx = |record| record

    ## Renders the template. Text values are HTML-escaped with `Formatters.escape`.
    render : Ctx -> Str
    render = |context| {
        var $out = Str.with_capacity(1024)
        $out = push($out, "<!doctype html>\n<html>\n<head><title>")
        $out = push($out, Formatters.escape(context.title))
        $out = push($out, "</title></head>\n<body>\n")
        $out = push($out, Nav.render({ nav: context.nav }))
        $out = push($out, "<h1>")
        $out = push($out, Formatters.escape(Formatters.upper(context.title)))
        $out = push($out, "</h1>\n<p>")
        $out = push($out, Formatters.escape(context.intro))
        $out = push($out, "</p>\n<ul>\n")
        for item in context.features {
            $out = push($out, "  <li><b>")
            $out = push($out, Formatters.escape(item.name))
            $out = push($out, "</b>: ")
            $out = push($out, Formatters.escape(Formatters.truncate(item.blurb, 60)))
            $out = push($out, "</li>\n")
        }
        $out = push($out, "</ul>\n")
        $out = push($out, context.footer_html)
        $out = push($out, "\n</body>\n</html>\n")
        $out
    }
}

## Appends `s` to `out`, growing capacity geometrically.
push : Str, Str -> Str
push = |out, s| {
    len = out.count_utf8_bytes()
    need = len + s.count_utf8_bytes()
    out.reserve(next_capacity(need) - len).concat(s)
}

## Smallest power of two that is at least `n` (and at least 64).
next_capacity : U64 -> U64
next_capacity = |n| {
    var $cap = 64
    while $cap < n {
        $cap = $cap * 2
    }
    $cap
}

benchmarks of both codegen and rendering seem good.

Is there any way to hook into the official build for stuff like this, so that it just comes with a platform?

view this post on Zulip Anton (Sep 11 2026 at 12:07):

Cool project @Zak Kohler!

@Isaac Van Doren worked on something similar a year ago: Roc Template Language. It was made with the old Roc compiler though.

Is there any way to hook into the official build for stuff like this, so that it just comes with a platform?

I feel like this makes perfect sense as a standalone binary, just like Isaac's project.
You could also make this in pure Roc and make it available as a Roc package (example) so it can easily be used with any platform.

view this post on Zulip Zak Kohler (Sep 11 2026 at 20:57):

Also trying to attack this from another angle.

I also just prototyped up a "comp-time" version.

Instead of generating Roc, it parses and validates the Mustache source at compile time: tpl = templates.template("Home").check(home_ctx) is a top-level definition, so Roc constant-folds it; check reads the sample record's fields and types through its derived encoder_for and crashes on a mismatch, which surfaces as a roc check error.

It is about half as slow for small templates. nearly equal for large templates.

view this post on Zulip Zak Kohler (Sep 11 2026 at 21:05):

Is the comptime thing based on string imports an anti-pattern? I wonder if the compiler does/ could do the needed caching on the template string to make recompilation/check small is there are no template changes.

This would be amazing with --watch if it all just worked.

view this post on Zulip Luke Boswell (Sep 11 2026 at 21:35):

I would think the comptime thing is not an anti-pattern, but everything here is a little experimental

view this post on Zulip Luke Boswell (Sep 11 2026 at 21:36):

One tradeoff I can think of is at comptime you are running using the dev backend not optimized etc ... but for parsing text that should be way fast enough I think.

view this post on Zulip Luke Boswell (Sep 11 2026 at 21:37):

Does it not work with --watch? I would assume that just works...

view this post on Zulip Zak Kohler (Sep 11 2026 at 22:34):

Benchmark

master (codegen) runtime-templates
roc check cold / warm 0.23 s / 0.054 s 0.64 s / 0.058 s
roc build cold 8.2 s 22.4 s
binary (static musl) 6.6 MB 7.3 MB
startup to first response 6 ms 7 ms
todos page render (609 B) 5-7 µs 57-60 µs
1000-row table render (122 KB) 2.4 ms 3.0-3.3 ms
/todos, c=200 32.6k req/s, p99 16 ms 14.2k req/s, p99 35 ms
/table/1000, c=64 344 req/s 297 req/s

Luke Boswell said:

Does it not work with --watch? I would assume that just works...

oh wow it does. I didn't know we actually had --watch already. I thought it was a "future todo"...
image.png

view this post on Zulip Zak Kohler (Sep 11 2026 at 22:35):

See here: https://github.com/y2kbugger/roc-templegen/tree/runtime-templates

view this post on Zulip Zak Kohler (Sep 11 2026 at 22:36):

And it doesn't seem to affect the warm check speed either.

view this post on Zulip Richard Feldman (Sep 11 2026 at 23:01):

yeah this is cool! I'd thought about the comptime string templates thing in the past, but I think you're the first to actually implement it! :smiley:

view this post on Zulip Richard Feldman (Sep 11 2026 at 23:02):

another thing you can do is put the string in a separate file and import it - that way you can give it whatever file extension you like for syntax highlighting purposes

view this post on Zulip Zak Kohler (Sep 12 2026 at 00:16):

Richard Feldman said:

another thing you can do is put the string in a separate file and import it

That's exactly what I'm doing. I'm impressed that the --watch picked up mutations to the the external files perfectly

view this post on Zulip Zak Kohler (Sep 12 2026 at 02:57):

--watch demo with polled fragment

roc_templates_watch.webm

view this post on Zulip Zak Kohler (Sep 13 2026 at 21:55):

The more I dig into the comptime implementation, the worst it's ergonomics look. Without a true zig-like comptime that could return a type, the checking relies on setting up dummy sample data and checking that encoder at compile time... Not great...

I wonder if there's a way to do comptime/preprocessors constrained to only non-roc -> roc code. Perhaps there is even a way to do it so rigorously with a processing language that you could get LSP of context vars tracked into the source? This could be useful for templates or even possibly DSLs.

view this post on Zulip Zak Kohler (Sep 13 2026 at 22:18):

https://roc.zulipchat.com/#narrow/channel/231634-beginners/topic/Hook.20for.20hot.20reload/with/622960673

Just a link to a related idea for the preprocessor style. Maybe it's not so crazy to have the platform watch the templates, and regenerate Roc code on demand.

view this post on Zulip Luke Boswell (Sep 13 2026 at 23:05):

Zak Kohler said:

Without a true zig-like comptime that could return a type, the checking relies on setting up dummy sample data and checking that encoder at compile time... Not great...

Could expand on this, I don't quite follow you here

view this post on Zulip Zak Kohler (Sep 14 2026 at 01:19):

# main.roc: the production data doubles as the sample
todos_ctx = {
    nav: nav_for("/todos"),
    remaining: 2.U64,          # suffix, or it's a Dec and `count` fails the check
    total: 3.U64,
    todos: [                   # must be non-empty or the loop body can't be checked
        { title: "Write a <template> compiler in Zig", due: "yesterday", tags: ["zig", "codegen"], done: Bool.True },
        ...
    ],
}
todos_tpl = templates.template("Todos").check(todos_ctx)

view this post on Zulip Luke Boswell (Sep 14 2026 at 01:41):

Could you use default values for the record?

view this post on Zulip Anton (Sep 14 2026 at 13:31):

@Zak Kohler would adding a type annotation above todos_ctx solve your problem? For example:

todos_ctx : { nav: Nav, remaining: U64, total: U64, todos: List({ title: Str, due: Str, ....})}
todos_ctx = {
    nav: nav_for("/todos"),
    remaining: 2,          # suffix, or it's a Dec and `count` fails the check
    total: 3,
    ...
}

view this post on Zulip Dan G Knutson (Sep 14 2026 at 18:06):

I'm also currently planning to do something like this with string templates, but something like F# type providers would be super welcome if it's in scope.

Even if it was a platform-only compiler API thing kind of like what typescript has instead of F# userland thing, it would be nice to not go through source code strings.

view this post on Zulip Richard Feldman (Sep 14 2026 at 19:17):

hm, I don't follow - can you say more about the use case and the pain point?

view this post on Zulip Dan G Knutson (Sep 14 2026 at 21:37):

I'm not far enough along to call it a pain point yet, and I can't speak for Zak, but here's the kind of thing I'm thinking of.

I have a shader defined in Slang that expects a specific type of data to be bound in a specific format. We can use Slang's compiler reflection api to get that type information during a build. The idea is to generate a matching Roc file for using the shader in a type-safe way. Roc's encode/decode addresses "this shader expects std 140 not std 430" but it doesn't address "this shader expects a buffer of material structs with these fields".

A simpler example of a similar thing I did with the old Roc compiler, was generating a typed sprite sheet from the json and image exported from Aseprite. From the image and associated metadata, we can generate a type-safe module for using the offsets or referencing animations created in Aseprite in a more natural way.

In both of these cases, my idea is to have some kind of platform cli watching the asset files (shader or aseprite sheet/json, in these cases) and generating a matching Roc file to trigger hot reload. This is already possible with string templates.

view this post on Zulip Zak Kohler (Sep 15 2026 at 02:23):

image.png

I'm going down the path of codegen a bit more, seems to work nicer with tooling.

pretty cool i can get nested LSP for the templates already.

working on a codegen for sqlite ala sqlc to go along with it, and i am getting sub-second reloads with --watch right now. very cool.


Last updated: Sep 24 2026 at 15:59 UTC