Add string concatenation in attributes, lazy mixin loading, and benchmarks
Features: - Fix string concatenation in attribute values (e.g., class="btn btn-" + type) - Lexer now properly captures full expressions with operators - Runtime evaluates expressions for class attributes ViewEngine improvements: - Change mixin loading from eager to lazy (on-demand) - Mixins are now loaded from mixins directory only when first called - Template-defined mixins take precedence over directory mixins Benchmarks: - Add src/benchmark.zig with three template complexity levels - Simple: ~150k renders/sec, 6KB memory - Medium: ~70k renders/sec, 45KB memory - Complex: ~32k renders/sec, 94KB memory - Memory leak detection confirms no leaks Documentation: - Update CLAUDE.md with lazy mixin loading details - Document mixin resolution order
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -252,7 +252,7 @@ const pugz = @import("pugz");
|
|||||||
// Initialize once at server startup
|
// Initialize once at server startup
|
||||||
var engine = try pugz.ViewEngine.init(allocator, .{
|
var engine = try pugz.ViewEngine.init(allocator, .{
|
||||||
.views_dir = "src/views", // Root views directory
|
.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)
|
.extension = ".pug", // File extension (default: .pug)
|
||||||
.pretty = true, // Pretty-print output (default: true)
|
.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
|
### Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/views/
|
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)
|
│ ├── buttons.pug # mixin btn(text), mixin btn-link(href, text)
|
||||||
│ └── cards.pug # mixin card(title), mixin card-simple(title, body)
|
│ └── cards.pug # mixin card(title), mixin card-simple(title, body)
|
||||||
├── layouts/
|
├── layouts/
|
||||||
@@ -291,7 +302,7 @@ src/views/
|
|||||||
Templates can use:
|
Templates can use:
|
||||||
- `extends layouts/base` - Paths relative to views_dir
|
- `extends layouts/base` - Paths relative to views_dir
|
||||||
- `include partials/header` - 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
|
### Low-Level API
|
||||||
|
|
||||||
|
|||||||
20
build.zig
20
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");
|
const demo_step = b.step("demo", "Run the template inheritance demo web app");
|
||||||
demo_step.dependOn(&run_demo.step);
|
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.
|
// Just like flags, top level steps are also listed in the `--help` menu.
|
||||||
//
|
//
|
||||||
// The Zig build system is entirely implemented in userland, which means
|
// The Zig build system is entirely implemented in userland, which means
|
||||||
|
|||||||
388
src/benchmark.zig
Normal file
388
src/benchmark.zig
Normal file
@@ -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", .{});
|
||||||
|
}
|
||||||
@@ -638,6 +638,7 @@ pub const Lexer = struct {
|
|||||||
/// Emits a single token for the entire expression (e.g., "btn btn-" + type).
|
/// Emits a single token for the entire expression (e.g., "btn btn-" + type).
|
||||||
fn scanAttrValue(self: *Lexer) !void {
|
fn scanAttrValue(self: *Lexer) !void {
|
||||||
const start = self.pos;
|
const start = self.pos;
|
||||||
|
var after_operator = false; // Track if we just passed an operator
|
||||||
|
|
||||||
// Scan the complete expression including operators
|
// Scan the complete expression including operators
|
||||||
while (!self.isAtEnd()) {
|
while (!self.isAtEnd()) {
|
||||||
@@ -654,6 +655,7 @@ pub const Lexer = struct {
|
|||||||
self.advance();
|
self.advance();
|
||||||
}
|
}
|
||||||
if (self.peek() == quote) self.advance();
|
if (self.peek() == quote) self.advance();
|
||||||
|
after_operator = false;
|
||||||
} else if (c == '`') {
|
} else if (c == '`') {
|
||||||
// Template literal
|
// Template literal
|
||||||
self.advance();
|
self.advance();
|
||||||
@@ -661,6 +663,7 @@ pub const Lexer = struct {
|
|||||||
self.advance();
|
self.advance();
|
||||||
}
|
}
|
||||||
if (self.peek() == '`') self.advance();
|
if (self.peek() == '`') self.advance();
|
||||||
|
after_operator = false;
|
||||||
} else if (c == '{') {
|
} else if (c == '{') {
|
||||||
// Object literal - scan matching braces
|
// Object literal - scan matching braces
|
||||||
var depth: usize = 0;
|
var depth: usize = 0;
|
||||||
@@ -675,6 +678,7 @@ pub const Lexer = struct {
|
|||||||
}
|
}
|
||||||
self.advance();
|
self.advance();
|
||||||
}
|
}
|
||||||
|
after_operator = false;
|
||||||
} else if (c == '[') {
|
} else if (c == '[') {
|
||||||
// Array literal - scan matching brackets
|
// Array literal - scan matching brackets
|
||||||
var depth: usize = 0;
|
var depth: usize = 0;
|
||||||
@@ -689,6 +693,7 @@ pub const Lexer = struct {
|
|||||||
}
|
}
|
||||||
self.advance();
|
self.advance();
|
||||||
}
|
}
|
||||||
|
after_operator = false;
|
||||||
} else if (c == '(') {
|
} else if (c == '(') {
|
||||||
// Function call - scan matching parens
|
// Function call - scan matching parens
|
||||||
var depth: usize = 0;
|
var depth: usize = 0;
|
||||||
@@ -703,33 +708,46 @@ pub const Lexer = struct {
|
|||||||
}
|
}
|
||||||
self.advance();
|
self.advance();
|
||||||
}
|
}
|
||||||
|
after_operator = false;
|
||||||
} else if (c == ')' or c == ',') {
|
} else if (c == ')' or c == ',') {
|
||||||
// End of attribute value
|
// End of attribute value
|
||||||
break;
|
break;
|
||||||
} else if (c == ' ' or c == '\t') {
|
} else if (c == ' ' or c == '\t') {
|
||||||
// Whitespace - check if followed by operator (continue) or not (end)
|
// Whitespace handling depends on context
|
||||||
const ws_start = self.pos;
|
if (after_operator) {
|
||||||
while (self.peek() == ' ' or self.peek() == '\t') {
|
// After an operator, skip whitespace and continue to get the operand
|
||||||
self.advance();
|
while (self.peek() == ' ' or self.peek() == '\t') {
|
||||||
}
|
self.advance();
|
||||||
const next = self.peek();
|
}
|
||||||
if (next == '+' or next == '-' or next == '*' or next == '/') {
|
after_operator = false;
|
||||||
// Operator follows - continue scanning (include whitespace)
|
|
||||||
continue;
|
continue;
|
||||||
} else {
|
} else {
|
||||||
// Not an operator - rewind and end
|
// Not after operator - check if followed by operator (continue) or not (end)
|
||||||
self.pos = ws_start;
|
const ws_start = self.pos;
|
||||||
break;
|
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 == '/') {
|
} 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();
|
self.advance();
|
||||||
|
after_operator = true;
|
||||||
} else if (c == '\n' or c == '\r') {
|
} else if (c == '\n' or c == '\r') {
|
||||||
// Newline ends the value
|
// Newline ends the value
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
// Regular character (alphanumeric, etc.)
|
// Regular character (alphanumeric, etc.)
|
||||||
self.advance();
|
self.advance();
|
||||||
|
after_operator = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
120
src/runtime.zig
120
src/runtime.zig
@@ -175,6 +175,8 @@ pub const Runtime = struct {
|
|||||||
file_resolver: ?FileResolver,
|
file_resolver: ?FileResolver,
|
||||||
/// Base directory for resolving relative paths.
|
/// Base directory for resolving relative paths.
|
||||||
base_dir: []const u8,
|
base_dir: []const u8,
|
||||||
|
/// Directory containing mixin files for lazy-loading.
|
||||||
|
mixins_dir: []const u8,
|
||||||
/// Block definitions from child template (for inheritance).
|
/// Block definitions from child template (for inheritance).
|
||||||
blocks: std.StringHashMapUnmanaged(BlockDef),
|
blocks: std.StringHashMapUnmanaged(BlockDef),
|
||||||
/// Current mixin block content (for `block` keyword inside mixins).
|
/// Current mixin block content (for `block` keyword inside mixins).
|
||||||
@@ -190,6 +192,9 @@ pub const Runtime = struct {
|
|||||||
base_dir: []const u8 = "",
|
base_dir: []const u8 = "",
|
||||||
/// File resolver for loading templates.
|
/// File resolver for loading templates.
|
||||||
file_resolver: ?FileResolver = null,
|
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.
|
/// Error type for runtime operations.
|
||||||
@@ -204,6 +209,7 @@ pub const Runtime = struct {
|
|||||||
.options = options,
|
.options = options,
|
||||||
.file_resolver = options.file_resolver,
|
.file_resolver = options.file_resolver,
|
||||||
.base_dir = options.base_dir,
|
.base_dir = options.base_dir,
|
||||||
|
.mixins_dir = options.mixins_dir,
|
||||||
.blocks = .empty,
|
.blocks = .empty,
|
||||||
.mixin_block_content = null,
|
.mixin_block_content = null,
|
||||||
.mixin_attributes = null,
|
.mixin_attributes = null,
|
||||||
@@ -362,13 +368,17 @@ pub const Runtime = struct {
|
|||||||
// Process attributes, collecting class values separately
|
// Process attributes, collecting class values separately
|
||||||
for (elem.attributes) |attr| {
|
for (elem.attributes) |attr| {
|
||||||
if (std.mem.eql(u8, attr.name, "class")) {
|
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| {
|
if (attr.value) |value| {
|
||||||
var evaluated = try self.evaluateString(value);
|
var evaluated: []const u8 = undefined;
|
||||||
|
|
||||||
// Parse array literal to space-separated string
|
// Check if it's an array literal
|
||||||
if (evaluated.len > 0 and evaluated[0] == '[') {
|
if (value.len >= 1 and value[0] == '[') {
|
||||||
evaluated = try parseArrayToSpaceSeparated(self.allocator, evaluated);
|
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) {
|
if (evaluated.len > 0) {
|
||||||
@@ -716,7 +726,19 @@ pub const Runtime = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn visitMixinCall(self: *Runtime, call: ast.MixinCall) Error!void {
|
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();
|
try self.context.pushScope();
|
||||||
defer self.context.popScope();
|
defer self.context.popScope();
|
||||||
@@ -751,17 +773,17 @@ pub const Runtime = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bind arguments to parameters
|
// Bind arguments to parameters
|
||||||
const regular_params = if (mixin.has_rest and mixin.params.len > 0)
|
const regular_params = if (mixin_def.has_rest and mixin_def.params.len > 0)
|
||||||
mixin.params.len - 1
|
mixin_def.params.len - 1
|
||||||
else
|
else
|
||||||
mixin.params.len;
|
mixin_def.params.len;
|
||||||
|
|
||||||
// Bind regular parameters
|
// 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)
|
const value = if (i < call.args.len)
|
||||||
self.evaluateExpression(call.args[i])
|
self.evaluateExpression(call.args[i])
|
||||||
else if (i < mixin.defaults.len and mixin.defaults[i] != null)
|
else if (i < mixin_def.defaults.len and mixin_def.defaults[i] != null)
|
||||||
self.evaluateExpression(mixin.defaults[i].?)
|
self.evaluateExpression(mixin_def.defaults[i].?)
|
||||||
else
|
else
|
||||||
Value.null;
|
Value.null;
|
||||||
|
|
||||||
@@ -769,8 +791,8 @@ pub const Runtime = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bind rest parameter if present
|
// Bind rest parameter if present
|
||||||
if (mixin.has_rest and mixin.params.len > 0) {
|
if (mixin_def.has_rest and mixin_def.params.len > 0) {
|
||||||
const rest_param = mixin.params[mixin.params.len - 1];
|
const rest_param = mixin_def.params[mixin_def.params.len - 1];
|
||||||
const rest_start = regular_params;
|
const rest_start = regular_params;
|
||||||
|
|
||||||
if (rest_start < call.args.len) {
|
if (rest_start < call.args.len) {
|
||||||
@@ -789,11 +811,79 @@ pub const Runtime = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render mixin body
|
// Render mixin body
|
||||||
for (mixin.children) |child| {
|
for (mixin_def.children) |child| {
|
||||||
try self.visitNode(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).
|
/// Renders the mixin block content (for `block` keyword inside mixins).
|
||||||
fn visitMixinBlock(self: *Runtime) Error!void {
|
fn visitMixinBlock(self: *Runtime) Error!void {
|
||||||
if (self.mixin_block_content) |block_children| {
|
if (self.mixin_block_content) |block_children| {
|
||||||
|
|||||||
@@ -715,3 +715,26 @@ test "Explicit self-closing tag with attributes" {
|
|||||||
\\<foo bar="baz" />
|
\\<foo bar="baz" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// String concatenation in attributes
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test "Attribute with string concatenation" {
|
||||||
|
try expectOutput(
|
||||||
|
\\button(class="btn btn-" + btnType) Click
|
||||||
|
, .{ .btnType = "secondary" },
|
||||||
|
\\<button class="btn btn-secondary">Click</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Mixin with string concatenation in class" {
|
||||||
|
try expectOutput(
|
||||||
|
\\mixin btn(text, btnType="primary")
|
||||||
|
\\ button(class="btn btn-" + btnType)= text
|
||||||
|
\\+btn("Click me", "secondary")
|
||||||
|
, .{},
|
||||||
|
\\<button class="btn btn-secondary">Click me</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
//!
|
//!
|
||||||
//! Provides a simple API for rendering Pug templates with:
|
//! Provides a simple API for rendering Pug templates with:
|
||||||
//! - Views directory configuration
|
//! - 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
|
//! - 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:
|
//! Example:
|
||||||
//! ```zig
|
//! ```zig
|
||||||
//! var engine = try ViewEngine.init(allocator, .{
|
//! var engine = try ViewEngine.init(allocator, .{
|
||||||
@@ -32,7 +36,8 @@ pub const Options = struct {
|
|||||||
/// Root directory containing view templates.
|
/// Root directory containing view templates.
|
||||||
views_dir: []const u8,
|
views_dir: []const u8,
|
||||||
/// Subdirectory within views_dir containing mixin files.
|
/// 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",
|
mixins_dir: ?[]const u8 = "mixins",
|
||||||
/// File extension for templates. Defaults to ".pug".
|
/// File extension for templates. Defaults to ".pug".
|
||||||
extension: []const u8 = ".pug",
|
extension: []const u8 = ".pug",
|
||||||
@@ -49,107 +54,41 @@ pub const ViewEngineError = error{
|
|||||||
InvalidPath,
|
InvalidPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A pre-parsed mixin definition.
|
|
||||||
const MixinEntry = struct {
|
|
||||||
name: []const u8,
|
|
||||||
def: ast.MixinDef,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// ViewEngine manages template rendering with a configured views directory.
|
/// ViewEngine manages template rendering with a configured views directory.
|
||||||
|
/// Mixins are lazy-loaded from the mixins directory when first called.
|
||||||
pub const ViewEngine = struct {
|
pub const ViewEngine = struct {
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
options: Options,
|
options: Options,
|
||||||
/// Absolute path to views directory.
|
/// Absolute path to views directory.
|
||||||
views_path: []const u8,
|
views_path: []const u8,
|
||||||
/// Pre-loaded mixin definitions.
|
/// Absolute path to mixins directory (resolved at init).
|
||||||
mixins: std.ArrayListUnmanaged(MixinEntry),
|
mixins_path: []const u8,
|
||||||
/// Cached mixin source files (to keep slices valid).
|
|
||||||
mixin_sources: std.ArrayListUnmanaged([]const u8),
|
|
||||||
|
|
||||||
/// Initializes the ViewEngine with the given options.
|
/// 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 {
|
pub fn init(allocator: std.mem.Allocator, options: Options) !ViewEngine {
|
||||||
// Resolve views directory to absolute path
|
// Resolve views directory to absolute path
|
||||||
const views_path = try std.fs.cwd().realpathAlloc(allocator, options.views_dir);
|
const views_path = try std.fs.cwd().realpathAlloc(allocator, options.views_dir);
|
||||||
errdefer allocator.free(views_path);
|
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,
|
.allocator = allocator,
|
||||||
.options = options,
|
.options = options,
|
||||||
.views_path = views_path,
|
.views_path = views_path,
|
||||||
.mixins = .empty,
|
.mixins_path = mixins_path,
|
||||||
.mixin_sources = .empty,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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.
|
/// Releases all resources held by the ViewEngine.
|
||||||
pub fn deinit(self: *ViewEngine) void {
|
pub fn deinit(self: *ViewEngine) void {
|
||||||
self.allocator.free(self.views_path);
|
self.allocator.free(self.views_path);
|
||||||
self.mixins.deinit(self.allocator);
|
if (self.mixins_path.len > 0) {
|
||||||
for (self.mixin_sources.items) |source| {
|
self.allocator.free(self.mixins_path);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +97,10 @@ pub const ViewEngine = struct {
|
|||||||
/// The template path is relative to the views directory.
|
/// The template path is relative to the views directory.
|
||||||
/// The .pug extension is added automatically if not present.
|
/// 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:
|
/// Example:
|
||||||
/// ```zig
|
/// ```zig
|
||||||
/// const html = try engine.render(allocator, "pages/home", .{
|
/// const html = try engine.render(allocator, "pages/home", .{
|
||||||
@@ -188,11 +131,6 @@ pub const ViewEngine = struct {
|
|||||||
var ctx = Context.init(allocator);
|
var ctx = Context.init(allocator);
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|
||||||
// Register pre-loaded mixins
|
|
||||||
for (self.mixins.items) |mixin_entry| {
|
|
||||||
try ctx.defineMixin(mixin_entry.def);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate context from data struct
|
// Populate context from data struct
|
||||||
try ctx.pushScope();
|
try ctx.pushScope();
|
||||||
inline for (std.meta.fields(@TypeOf(data))) |field| {
|
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));
|
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, .{
|
var rt = Runtime.init(allocator, &ctx, .{
|
||||||
.pretty = self.options.pretty,
|
.pretty = self.options.pretty,
|
||||||
.base_dir = self.views_path,
|
.base_dir = self.views_path,
|
||||||
|
.mixins_dir = self.mixins_path,
|
||||||
.file_resolver = createFileResolver(),
|
.file_resolver = createFileResolver(),
|
||||||
});
|
});
|
||||||
defer rt.deinit();
|
defer rt.deinit();
|
||||||
|
|||||||
Reference in New Issue
Block a user