diff --git a/CLAUDE.md b/CLAUDE.md
index de9bc42..10f805a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -252,7 +252,7 @@ const pugz = @import("pugz");
// Initialize once at server startup
var engine = try pugz.ViewEngine.init(allocator, .{
.views_dir = "src/views", // Root views directory
- .mixins_dir = "mixins", // Auto-load mixins from views/mixins/ (optional)
+ .mixins_dir = "mixins", // Mixins dir for lazy-loading (optional, default: "mixins")
.extension = ".pug", // File extension (default: .pug)
.pretty = true, // Pretty-print output (default: true)
});
@@ -271,11 +271,22 @@ pub fn handleRequest(engine: *pugz.ViewEngine, allocator: std.mem.Allocator) ![]
}
```
+### Mixin Resolution (Lazy Loading)
+
+Mixins are resolved in the following order:
+1. **Same template** - Mixins defined in the current template file
+2. **Mixins directory** - If not found, searches `views/mixins/*.pug` files (lazy-loaded on first use)
+
+This lazy-loading approach means:
+- Mixins are only parsed when first called
+- No upfront loading of all mixin files at server startup
+- Templates can override mixins from the mixins directory by defining them locally
+
### Directory Structure
```
src/views/
-├── mixins/ # Auto-loaded mixins (optional)
+├── mixins/ # Lazy-loaded mixins (searched when mixin not found in template)
│ ├── buttons.pug # mixin btn(text), mixin btn-link(href, text)
│ └── cards.pug # mixin card(title), mixin card-simple(title, body)
├── layouts/
@@ -291,7 +302,7 @@ src/views/
Templates can use:
- `extends layouts/base` - Paths relative to views_dir
- `include partials/header` - Paths relative to views_dir
-- `+btn("Click")` - Mixins from mixins/ dir available automatically
+- `+btn("Click")` - Mixins from mixins/ dir loaded on-demand
### Low-Level API
diff --git a/build.zig b/build.zig
index 614dc57..34b561c 100644
--- a/build.zig
+++ b/build.zig
@@ -106,6 +106,26 @@ pub fn build(b: *std.Build) void {
const demo_step = b.step("demo", "Run the template inheritance demo web app");
demo_step.dependOn(&run_demo.step);
+ // ─────────────────────────────────────────────────────────────────────────
+ // Benchmark executable
+ // ─────────────────────────────────────────────────────────────────────────
+ const bench = b.addExecutable(.{
+ .name = "bench",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/benchmark.zig"),
+ .target = target,
+ .optimize = .ReleaseFast, // Always use ReleaseFast for benchmarks
+ }),
+ });
+
+ b.installArtifact(bench);
+
+ const run_bench = b.addRunArtifact(bench);
+ run_bench.step.dependOn(b.getInstallStep());
+
+ const bench_step = b.step("bench", "Run rendering benchmarks");
+ bench_step.dependOn(&run_bench.step);
+
// Just like flags, top level steps are also listed in the `--help` menu.
//
// The Zig build system is entirely implemented in userland, which means
diff --git a/src/benchmark.zig b/src/benchmark.zig
new file mode 100644
index 0000000..dabf339
--- /dev/null
+++ b/src/benchmark.zig
@@ -0,0 +1,388 @@
+//! Pugz Rendering Benchmark
+//!
+//! Measures template rendering performance with various template complexities.
+//! Run with: zig build bench
+//!
+//! Metrics reported:
+//! - Total time for N iterations
+//! - Average time per render
+//! - Renders per second
+//! - Memory usage per render
+
+const std = @import("std");
+const pugz = @import("root.zig");
+
+const Allocator = std.mem.Allocator;
+
+/// Benchmark configuration
+const Config = struct {
+ warmup_iterations: usize = 200,
+ benchmark_iterations: usize = 20_000,
+ show_output: bool = false,
+};
+
+/// Benchmark result
+const Result = struct {
+ name: []const u8,
+ iterations: usize,
+ total_ns: u64,
+ min_ns: u64,
+ max_ns: u64,
+ avg_ns: u64,
+ ops_per_sec: f64,
+ bytes_per_render: usize,
+ arena_peak_bytes: usize,
+
+ pub fn print(self: Result) void {
+ std.debug.print("\n{s}\n", .{self.name});
+ std.debug.print(" Iterations: {d:>10}\n", .{self.iterations});
+ std.debug.print(" Total time: {d:>10.2} ms\n", .{@as(f64, @floatFromInt(self.total_ns)) / 1_000_000.0});
+ std.debug.print(" Avg per render: {d:>10.2} us\n", .{@as(f64, @floatFromInt(self.avg_ns)) / 1_000.0});
+ std.debug.print(" Min: {d:>10.2} us\n", .{@as(f64, @floatFromInt(self.min_ns)) / 1_000.0});
+ std.debug.print(" Max: {d:>10.2} us\n", .{@as(f64, @floatFromInt(self.max_ns)) / 1_000.0});
+ std.debug.print(" Renders/sec: {d:>10.0}\n", .{self.ops_per_sec});
+ std.debug.print(" Output size: {d:>10} bytes\n", .{self.bytes_per_render});
+ std.debug.print(" Memory/render: {d:>10} bytes\n", .{self.arena_peak_bytes});
+ }
+};
+
+/// Run a benchmark for a template
+fn runBenchmark(
+ allocator: Allocator,
+ comptime name: []const u8,
+ template: []const u8,
+ data: anytype,
+ config: Config,
+) !Result {
+ // Warmup phase
+ for (0..config.warmup_iterations) |_| {
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+ _ = try pugz.renderTemplate(arena.allocator(), template, data);
+ }
+
+ // Benchmark phase
+ var total_ns: u64 = 0;
+ var min_ns: u64 = std.math.maxInt(u64);
+ var max_ns: u64 = 0;
+ var output_size: usize = 0;
+ var peak_memory: usize = 0;
+
+ var timer = try std.time.Timer.start();
+
+ for (0..config.benchmark_iterations) |i| {
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+
+ timer.reset();
+ const result = try pugz.renderTemplate(arena.allocator(), template, data);
+ const elapsed = timer.read();
+
+ total_ns += elapsed;
+ min_ns = @min(min_ns, elapsed);
+ max_ns = @max(max_ns, elapsed);
+
+ if (i == 0) {
+ output_size = result.len;
+ // Measure memory used by arena (query state before deinit)
+ const state = arena.queryCapacity();
+ peak_memory = state;
+ if (config.show_output) {
+ std.debug.print("\n--- {s} output ---\n{s}\n", .{ name, result });
+ }
+ }
+ }
+
+ const avg_ns = total_ns / config.benchmark_iterations;
+ const ops_per_sec = @as(f64, @floatFromInt(config.benchmark_iterations)) / (@as(f64, @floatFromInt(total_ns)) / 1_000_000_000.0);
+
+ return .{
+ .name = name,
+ .iterations = config.benchmark_iterations,
+ .total_ns = total_ns,
+ .min_ns = min_ns,
+ .max_ns = max_ns,
+ .avg_ns = avg_ns,
+ .ops_per_sec = ops_per_sec,
+ .bytes_per_render = output_size,
+ .arena_peak_bytes = peak_memory,
+ };
+}
+
+/// Simple template - just a few elements
+const simple_template =
+ \\doctype html
+ \\html
+ \\ head
+ \\ title= title
+ \\ body
+ \\ h1 Hello, #{name}!
+ \\ p Welcome to our site.
+;
+
+/// Medium template - with conditionals and loops
+const medium_template =
+ \\doctype html
+ \\html
+ \\ head
+ \\ title= title
+ \\ meta(charset="utf-8")
+ \\ meta(name="viewport" content="width=device-width, initial-scale=1")
+ \\ body
+ \\ header
+ \\ nav.navbar
+ \\ a.brand(href="/") Brand
+ \\ ul.nav-links
+ \\ each link in navLinks
+ \\ li
+ \\ a(href=link.href)= link.text
+ \\ main.container
+ \\ h1= title
+ \\ if showIntro
+ \\ p.intro Welcome, #{userName}!
+ \\ section.content
+ \\ each item in items
+ \\ .card
+ \\ h3= item.title
+ \\ p= item.description
+ \\ footer
+ \\ p Copyright 2024
+;
+
+/// Complex template - with mixins, nested loops, conditionals
+const complex_template =
+ \\mixin card(title, description)
+ \\ .card
+ \\ .card-header
+ \\ h3= title
+ \\ .card-body
+ \\ p= description
+ \\ block
+ \\
+ \\mixin button(text, type="primary")
+ \\ button(class="btn btn-" + type)= text
+ \\
+ \\mixin navItem(href, text)
+ \\ li
+ \\ a(href=href)= text
+ \\
+ \\doctype html
+ \\html
+ \\ head
+ \\ title= title
+ \\ meta(charset="utf-8")
+ \\ meta(name="viewport" content="width=device-width, initial-scale=1")
+ \\ link(rel="stylesheet" href="/css/style.css")
+ \\ body
+ \\ header.site-header
+ \\ .container
+ \\ a.logo(href="/")
+ \\ img(src="/img/logo.png" alt="Logo")
+ \\ nav.main-nav
+ \\ ul
+ \\ each link in navLinks
+ \\ +navItem(link.href, link.text)
+ \\ .user-menu
+ \\ if user
+ \\ span.greeting Hello, #{user.name}!
+ \\ +button("Logout", "secondary")
+ \\ else
+ \\ +button("Login")
+ \\ +button("Sign Up", "success")
+ \\ main.site-content
+ \\ .container
+ \\ .page-header
+ \\ h1= pageTitle
+ \\ if subtitle
+ \\ p.subtitle= subtitle
+ \\ .content-grid
+ \\ each category in categories
+ \\ section.category
+ \\ h2= category.name
+ \\ .cards
+ \\ each item in category.items
+ \\ +card(item.title, item.description)
+ \\ .card-footer
+ \\ +button("View Details")
+ \\ aside.sidebar
+ \\ .widget
+ \\ h4 Recent Posts
+ \\ ul.post-list
+ \\ each post in recentPosts
+ \\ li
+ \\ a(href=post.url)= post.title
+ \\ .widget
+ \\ h4 Tags
+ \\ .tag-cloud
+ \\ each tag in allTags
+ \\ span.tag= tag
+ \\ footer.site-footer
+ \\ .container
+ \\ .footer-grid
+ \\ .footer-col
+ \\ h4 About
+ \\ p Some description text here.
+ \\ .footer-col
+ \\ h4 Links
+ \\ ul
+ \\ each link in footerLinks
+ \\ li
+ \\ a(href=link.href)= link.text
+ \\ .footer-col
+ \\ h4 Contact
+ \\ p Email: contact@example.com
+ \\ .copyright
+ \\ p Copyright #{year} Example Inc.
+;
+
+pub fn main() !void {
+ // Use GPA with leak detection enabled
+ var gpa = std.heap.GeneralPurposeAllocator(.{
+ .stack_trace_frames = 10,
+ .safety = true,
+ }){};
+ defer {
+ const leaked = gpa.deinit();
+ if (leaked == .leak) {
+ std.debug.print("\n⚠️ MEMORY LEAK DETECTED!\n", .{});
+ } else {
+ std.debug.print("\n✓ No memory leaks detected.\n", .{});
+ }
+ }
+ const allocator = gpa.allocator();
+
+ const config = Config{
+ .warmup_iterations = 200,
+ .benchmark_iterations = 20_000,
+ .show_output = false,
+ };
+
+ std.debug.print("\n", .{});
+ std.debug.print("╔══════════════════════════════════════════════════════════════╗\n", .{});
+ std.debug.print("║ Pugz Template Rendering Benchmark ║\n", .{});
+ std.debug.print("╠══════════════════════════════════════════════════════════════╣\n", .{});
+ std.debug.print("║ Warmup iterations: {d:>6} ║\n", .{config.warmup_iterations});
+ std.debug.print("║ Benchmark iterations: {d:>6} ║\n", .{config.benchmark_iterations});
+ std.debug.print("╚══════════════════════════════════════════════════════════════╝\n", .{});
+
+ // Simple template benchmark
+ const simple_result = try runBenchmark(
+ allocator,
+ "Simple Template (basic elements, interpolation)",
+ simple_template,
+ .{
+ .title = "Welcome",
+ .name = "World",
+ },
+ config,
+ );
+ simple_result.print();
+
+ // Medium template benchmark
+ const NavLink = struct { href: []const u8, text: []const u8 };
+ const Item = struct { title: []const u8, description: []const u8 };
+
+ const medium_result = try runBenchmark(
+ allocator,
+ "Medium Template (loops, conditionals, nested elements)",
+ medium_template,
+ .{
+ .title = "Dashboard",
+ .userName = "Alice",
+ .showIntro = true,
+ .navLinks = &[_]NavLink{
+ .{ .href = "/", .text = "Home" },
+ .{ .href = "/about", .text = "About" },
+ .{ .href = "/contact", .text = "Contact" },
+ },
+ .items = &[_]Item{
+ .{ .title = "Item 1", .description = "Description for item 1" },
+ .{ .title = "Item 2", .description = "Description for item 2" },
+ .{ .title = "Item 3", .description = "Description for item 3" },
+ .{ .title = "Item 4", .description = "Description for item 4" },
+ },
+ },
+ config,
+ );
+ medium_result.print();
+
+ // Complex template benchmark
+ const User = struct { name: []const u8 };
+ const SimpleItem = struct { title: []const u8, description: []const u8 };
+ const Category = struct { name: []const u8, items: []const SimpleItem };
+ const Post = struct { url: []const u8, title: []const u8 };
+ const FooterLink = struct { href: []const u8, text: []const u8 };
+
+ const complex_result = try runBenchmark(
+ allocator,
+ "Complex Template (mixins, nested loops, conditionals)",
+ complex_template,
+ .{
+ .title = "Example Site",
+ .pageTitle = "Welcome to Our Site",
+ .subtitle = "The best place on the web",
+ .year = "2024",
+ .user = User{ .name = "Alice" },
+ .navLinks = &[_]NavLink{
+ .{ .href = "/", .text = "Home" },
+ .{ .href = "/products", .text = "Products" },
+ .{ .href = "/about", .text = "About" },
+ .{ .href = "/contact", .text = "Contact" },
+ },
+ .categories = &[_]Category{
+ .{
+ .name = "Featured",
+ .items = &[_]SimpleItem{
+ .{ .title = "Product A", .description = "Amazing product A" },
+ .{ .title = "Product B", .description = "Wonderful product B" },
+ },
+ },
+ .{
+ .name = "Popular",
+ .items = &[_]SimpleItem{
+ .{ .title = "Product C", .description = "Popular product C" },
+ .{ .title = "Product D", .description = "Trending product D" },
+ },
+ },
+ },
+ .recentPosts = &[_]Post{
+ .{ .url = "/blog/post-1", .title = "First Blog Post" },
+ .{ .url = "/blog/post-2", .title = "Second Blog Post" },
+ .{ .url = "/blog/post-3", .title = "Third Blog Post" },
+ },
+ .allTags = &[_][]const u8{ "tech", "news", "tutorial", "review", "guide" },
+ .footerLinks = &[_]FooterLink{
+ .{ .href = "/privacy", .text = "Privacy Policy" },
+ .{ .href = "/terms", .text = "Terms of Service" },
+ .{ .href = "/sitemap", .text = "Sitemap" },
+ },
+ },
+ config,
+ );
+ complex_result.print();
+
+ // Summary
+ std.debug.print("\n", .{});
+ std.debug.print("╔══════════════════════════════════════════════════════════════╗\n", .{});
+ std.debug.print("║ Summary ║\n", .{});
+ std.debug.print("╠══════════════════════════════════════════════════════════════╣\n", .{});
+ std.debug.print("║ Template │ Avg (us) │ Renders/sec │ Output (bytes) ║\n", .{});
+ std.debug.print("╠──────────────────┼──────────┼─────────────┼─────────────────╣\n", .{});
+ std.debug.print("║ Simple │ {d:>8.2} │ {d:>11.0} │ {d:>15} ║\n", .{
+ @as(f64, @floatFromInt(simple_result.avg_ns)) / 1_000.0,
+ simple_result.ops_per_sec,
+ simple_result.bytes_per_render,
+ });
+ std.debug.print("║ Medium │ {d:>8.2} │ {d:>11.0} │ {d:>15} ║\n", .{
+ @as(f64, @floatFromInt(medium_result.avg_ns)) / 1_000.0,
+ medium_result.ops_per_sec,
+ medium_result.bytes_per_render,
+ });
+ std.debug.print("║ Complex │ {d:>8.2} │ {d:>11.0} │ {d:>15} ║\n", .{
+ @as(f64, @floatFromInt(complex_result.avg_ns)) / 1_000.0,
+ complex_result.ops_per_sec,
+ complex_result.bytes_per_render,
+ });
+ std.debug.print("╚══════════════════════════════════════════════════════════════╝\n", .{});
+ std.debug.print("\n", .{});
+}
diff --git a/src/lexer.zig b/src/lexer.zig
index 6d2aaab..71cecc4 100644
--- a/src/lexer.zig
+++ b/src/lexer.zig
@@ -638,6 +638,7 @@ pub const Lexer = struct {
/// Emits a single token for the entire expression (e.g., "btn btn-" + type).
fn scanAttrValue(self: *Lexer) !void {
const start = self.pos;
+ var after_operator = false; // Track if we just passed an operator
// Scan the complete expression including operators
while (!self.isAtEnd()) {
@@ -654,6 +655,7 @@ pub const Lexer = struct {
self.advance();
}
if (self.peek() == quote) self.advance();
+ after_operator = false;
} else if (c == '`') {
// Template literal
self.advance();
@@ -661,6 +663,7 @@ pub const Lexer = struct {
self.advance();
}
if (self.peek() == '`') self.advance();
+ after_operator = false;
} else if (c == '{') {
// Object literal - scan matching braces
var depth: usize = 0;
@@ -675,6 +678,7 @@ pub const Lexer = struct {
}
self.advance();
}
+ after_operator = false;
} else if (c == '[') {
// Array literal - scan matching brackets
var depth: usize = 0;
@@ -689,6 +693,7 @@ pub const Lexer = struct {
}
self.advance();
}
+ after_operator = false;
} else if (c == '(') {
// Function call - scan matching parens
var depth: usize = 0;
@@ -703,33 +708,46 @@ pub const Lexer = struct {
}
self.advance();
}
+ after_operator = false;
} else if (c == ')' or c == ',') {
// End of attribute value
break;
} else if (c == ' ' or c == '\t') {
- // Whitespace - check if followed by operator (continue) or not (end)
- const ws_start = self.pos;
- while (self.peek() == ' ' or self.peek() == '\t') {
- self.advance();
- }
- const next = self.peek();
- if (next == '+' or next == '-' or next == '*' or next == '/') {
- // Operator follows - continue scanning (include whitespace)
+ // Whitespace handling depends on context
+ if (after_operator) {
+ // After an operator, skip whitespace and continue to get the operand
+ while (self.peek() == ' ' or self.peek() == '\t') {
+ self.advance();
+ }
+ after_operator = false;
continue;
} else {
- // Not an operator - rewind and end
- self.pos = ws_start;
- break;
+ // Not after operator - check if followed by operator (continue) or not (end)
+ const ws_start = self.pos;
+ while (self.peek() == ' ' or self.peek() == '\t') {
+ self.advance();
+ }
+ const next = self.peek();
+ if (next == '+' or next == '-' or next == '*' or next == '/') {
+ // Operator follows - continue scanning (include whitespace)
+ continue;
+ } else {
+ // Not an operator - rewind and end
+ self.pos = ws_start;
+ break;
+ }
}
} else if (c == '+' or c == '-' or c == '*' or c == '/') {
- // Operator - include it and continue
+ // Operator - include it and mark that we need to continue for the operand
self.advance();
+ after_operator = true;
} else if (c == '\n' or c == '\r') {
// Newline ends the value
break;
} else {
// Regular character (alphanumeric, etc.)
self.advance();
+ after_operator = false;
}
}
diff --git a/src/runtime.zig b/src/runtime.zig
index 6a1090b..970bbcf 100644
--- a/src/runtime.zig
+++ b/src/runtime.zig
@@ -175,6 +175,8 @@ pub const Runtime = struct {
file_resolver: ?FileResolver,
/// Base directory for resolving relative paths.
base_dir: []const u8,
+ /// Directory containing mixin files for lazy-loading.
+ mixins_dir: []const u8,
/// Block definitions from child template (for inheritance).
blocks: std.StringHashMapUnmanaged(BlockDef),
/// Current mixin block content (for `block` keyword inside mixins).
@@ -190,6 +192,9 @@ pub const Runtime = struct {
base_dir: []const u8 = "",
/// File resolver for loading templates.
file_resolver: ?FileResolver = null,
+ /// Directory containing mixin files for lazy-loading.
+ /// If set, mixins not found in template will be loaded from here.
+ mixins_dir: []const u8 = "",
};
/// Error type for runtime operations.
@@ -204,6 +209,7 @@ pub const Runtime = struct {
.options = options,
.file_resolver = options.file_resolver,
.base_dir = options.base_dir,
+ .mixins_dir = options.mixins_dir,
.blocks = .empty,
.mixin_block_content = null,
.mixin_attributes = null,
@@ -362,13 +368,17 @@ pub const Runtime = struct {
// Process attributes, collecting class values separately
for (elem.attributes) |attr| {
if (std.mem.eql(u8, attr.name, "class")) {
- // Handle class attribute - may be array literal
+ // Handle class attribute - may be array literal or expression
if (attr.value) |value| {
- var evaluated = try self.evaluateString(value);
+ var evaluated: []const u8 = undefined;
- // Parse array literal to space-separated string
- if (evaluated.len > 0 and evaluated[0] == '[') {
- evaluated = try parseArrayToSpaceSeparated(self.allocator, evaluated);
+ // Check if it's an array literal
+ if (value.len >= 1 and value[0] == '[') {
+ evaluated = try parseArrayToSpaceSeparated(self.allocator, value);
+ } else {
+ // Evaluate as expression (handles "str" + var concatenation)
+ const expr_value = self.evaluateExpression(value);
+ evaluated = try expr_value.toString(self.allocator);
}
if (evaluated.len > 0) {
@@ -716,7 +726,19 @@ pub const Runtime = struct {
}
fn visitMixinCall(self: *Runtime, call: ast.MixinCall) Error!void {
- const mixin = self.context.getMixin(call.name) orelse return;
+ // First check if mixin is defined in current context (same template or preloaded)
+ var mixin = self.context.getMixin(call.name);
+
+ // If not found and mixins_dir is configured, try loading from mixins directory
+ if (mixin == null and self.mixins_dir.len > 0) {
+ if (self.loadMixinFromDir(call.name)) |loaded_mixin| {
+ try self.context.defineMixin(loaded_mixin);
+ mixin = loaded_mixin;
+ }
+ }
+
+ // If still not found, skip this mixin call
+ const mixin_def = mixin orelse return;
try self.context.pushScope();
defer self.context.popScope();
@@ -751,17 +773,17 @@ pub const Runtime = struct {
}
// Bind arguments to parameters
- const regular_params = if (mixin.has_rest and mixin.params.len > 0)
- mixin.params.len - 1
+ const regular_params = if (mixin_def.has_rest and mixin_def.params.len > 0)
+ mixin_def.params.len - 1
else
- mixin.params.len;
+ mixin_def.params.len;
// Bind regular parameters
- for (mixin.params[0..regular_params], 0..) |param, i| {
+ for (mixin_def.params[0..regular_params], 0..) |param, i| {
const value = if (i < call.args.len)
self.evaluateExpression(call.args[i])
- else if (i < mixin.defaults.len and mixin.defaults[i] != null)
- self.evaluateExpression(mixin.defaults[i].?)
+ else if (i < mixin_def.defaults.len and mixin_def.defaults[i] != null)
+ self.evaluateExpression(mixin_def.defaults[i].?)
else
Value.null;
@@ -769,8 +791,8 @@ pub const Runtime = struct {
}
// Bind rest parameter if present
- if (mixin.has_rest and mixin.params.len > 0) {
- const rest_param = mixin.params[mixin.params.len - 1];
+ if (mixin_def.has_rest and mixin_def.params.len > 0) {
+ const rest_param = mixin_def.params[mixin_def.params.len - 1];
const rest_start = regular_params;
if (rest_start < call.args.len) {
@@ -789,11 +811,79 @@ pub const Runtime = struct {
}
// Render mixin body
- for (mixin.children) |child| {
+ for (mixin_def.children) |child| {
try self.visitNode(child);
}
}
+ /// Loads a mixin from the mixins directory by name.
+ /// Searches for files named {name}.pug or iterates through all .pug files.
+ /// Note: The source file memory is intentionally not freed to keep AST slices valid.
+ fn loadMixinFromDir(self: *Runtime, name: []const u8) ?ast.MixinDef {
+ const resolver = self.file_resolver orelse return null;
+
+ // First try: look for a file named {name}.pug
+ const specific_path = std.fs.path.join(self.allocator, &.{ self.mixins_dir, name }) catch return null;
+ defer self.allocator.free(specific_path);
+
+ const with_ext = std.fmt.allocPrint(self.allocator, "{s}.pug", .{specific_path}) catch return null;
+ defer self.allocator.free(with_ext);
+
+ if (resolver(self.allocator, with_ext)) |source| {
+ // Note: source is intentionally not freed - AST nodes contain slices into it
+ if (self.parseMixinFromSource(source, name)) |mixin_def| {
+ return mixin_def;
+ }
+ // Only free if we didn't find the mixin we wanted
+ self.allocator.free(source);
+ }
+
+ // Second try: iterate through all .pug files in mixins directory
+ var dir = std.fs.openDirAbsolute(self.mixins_dir, .{ .iterate = true }) catch return null;
+ defer dir.close();
+
+ var iter = dir.iterate();
+ while (iter.next() catch return null) |entry| {
+ if (entry.kind != .file) continue;
+ if (!std.mem.endsWith(u8, entry.name, ".pug")) continue;
+
+ const file_path = std.fs.path.join(self.allocator, &.{ self.mixins_dir, entry.name }) catch continue;
+ defer self.allocator.free(file_path);
+
+ if (resolver(self.allocator, file_path)) |source| {
+ // Note: source is intentionally not freed - AST nodes contain slices into it
+ if (self.parseMixinFromSource(source, name)) |mixin_def| {
+ return mixin_def;
+ }
+ // Only free if we didn't find the mixin we wanted
+ self.allocator.free(source);
+ }
+ }
+
+ return null;
+ }
+
+ /// Parses a source file and extracts a mixin definition by name.
+ fn parseMixinFromSource(self: *Runtime, source: []const u8, name: []const u8) ?ast.MixinDef {
+ var lexer = Lexer.init(self.allocator, source);
+ const tokens = lexer.tokenize() catch return null;
+ // Note: lexer is not deinitialized - tokens contain slices into source
+
+ var parser = Parser.init(self.allocator, tokens);
+ const doc = parser.parse() catch return null;
+
+ // Find the mixin definition with the matching name
+ for (doc.nodes) |node| {
+ if (node == .mixin_def) {
+ if (std.mem.eql(u8, node.mixin_def.name, name)) {
+ return node.mixin_def;
+ }
+ }
+ }
+
+ return null;
+ }
+
/// Renders the mixin block content (for `block` keyword inside mixins).
fn visitMixinBlock(self: *Runtime) Error!void {
if (self.mixin_block_content) |block_children| {
diff --git a/src/tests/general_test.zig b/src/tests/general_test.zig
index 8f81fde..5da79db 100644
--- a/src/tests/general_test.zig
+++ b/src/tests/general_test.zig
@@ -715,3 +715,26 @@ test "Explicit self-closing tag with attributes" {
\\
);
}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// String concatenation in attributes
+// ─────────────────────────────────────────────────────────────────────────────
+
+test "Attribute with string concatenation" {
+ try expectOutput(
+ \\button(class="btn btn-" + btnType) Click
+ , .{ .btnType = "secondary" },
+ \\
+ );
+}
+
+test "Mixin with string concatenation in class" {
+ try expectOutput(
+ \\mixin btn(text, btnType="primary")
+ \\ button(class="btn btn-" + btnType)= text
+ \\+btn("Click me", "secondary")
+ , .{},
+ \\
+ );
+}
+
diff --git a/src/view_engine.zig b/src/view_engine.zig
index 6f07905..de858a9 100644
--- a/src/view_engine.zig
+++ b/src/view_engine.zig
@@ -2,9 +2,13 @@
//!
//! Provides a simple API for rendering Pug templates with:
//! - Views directory configuration
-//! - Auto-loading mixins from a mixins subdirectory
+//! - Lazy-loading mixins from a mixins subdirectory (on-demand)
//! - Relative path resolution for includes and extends
//!
+//! Mixins are resolved in the following order:
+//! 1. Mixins defined in the same template file
+//! 2. Mixins from the mixins directory (lazy-loaded when first called)
+//!
//! Example:
//! ```zig
//! var engine = try ViewEngine.init(allocator, .{
@@ -32,7 +36,8 @@ pub const Options = struct {
/// Root directory containing view templates.
views_dir: []const u8,
/// Subdirectory within views_dir containing mixin files.
- /// Defaults to "mixins". Set to null to disable auto-loading.
+ /// Defaults to "mixins". Mixins are lazy-loaded on first use.
+ /// Set to null to disable mixin directory lookup.
mixins_dir: ?[]const u8 = "mixins",
/// File extension for templates. Defaults to ".pug".
extension: []const u8 = ".pug",
@@ -49,107 +54,41 @@ pub const ViewEngineError = error{
InvalidPath,
};
-/// A pre-parsed mixin definition.
-const MixinEntry = struct {
- name: []const u8,
- def: ast.MixinDef,
-};
-
/// ViewEngine manages template rendering with a configured views directory.
+/// Mixins are lazy-loaded from the mixins directory when first called.
pub const ViewEngine = struct {
allocator: std.mem.Allocator,
options: Options,
/// Absolute path to views directory.
views_path: []const u8,
- /// Pre-loaded mixin definitions.
- mixins: std.ArrayListUnmanaged(MixinEntry),
- /// Cached mixin source files (to keep slices valid).
- mixin_sources: std.ArrayListUnmanaged([]const u8),
+ /// Absolute path to mixins directory (resolved at init).
+ mixins_path: []const u8,
/// Initializes the ViewEngine with the given options.
- /// Loads all mixins from the mixins directory if configured.
pub fn init(allocator: std.mem.Allocator, options: Options) !ViewEngine {
// Resolve views directory to absolute path
const views_path = try std.fs.cwd().realpathAlloc(allocator, options.views_dir);
errdefer allocator.free(views_path);
- var engine = ViewEngine{
+ // Resolve mixins directory path (may not exist yet)
+ var mixins_path: []const u8 = "";
+ if (options.mixins_dir) |mixins_subdir| {
+ mixins_path = try std.fs.path.join(allocator, &.{ views_path, mixins_subdir });
+ }
+
+ return ViewEngine{
.allocator = allocator,
.options = options,
.views_path = views_path,
- .mixins = .empty,
- .mixin_sources = .empty,
+ .mixins_path = mixins_path,
};
-
- // Auto-load mixins if configured
- if (options.mixins_dir) |mixins_subdir| {
- try engine.loadMixins(mixins_subdir);
- }
-
- return engine;
}
/// Releases all resources held by the ViewEngine.
pub fn deinit(self: *ViewEngine) void {
self.allocator.free(self.views_path);
- self.mixins.deinit(self.allocator);
- for (self.mixin_sources.items) |source| {
- self.allocator.free(source);
- }
- self.mixin_sources.deinit(self.allocator);
- }
-
- /// Loads all mixin files from the specified subdirectory.
- fn loadMixins(self: *ViewEngine, mixins_subdir: []const u8) !void {
- const mixins_path = try std.fs.path.join(self.allocator, &.{ self.views_path, mixins_subdir });
- defer self.allocator.free(mixins_path);
-
- var dir = std.fs.openDirAbsolute(mixins_path, .{ .iterate = true }) catch |err| {
- if (err == error.FileNotFound) {
- // Mixins directory doesn't exist - that's OK
- return;
- }
- return err;
- };
- defer dir.close();
-
- var iter = dir.iterate();
- while (try iter.next()) |entry| {
- if (entry.kind != .file) continue;
-
- // Check for .pug extension
- if (!std.mem.endsWith(u8, entry.name, self.options.extension)) continue;
-
- // Read and parse the mixin file
- try self.loadMixinFile(dir, entry.name);
- }
- }
-
- /// Loads a single mixin file and extracts its mixin definitions.
- fn loadMixinFile(self: *ViewEngine, dir: std.fs.Dir, filename: []const u8) !void {
- const source = try dir.readFileAlloc(self.allocator, filename, 1024 * 1024);
- errdefer self.allocator.free(source);
-
- // Keep source alive for string slices
- try self.mixin_sources.append(self.allocator, source);
-
- // Parse the file
- var lexer = Lexer.init(self.allocator, source);
- defer lexer.deinit();
-
- const tokens = lexer.tokenize() catch return;
-
- var parser = Parser.init(self.allocator, tokens);
- const doc = parser.parse() catch return;
-
- // Extract mixin definitions
- for (doc.nodes) |node| {
- if (node == .mixin_def) {
- try self.mixins.append(self.allocator, .{
- .name = node.mixin_def.name,
- .def = node.mixin_def,
- });
- }
+ if (self.mixins_path.len > 0) {
+ self.allocator.free(self.mixins_path);
}
}
@@ -158,6 +97,10 @@ pub const ViewEngine = struct {
/// The template path is relative to the views directory.
/// The .pug extension is added automatically if not present.
///
+ /// Mixins are resolved in order:
+ /// 1. Mixins defined in the template itself
+ /// 2. Mixins from the mixins directory (lazy-loaded)
+ ///
/// Example:
/// ```zig
/// const html = try engine.render(allocator, "pages/home", .{
@@ -188,11 +131,6 @@ pub const ViewEngine = struct {
var ctx = Context.init(allocator);
defer ctx.deinit();
- // Register pre-loaded mixins
- for (self.mixins.items) |mixin_entry| {
- try ctx.defineMixin(mixin_entry.def);
- }
-
// Populate context from data struct
try ctx.pushScope();
inline for (std.meta.fields(@TypeOf(data))) |field| {
@@ -200,10 +138,11 @@ pub const ViewEngine = struct {
try ctx.set(field.name, runtime.toValue(allocator, value));
}
- // Create runtime with file resolver for includes/extends
+ // Create runtime with file resolver for includes/extends and lazy mixin loading
var rt = Runtime.init(allocator, &ctx, .{
.pretty = self.options.pretty,
.base_dir = self.views_path,
+ .mixins_dir = self.mixins_path,
.file_resolver = createFileResolver(),
});
defer rt.deinit();