- Renamed std.ArrayListUnmanaged to std.ArrayList across all source files - Updated CLAUDE.md with Zig version standards rule - Removed debug print from mixin test - No API changes (allocator still passed to methods)
16 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Purpose
Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It compiles Pug templates directly to HTML (unlike JS pug which compiles to JavaScript functions). It implements Pug 3 syntax for indentation-based HTML templating with a focus on server-side rendering.
Rules
- Do not auto commit, user will do it.
- At the start of each new session, read this CLAUDE.md file to understand project context and rules.
- When the user specifies a new rule, update this CLAUDE.md file to include it.
- Code comments are required but must be meaningful, not bloated. Focus on explaining "why" not "what". Avoid obvious comments like "// increment counter" - instead explain complex logic, non-obvious decisions, or tricky edge cases.
- All documentation files (.md) must be saved to the
docs/directory. Do not create .md files in the root directory or examples directories - always place them indocs/. - Follow Zig standards for the version specified in
build.zig.zon(currently 0.15.2). This includes:- Use
std.ArrayList(T)instead of the deprecatedstd.ArrayListUnmanaged(T)(renamed in Zig 0.15) - Pass allocator to method calls (
append,deinit, etc.) as per the unmanaged pattern - Check Zig release notes for API changes when updating the minimum Zig version
- Use
- Publish command: Only when user explicitly says "publish", do the following:
- Bump the fix version (patch version in build.zig.zon)
- Git commit with appropriate message
- Git push to remote
originand remotegithub
- Do NOT publish automatically or without explicit user request.
Build Commands
zig build- Build the project (output inzig-out/)zig build test- Run all testszig build test-compile- Test the template compilation build stepzig build bench-v1- Run v1 template benchmarkzig build bench-interpreted- Run interpreted templates benchmark
Architecture Overview
Compilation Pipeline
Source → Lexer → Tokens → StripComments → Parser → AST → Linker → Codegen → HTML
Three Rendering Modes
- Static compilation (
pug.compile): Outputs HTML directly viacodegen.zig - Data binding (
template.renderWithData): Supports#{field}interpolation with Zig structs viatemplate.zig - Compiled templates (
.pug→.zig): Pre-compile templates to Zig functions viazig_codegen.zig
Important: Shared AST Consumers
codegen.zig, template.zig, and zig_codegen.zig all consume the AST from the parser. When fixing bugs related to AST structure (like attribute handling, class merging, etc.), prefer fixing in parser.zig so all three rendering paths benefit from the fix automatically. Only fix in the individual codegen modules if the behavior should differ between rendering modes.
Shared Utilities in runtime.zig
The runtime.zig module is the single source of truth for shared utilities used across all rendering modes:
isHtmlEntity(str)- Checks if string starts with valid HTML entity (&name;,&#digits;,&#xhex;)appendTextEscaped(allocator, output, str)- Escapes text content (<,>,&) preserving existing entitiesisXhtmlDoctype(val)- Checks if doctype is XHTML (xml, strict, transitional, frameset, 1.1, basic, mobile)escapeChar(c)- O(1) lookup table for HTML character escapingappendEscaped(allocator, output, str)- Escapes all HTML special chars including quotesdoctypes- StaticStringMap of doctype names to DOCTYPE stringswhitespace_sensitive_tags- Tags where whitespace matters (pre, textarea, script, style, code)
The codegen.zig module provides:
void_elements- StaticStringMap of HTML5 void/self-closing elements (br, img, input, etc.)
Core Modules
| Module | File | Purpose |
|---|---|---|
| Lexer | src/lexer.zig |
Tokenizes Pug source into tokens |
| Parser | src/parser.zig |
Builds AST from tokens |
| Runtime | src/runtime.zig |
Shared utilities (HTML escaping, entity detection, doctype helpers) |
| Error | src/error.zig |
Error formatting with source context |
| Walk | src/walk.zig |
AST traversal with visitor pattern |
| Strip Comments | src/strip_comments.zig |
Token filtering for comments |
| Load | src/load.zig |
File loading for includes/extends |
| Linker | src/linker.zig |
Template inheritance (extends/blocks) |
| Codegen | src/codegen.zig |
AST to HTML generation |
| Template | src/template.zig |
Data binding renderer |
| Pug | src/pug.zig |
Main entry point |
| ViewEngine | src/view_engine.zig |
High-level API for web servers |
| ZigCodegen | src/tpl_compiler/zig_codegen.zig |
Compiles .pug AST to Zig functions |
| CompileTpls | src/compile_tpls.zig |
Build step for compiling templates at build time |
| Root | src/root.zig |
Public library API exports |
Test Files
- tests/general_test.zig - Comprehensive integration tests
- tests/doctype_test.zig - Doctype-specific tests
- tests/check_list_test.zig - Template output validation tests
API Usage
Static Compilation (no data)
const std = @import("std");
const pug = @import("pugz").pug;
pub fn main() !void {
const allocator = std.heap.page_allocator;
var result = try pug.compile(allocator, "p Hello World", .{});
defer result.deinit(allocator);
std.debug.print("{s}\n", .{result.html}); // <p>Hello World</p>
}
Dynamic Rendering with Data
const std = @import("std");
const pugz = @import("pugz");
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const html = try pugz.renderTemplate(arena.allocator(),
\\h1 #{title}
\\p #{message}
, .{
.title = "Welcome",
.message = "Hello, World!",
});
std.debug.print("{s}\n", .{html});
// Output: <h1>Welcome</h1><p>Hello, World!</p>
}
Data Binding Features
- Interpolation:
#{fieldName}in text content - Attribute binding:
a(href=url)bindsurlfield to href - Buffered code:
p= messageoutputs themessagefield - Auto-escaping: HTML is escaped by default (XSS protection)
const html = try pugz.renderTemplate(allocator,
\\a(href=url, class=style) #{text}
, .{
.url = "https://example.com",
.style = "btn",
.text = "Click me!",
});
// Output: <a href="https://example.com" class="btn">Click me!</a>
Compiled Templates (Maximum Performance)
For production deployments where maximum performance is critical, you can pre-compile .pug templates to Zig functions using a build step:
Step 1: Add build step to your build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Add pugz dependency
const pugz_dep = b.dependency("pugz", .{
.target = target,
.optimize = optimize,
});
const pugz = pugz_dep.module("pugz");
// Add template compilation build step
const compile_templates = @import("pugz").addCompileStep(b, .{
.name = "compile-templates",
.source_dirs = &.{"src/views", "src/pages"}, // Can specify multiple directories
.output_dir = "generated",
});
const exe = b.addExecutable(.{
.name = "myapp",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("pugz", pugz);
exe.root_module.addImport("templates", compile_templates.getOutput());
exe.step.dependOn(&compile_templates.step);
b.installArtifact(exe);
}
Step 2: Use compiled templates in your code
const std = @import("std");
const tpls = @import("templates"); // Import from build step
pub fn handleRequest(allocator: std.mem.Allocator) ![]const u8 {
// Access templates by their path: views/pages/home.pug -> tpls.views_pages_home
return try tpls.views_home.render(allocator, .{
.title = "Home",
.name = "Alice",
});
}
// Or use layouts
pub fn renderLayout(allocator: std.mem.Allocator) ![]const u8 {
return try tpls.layouts_base.render(allocator, .{
.content = "Main content here",
});
}
How templates are named:
views/home.pug→tpls.views_homepages/about.pug→tpls.pages_aboutlayouts/main.pug→tpls.layouts_mainviews/user-profile.pug→tpls.views_user_profile(dashes become underscores)- Directory separators and dashes are converted to underscores
Performance Benefits:
- Zero parsing overhead - templates compiled at build time
- Type-safe data binding - compile errors for missing fields
- Optimized code - direct string concatenation instead of AST traversal
- ~10-100x faster than runtime parsing depending on template complexity
What gets resolved at compile time:
- Template inheritance (
extends/block) - fully resolved - Includes (
include) - inlined into template - Mixins - available in compiled templates
Trade-offs:
- Templates are regenerated automatically when you run
zig build - Includes/extends are resolved at compile time (no dynamic loading)
- Each/if statements not yet supported (coming soon)
ViewEngine (for Web Servers)
const std = @import("std");
const pugz = @import("pugz");
const engine = pugz.ViewEngine.init(.{
.views_dir = "src/views",
.extension = ".pug",
});
// In request handler
pub fn handleRequest(allocator: std.mem.Allocator) ![]const u8 {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
return try engine.render(arena.allocator(), "pages/home", .{
.title = "Home",
.user = .{ .name = "Alice" },
});
}
Compile Options
pub const CompileOptions = struct {
filename: ?[]const u8 = null, // For error messages
basedir: ?[]const u8 = null, // For absolute includes
pretty: bool = false, // Pretty print output
strip_unbuffered_comments: bool = true,
strip_buffered_comments: bool = false,
debug: bool = false,
doctype: ?[]const u8 = null,
};
Memory Management
Important: The runtime is designed to work with ArenaAllocator:
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit(); // Frees all template memory at once
const html = try pugz.renderTemplate(arena.allocator(), template, data);
This pattern is recommended because template rendering creates many small allocations that are all freed together after the response is sent.
Key Implementation Notes
Lexer (lexer.zig)
Lexer.init(allocator, source, options)- InitializeLexer.getTokens()- Returns token sliceLexer.last_error- Check for errors after failedgetTokens()
Parser (parser.zig)
Parser.init(allocator, tokens, filename, source)- InitializeParser.parse()- Returns AST root nodeParser.err- Check for errors after failedparse()
Codegen (codegen.zig)
Compiler.init(allocator, options)- InitializeCompiler.compile(ast)- Returns HTML string
Walk (walk.zig)
- Uses O(1) stack operations (append/pop) not O(n) insert/remove
getParent(index)uses reverse indexing (0 = immediate parent)initWithCapacity()for pre-allocation optimization
Runtime (runtime.zig)
escapeChar(c)- Shared HTML escape functionappendEscaped(list, allocator, str)- Append with escaping
Supported Pug Features
Tags & Nesting
div
h1 Title
p Paragraph
Classes & IDs (shorthand)
div#main.container.active
.box // defaults to div
#sidebar // defaults to div
Attributes
a(href="/link" target="_blank") Click
input(type="checkbox" checked)
div(style={color: 'red'})
div(class=['foo', 'bar'])
button(disabled=false) // omitted when false
button(disabled=true) // disabled="disabled"
Text & Interpolation
p Hello #{name} // escaped interpolation (SAFE - default)
p Hello !{rawHtml} // unescaped interpolation (UNSAFE - trusted content only)
p= variable // buffered code (escaped, SAFE)
p!= rawVariable // buffered code (unescaped, UNSAFE)
| Piped text line
p.
Multi-line
text block
<p>Literal HTML</p> // passed through as-is
Security Note: By default, #{} and = escape HTML entities (<, >, &, ", ') to prevent XSS attacks. Only use !{} or != for content you fully trust.
Tag Interpolation
p This is #[em emphasized] text
p Click #[a(href="/") here] to continue
Block Expansion
a: img(src="logo.png") // colon for inline nesting
Conditionals
if condition
p Yes
else if other
p Maybe
else
p No
unless loggedIn
p Please login
Iteration
each item in items
li= item
each val, index in list
li #{index}: #{val}
each item in items
li= item
else
li No items
Case/When
case status
when "active"
p Active
when "pending"
p Pending
default
p Unknown
Mixins
mixin button(text, type="primary")
button(class="btn btn-" + type)= text
+button("Click me")
+button("Submit", "success")
Includes & Inheritance
include header.pug
extends layout.pug
block content
h1 Page Title
Comments
// This renders as HTML comment
//- This is a silent comment (not in output)
Benchmark Results (2000 iterations)
| Template | Time |
|---|---|
| simple-0 | 0.8ms |
| simple-1 | 11.6ms |
| simple-2 | 8.2ms |
| if-expression | 7.4ms |
| projects-escaped | 7.1ms |
| search-results | 13.4ms |
| friends | 22.9ms |
| TOTAL | 71.3ms |
Limitations vs JS Pug
- No JavaScript expressions:
- var x = 1not supported - No nested field access:
#{user.name}not supported, only#{name} - No filters:
:markdown,:coffeeetc. not implemented - String fields only: Data binding works best with
[]const u8fields
Error Handling
Uses error unions with detailed PugError context including line, column, and source snippet:
LexerError- Tokenization errorsParserError- Syntax errorsViewEngineError- Template not found, parse errors
File Structure
├── src/ # Source code
│ ├── root.zig # Public library API
│ ├── view_engine.zig # High-level ViewEngine
│ ├── pug.zig # Main entry point (static compilation)
│ ├── template.zig # Data binding renderer
│ ├── compile_tpls.zig # Build step for template compilation
│ ├── lexer.zig # Tokenizer
│ ├── parser.zig # AST parser
│ ├── runtime.zig # Shared utilities
│ ├── error.zig # Error formatting
│ ├── walk.zig # AST traversal
│ ├── strip_comments.zig # Comment filtering
│ ├── load.zig # File loading
│ ├── linker.zig # Template inheritance
│ ├── codegen.zig # HTML generation
│ └── tpl_compiler/ # Template-to-Zig code generation
│ ├── zig_codegen.zig # AST to Zig function compiler
│ ├── main.zig # CLI tool (standalone)
│ └── helpers_template.zig # Runtime helpers template
├── tests/ # Integration tests
│ ├── general_test.zig
│ ├── doctype_test.zig
│ └── check_list_test.zig
├── benchmarks/ # Performance benchmarks
├── docs/ # Documentation
├── examples/ # Example templates
└── playground/ # Development playground