DEV Community

Milky
Milky

Posted on

AAoM-01: Pug Template Engine

December 2025

Let's kick off the Agentic Adventures of MoonBit series with a complete Pug template engine. I'm using Claude Code (Opus 4.5) with the MoonBit system prompt and IDE skill. The workflow is simple: I describe what I want, Claude writes the code, and I review and refine.

Claude excels at bootstrapping MoonBit projects with moon new, understanding the package structure (moon.mod.json, moon.pkg.json), and generating idiomatic code after a few iterations.

Problem

HTML is verbose. Writing nested structures by hand is tedious and error-prone. Pug (formerly Jade) solves this with a clean, indentation-based syntax:

doctype html
html
  head
    title My Site
  body
    h1#greeting.hero Hello, World!
Enter fullscreen mode Exit fullscreen mode

The goal: implement a Pug-to-HTML compiler in MoonBit that supports the full specification—tags, attributes, interpolation, conditionals, loops, mixins, includes, and extends.

Aproach

I had Claude read all specifications from pugjs.org and write corresponding tests first.

The tests looked like:

test "case with fall through" {
  let pug =
    #|case num
    #|  when 0
    #|  when 1
    #|  when 2
    #|    p Small number
    #|  default
    #|    p Large number
  let locals = Locals::new()
  locals.set("num", "1")
  let html = render_with_locals(pug, locals)
  inspect(html, content="<p>Small number</p>")
}
Enter fullscreen mode Exit fullscreen mode

There were 153 tests spread across 18 blackbox test files. This test-driven approach caught edge cases early.

When Claude started to implement the library, I reviewed the tests. The implementation follows a classic compiler architecture: lexer.mbt, parser.mbt and render.mbt.

After the core features worked, Claude stopped and told me there remained features like includes/extends because of Claude does not know how to perform file system operations. I introduced @moonbitlang/x/fs for file system access. Claude's first API was awkward:

let registry = TemplateRegistry::new()
registry.register("includes/head.pug", @fs.read_file_to_string("example/includes/head.pug"))
registry.register("includes/foot.pug", @fs.read_file_to_string("example/includes/foot.pug"))
let html = render_with_registry(source, Locals::new(), registry)
Enter fullscreen mode Exit fullscreen mode

I pointed this out and asked for improvement. The result was much cleaner:

let html = render_file("example/index.pug")
Enter fullscreen mode Exit fullscreen mode

The render_file function now automatically discovers and loads dependencies, resolving paths relative to the input file.

The hardest challenge is JS expressions interpolation like #{msg.toUpperCase()}. To realize this, the template engine needs to evaluate JS expressions. We can directly use JS FFI for the JS backend. But I also want this library to be used on other backends (MoonBit compiles to multiple backends including WebAssembly, native and JavaScript). While Claude was trying to implement a comprehensive JS interpreter, I interrupted Claude and told him to use extern "js" fn eval(expr : String) -> String = "(expr) => eval(expr)" (not exactly this, but roughly the idea) for the JS backend and abort with only supported on the JS backend message for non-JS backends.

Final task was to implement a command-line interface. The design and implementation was done by Claude and I did not give any suggestions on this task. I ran the CLI with different inputs quite a few times and made sure the output made sense.

Results

A fully functional Pug template engine with:

  • Tags, IDs, classes, and attributes
  • Nested elements via indentation
  • String interpolation (#{} and !{})
  • Conditionals (if, else if, else, unless)
  • Iteration (each, while)
  • Mixins with parameters and blocks
  • Template inheritance (include, extends, block)
  • CLI with JSON locals and directory processing

All tests pass: 140 general tests and 13 js-only tests.

CLI example session:

$ echo '{"name": "World"}' > data.json
$ echo 'h1 Hello #{name}!' > greeting.pug
$ moon run cmd/main -- -O data.json greeting.pug
<h1>Hello World!</h1>
Enter fullscreen mode Exit fullscreen mode

Reusable templates with the compile API:

test "compile api" {
  // Compile template once
  let template = @pug.compile("p Hello #{name}!")

  // Render with different locals
  let locals1 = @pug.Locals::new()
  locals1.set("name", "Alice")
  inspect(template.render(locals1), content="<p>Hello Alice!</p>")
  let locals2 = @pug.Locals::new()
  locals2.set("name", "Bob")
  inspect(template.render(locals2), content="<p>Hello Bob!</p>")
}
Enter fullscreen mode Exit fullscreen mode

The API now is very clear and convenient to use, just like the official pug implementation.

Reflections

Test-driven development works very well. Having Claude read pugjs.org docs and write tests first caught issues early. MoonBit's pattern matching makes AST processing clean and exhaustive.

The challenges lies in MoonBit features or conventions which Claude is not very clear about. For example, Claude does not know the conventional way to access file system and is prone to write anti-patterns like match (try? expr) { Ok(_) => ...; Err(_) => ...}, which may be due to the Opus model's unfamiliarity with the latest MoonBit syntax and best practices. This information has been updated in my commonly used moonbit-lang SKILL, and its effectiveness will be tested in the subsequent AAoM series.

A practical way I have found to improve the project is to ask Claude "What Pug features are still missing?" Claude will explore the whole library, list the possible missing features, write a todo list, and then implement them. I only need to check one last time to make sure the tests match the examples on the official pug website.

The process takes roughly 6 hours of active development:

  • several minutes to generate the tests,
  • nearly 3 hours for core features,
  • 1 hour for include/extends,
  • 2 hours for JS interpolation and several minutes for CLI.

Code is available on github.

Top comments (0)