Add README and simplify ViewEngine API
- ViewEngine.init() no longer requires allocator - render() and renderTpl() accept allocator parameter - Remove deinit() - no resources to clean up - Remove unused parse/renderDoc methods - Add memory management guidance to runtime.zig - Clean up unused imports and options
This commit is contained in:
106
README.md
Normal file
106
README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Pugz
|
||||
|
||||
A Pug template engine for Zig.
|
||||
|
||||
## Features
|
||||
|
||||
- Pug syntax (tags, classes, IDs, attributes)
|
||||
- Interpolation (`#{var}`, `!{unescaped}`)
|
||||
- Conditionals (`if`, `else if`, `else`, `unless`)
|
||||
- Iteration (`each`, `while`)
|
||||
- Template inheritance (`extends`, `block`, `append`, `prepend`)
|
||||
- Includes
|
||||
- Mixins with parameters, defaults, rest args, and block content
|
||||
- Comments (rendered and unbuffered)
|
||||
|
||||
## Installation
|
||||
|
||||
Add to `build.zig.zon`:
|
||||
|
||||
```zig
|
||||
.dependencies = .{
|
||||
.pugz = .{
|
||||
.url = "git+https://github.com/ankitpatial/pugz",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Then in `build.zig`:
|
||||
|
||||
```zig
|
||||
const pugz = b.dependency("pugz", .{});
|
||||
exe.root_module.addImport("pugz", pugz.module("pugz"));
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```zig
|
||||
const std = @import("std");
|
||||
const pugz = @import("pugz");
|
||||
|
||||
pub fn main() !void {
|
||||
const engine = pugz.ViewEngine.init(.{
|
||||
.views_dir = "views",
|
||||
});
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const html = try engine.render(arena.allocator(), "index", .{
|
||||
.title = "Hello",
|
||||
.name = "World",
|
||||
});
|
||||
|
||||
std.debug.print("{s}\n", .{html});
|
||||
}
|
||||
```
|
||||
|
||||
### Template String
|
||||
|
||||
```zig
|
||||
const html = try engine.renderTpl(allocator,
|
||||
\\h1 Hello, #{name}!
|
||||
\\ul
|
||||
\\ each item in items
|
||||
\\ li= item
|
||||
, .{
|
||||
.name = "World",
|
||||
.items = &[_][]const u8{ "one", "two", "three" },
|
||||
});
|
||||
```
|
||||
|
||||
## Template Syntax
|
||||
|
||||
```pug
|
||||
doctype html
|
||||
html
|
||||
head
|
||||
title= title
|
||||
body
|
||||
h1.header Hello, #{name}!
|
||||
|
||||
if authenticated
|
||||
p Welcome back!
|
||||
else
|
||||
a(href="/login") Sign in
|
||||
|
||||
ul
|
||||
each item in items
|
||||
li= item
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
2000 iterations on MacBook Air M2:
|
||||
|
||||
| Template | Pugz | Pug.js | Speedup |
|
||||
|----------|------|--------|---------|
|
||||
| simple-0 | 0.6ms | 2ms | 3.4x |
|
||||
| simple-1 | 6.9ms | 9ms | 1.3x |
|
||||
| simple-2 | 7.7ms | 9ms | 1.2x |
|
||||
| if-expression | 6.0ms | 12ms | 2.0x |
|
||||
| projects-escaped | 9.3ms | 86ms | 9.2x |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
50
build.zig
50
build.zig
@@ -112,9 +112,12 @@ pub fn build(b: *std.Build) void {
|
||||
const bench = b.addExecutable(.{
|
||||
.name = "bench",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/benchmark.zig"),
|
||||
.root_source_file = b.path("src/benchmarks/benchmark.zig"),
|
||||
.target = target,
|
||||
.optimize = .ReleaseFast, // Always use ReleaseFast for benchmarks
|
||||
.imports = &.{
|
||||
.{ .name = "pugz", .module = mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -126,6 +129,51 @@ pub fn build(b: *std.Build) void {
|
||||
const bench_step = b.step("bench", "Run rendering benchmarks");
|
||||
bench_step.dependOn(&run_bench.step);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Comparison Benchmark Tests (template-engine-bench templates)
|
||||
// Run all: zig build test-bench
|
||||
// Run one: zig build test-bench -- simple-0
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const bench_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/benchmarks/comparison.zig"),
|
||||
.target = target,
|
||||
.optimize = .ReleaseFast,
|
||||
.imports = &.{
|
||||
.{ .name = "pugz", .module = mod },
|
||||
},
|
||||
}),
|
||||
.filters = if (b.args) |args| args else &.{},
|
||||
});
|
||||
|
||||
const run_bench_tests = b.addRunArtifact(bench_tests);
|
||||
|
||||
const bench_test_step = b.step("test-bench", "Run comparison benchmarks (template-engine-bench)");
|
||||
bench_test_step.dependOn(&run_bench_tests.step);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Profile executable (for CPU profiling)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
const profile = b.addExecutable(.{
|
||||
.name = "profile",
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/benchmarks/profile_friends.zig"),
|
||||
.target = target,
|
||||
.optimize = .ReleaseFast,
|
||||
.imports = &.{
|
||||
.{ .name = "pugz", .module = mod },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
b.installArtifact(profile);
|
||||
|
||||
const run_profile = b.addRunArtifact(profile);
|
||||
run_profile.step.dependOn(b.getInstallStep());
|
||||
|
||||
const profile_step = b.step("profile", "Run friends template for profiling");
|
||||
profile_step.dependOn(&run_profile.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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! - Memory usage per render
|
||||
|
||||
const std = @import("std");
|
||||
const pugz = @import("root.zig");
|
||||
const pugz = @import("pugz");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
479
src/benchmarks/comparison.zig
Normal file
479
src/benchmarks/comparison.zig
Normal file
@@ -0,0 +1,479 @@
|
||||
//! Pugz Benchmark - Comparison with template-engine-bench
|
||||
//!
|
||||
//! These benchmarks use the exact same templates from:
|
||||
//! https://github.com/itsarnaud/template-engine-bench
|
||||
//!
|
||||
//! Run individual benchmarks:
|
||||
//! zig build test-bench -- simple-0
|
||||
//! zig build test-bench -- friends
|
||||
//!
|
||||
//! Run all benchmarks:
|
||||
//! zig build test-bench
|
||||
//!
|
||||
//! Pug.js reference (2000 iterations on MacBook Air M2):
|
||||
//! - simple-0: pug => 2ms
|
||||
//! - simple-1: pug => 9ms
|
||||
//! - simple-2: pug => 9ms
|
||||
//! - if-expression: pug => 12ms
|
||||
//! - projects-escaped: pug => 86ms
|
||||
//! - search-results: pug => 41ms
|
||||
//! - friends: pug => 110ms
|
||||
|
||||
const std = @import("std");
|
||||
const pugz = @import("pugz");
|
||||
|
||||
const iterations: usize = 2000;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// simple-0
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const simple_0_tpl = "h1 Hello, #{name}";
|
||||
|
||||
test "bench: simple-0" {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer if (gpa.deinit() == .leak) @panic("leak!");
|
||||
|
||||
const engine = pugz.ViewEngine.init(.{});
|
||||
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
defer arena.deinit();
|
||||
|
||||
var total_ns: u64 = 0;
|
||||
var timer = try std.time.Timer.start();
|
||||
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
timer.reset();
|
||||
_ = try engine.renderTpl(arena.allocator(), simple_0_tpl, .{
|
||||
.name = "John",
|
||||
});
|
||||
total_ns += timer.read();
|
||||
}
|
||||
|
||||
printResult("simple-0", total_ns, 2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// simple-1
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const simple_1_tpl =
|
||||
\\.simple-1(style="background-color: blue; border: 1px solid black")
|
||||
\\ .colors
|
||||
\\ span.hello Hello #{name}!
|
||||
\\ strong You have #{messageCount} messages!
|
||||
\\ if colors
|
||||
\\ ul
|
||||
\\ each color in colors
|
||||
\\ li.color= color
|
||||
\\ else
|
||||
\\ div No colors!
|
||||
\\ if primary
|
||||
\\ button(type="button" class="primary") Click me!
|
||||
\\ else
|
||||
\\ button(type="button" class="secondary") Click me!
|
||||
;
|
||||
|
||||
test "bench: simple-1" {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
|
||||
const engine = pugz.ViewEngine.init(.{});
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
defer arena.deinit();
|
||||
|
||||
var total_ns: u64 = 0;
|
||||
var timer = try std.time.Timer.start();
|
||||
|
||||
const data = .{
|
||||
.name = "George Washington",
|
||||
.messageCount = 999,
|
||||
.colors = &[_][]const u8{ "red", "green", "blue", "yellow", "orange", "pink", "black", "white", "beige", "brown", "cyan", "magenta" },
|
||||
.primary = true,
|
||||
};
|
||||
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
timer.reset();
|
||||
_ = try engine.renderTpl(arena.allocator(), simple_1_tpl, data);
|
||||
total_ns += timer.read();
|
||||
}
|
||||
|
||||
printResult("simple-1", total_ns, 9);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// simple-2
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const simple_2_tpl =
|
||||
\\div
|
||||
\\ h1.header #{header}
|
||||
\\ h2.header2 #{header2}
|
||||
\\ h3.header3 #{header3}
|
||||
\\ h4.header4 #{header4}
|
||||
\\ h5.header5 #{header5}
|
||||
\\ h6.header6 #{header6}
|
||||
\\ ul.list
|
||||
\\ each item in list
|
||||
\\ li.item #{item}
|
||||
;
|
||||
|
||||
test "bench: simple-2" {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
|
||||
const engine = pugz.ViewEngine.init(.{});
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
defer arena.deinit();
|
||||
|
||||
var total_ns: u64 = 0;
|
||||
var timer = try std.time.Timer.start();
|
||||
const data = .{
|
||||
.header = "Header",
|
||||
.header2 = "Header2",
|
||||
.header3 = "Header3",
|
||||
.header4 = "Header4",
|
||||
.header5 = "Header5",
|
||||
.header6 = "Header6",
|
||||
.list = &[_][]const u8{ "1000000000", "2", "3", "4", "5", "6", "7", "8", "9", "10" },
|
||||
};
|
||||
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
timer.reset();
|
||||
_ = try engine.renderTpl(arena.allocator(), simple_2_tpl, data);
|
||||
total_ns += timer.read();
|
||||
}
|
||||
|
||||
printResult("simple-2", total_ns, 9);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// if-expression
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const if_expression_tpl =
|
||||
\\each account in accounts
|
||||
\\ div
|
||||
\\ if account.status == "closed"
|
||||
\\ div Your account has been closed!
|
||||
\\ if account.status == "suspended"
|
||||
\\ div Your account has been temporarily suspended
|
||||
\\ if account.status == "open"
|
||||
\\ div
|
||||
\\ | Bank balance:
|
||||
\\ if account.negative
|
||||
\\ span.negative= account.balanceFormatted
|
||||
\\ else
|
||||
\\ span.positive= account.balanceFormatted
|
||||
;
|
||||
|
||||
const Account = struct {
|
||||
balance: i32,
|
||||
balanceFormatted: []const u8,
|
||||
status: []const u8,
|
||||
negative: bool,
|
||||
};
|
||||
|
||||
test "bench: if-expression" {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
|
||||
const engine = pugz.ViewEngine.init(.{});
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
defer arena.deinit();
|
||||
|
||||
var total_ns: u64 = 0;
|
||||
var timer = try std.time.Timer.start();
|
||||
const data = .{
|
||||
.accounts = &[_]Account{
|
||||
.{ .balance = 0, .balanceFormatted = "$0.00", .status = "open", .negative = false },
|
||||
.{ .balance = 10, .balanceFormatted = "$10.00", .status = "closed", .negative = false },
|
||||
.{ .balance = -100, .balanceFormatted = "$-100.00", .status = "suspended", .negative = true },
|
||||
.{ .balance = 999, .balanceFormatted = "$999.00", .status = "open", .negative = false },
|
||||
},
|
||||
};
|
||||
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
timer.reset();
|
||||
_ = try engine.renderTpl(arena.allocator(), if_expression_tpl, data);
|
||||
total_ns += timer.read();
|
||||
}
|
||||
|
||||
printResult("if-expression", total_ns, 12);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// projects-escaped
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const projects_escaped_tpl =
|
||||
\\doctype html
|
||||
\\html
|
||||
\\ head
|
||||
\\ title #{title}
|
||||
\\ body
|
||||
\\ p #{text}
|
||||
\\ each project in projects
|
||||
\\ a(href=project.url) #{project.name}
|
||||
\\ p #{project.description}
|
||||
\\ else
|
||||
\\ p No projects
|
||||
;
|
||||
|
||||
const Project = struct {
|
||||
name: []const u8,
|
||||
url: []const u8,
|
||||
description: []const u8,
|
||||
};
|
||||
|
||||
test "bench: projects-escaped" {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
|
||||
const engine = pugz.ViewEngine.init(.{});
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
defer arena.deinit();
|
||||
|
||||
var total_ns: u64 = 0;
|
||||
var timer = try std.time.Timer.start();
|
||||
const data = .{
|
||||
.title = "Projects",
|
||||
.text = "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>",
|
||||
.projects = &[_]Project{
|
||||
.{ .name = "<strong>Facebook</strong>", .url = "http://facebook.com", .description = "Social network" },
|
||||
.{ .name = "<strong>Google</strong>", .url = "http://google.com", .description = "Search engine" },
|
||||
.{ .name = "<strong>Twitter</strong>", .url = "http://twitter.com", .description = "Microblogging service" },
|
||||
.{ .name = "<strong>Amazon</strong>", .url = "http://amazon.com", .description = "Online retailer" },
|
||||
.{ .name = "<strong>eBay</strong>", .url = "http://ebay.com", .description = "Online auction" },
|
||||
.{ .name = "<strong>Wikipedia</strong>", .url = "http://wikipedia.org", .description = "A free encyclopedia" },
|
||||
.{ .name = "<strong>LiveJournal</strong>", .url = "http://livejournal.com", .description = "Blogging platform" },
|
||||
},
|
||||
};
|
||||
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
timer.reset();
|
||||
_ = try engine.renderTpl(arena.allocator(), projects_escaped_tpl, data);
|
||||
total_ns += timer.read();
|
||||
}
|
||||
|
||||
printResult("projects-escaped", total_ns, 86);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// search-results
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Simplified to match original JS benchmark template exactly
|
||||
const search_results_tpl =
|
||||
\\.search-results.view-gallery
|
||||
\\ each searchRecord in searchRecords
|
||||
\\ .search-item
|
||||
\\ .search-item-container.drop-shadow
|
||||
\\ .img-container
|
||||
\\ img(src=searchRecord.imgUrl)
|
||||
\\ h4.title
|
||||
\\ a(href=searchRecord.viewItemUrl)= searchRecord.title
|
||||
\\ | #{searchRecord.description}
|
||||
\\ if searchRecord.featured
|
||||
\\ div Featured!
|
||||
\\ if searchRecord.sizes
|
||||
\\ div
|
||||
\\ | Sizes available:
|
||||
\\ ul
|
||||
\\ each size in searchRecord.sizes
|
||||
\\ li= size
|
||||
;
|
||||
|
||||
const SearchRecord = struct {
|
||||
imgUrl: []const u8,
|
||||
viewItemUrl: []const u8,
|
||||
title: []const u8,
|
||||
description: []const u8,
|
||||
featured: bool,
|
||||
sizes: ?[]const []const u8,
|
||||
};
|
||||
|
||||
test "bench: search-results" {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
|
||||
const engine = pugz.ViewEngine.init(.{});
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
defer arena.deinit();
|
||||
|
||||
const sizes = &[_][]const u8{ "S", "M", "L", "XL", "XXL" };
|
||||
|
||||
// Long descriptions matching original benchmark (Lorem ipsum paragraphs)
|
||||
const desc1 = "Duis laborum nostrud consectetur exercitation minim ad laborum velit adipisicing. Dolore adipisicing pariatur in fugiat nulla voluptate aliquip esse laboris quis exercitation aliqua labore.";
|
||||
const desc2 = "Incididunt ea mollit commodo velit officia. Enim officia occaecat nulla aute. Esse sunt laborum excepteur sint elit sit esse ad.";
|
||||
const desc3 = "Aliquip Lorem consequat sunt ipsum dolor amet amet cupidatat deserunt eiusmod qui anim cillum sint. Dolor exercitation tempor aliquip sunt nisi ipsum ullamco adipisicing.";
|
||||
const desc4 = "Est ad amet irure veniam dolore velit amet irure fugiat ut elit. Tempor fugiat dolor tempor aute enim. Ad sint mollit laboris id sint ullamco eu do irure nostrud magna sunt voluptate.";
|
||||
const desc5 = "Sunt ex magna culpa cillum esse irure consequat Lorem aliquip enim sit reprehenderit sunt. Exercitation esse irure magna proident ex ut elit magna mollit aliqua amet.";
|
||||
|
||||
var total_ns: u64 = 0;
|
||||
var timer = try std.time.Timer.start();
|
||||
const data = .{
|
||||
.searchRecords = &[_]SearchRecord{
|
||||
.{ .imgUrl = "img1.jpg", .viewItemUrl = "http://foo/1", .title = "Namebox", .description = desc1, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img2.jpg", .viewItemUrl = "http://foo/2", .title = "Arctiq", .description = desc2, .featured = false, .sizes = sizes },
|
||||
.{ .imgUrl = "img3.jpg", .viewItemUrl = "http://foo/3", .title = "Niquent", .description = desc3, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img4.jpg", .viewItemUrl = "http://foo/4", .title = "Remotion", .description = desc4, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img5.jpg", .viewItemUrl = "http://foo/5", .title = "Octocore", .description = desc5, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img6.jpg", .viewItemUrl = "http://foo/6", .title = "Spherix", .description = desc1, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img7.jpg", .viewItemUrl = "http://foo/7", .title = "Quarex", .description = desc2, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img8.jpg", .viewItemUrl = "http://foo/8", .title = "Supremia", .description = desc3, .featured = false, .sizes = sizes },
|
||||
.{ .imgUrl = "img9.jpg", .viewItemUrl = "http://foo/9", .title = "Amtap", .description = desc4, .featured = false, .sizes = sizes },
|
||||
.{ .imgUrl = "img10.jpg", .viewItemUrl = "http://foo/10", .title = "Qiao", .description = desc5, .featured = false, .sizes = sizes },
|
||||
.{ .imgUrl = "img11.jpg", .viewItemUrl = "http://foo/11", .title = "Pushcart", .description = desc1, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img12.jpg", .viewItemUrl = "http://foo/12", .title = "Eweville", .description = desc2, .featured = false, .sizes = sizes },
|
||||
.{ .imgUrl = "img13.jpg", .viewItemUrl = "http://foo/13", .title = "Senmei", .description = desc3, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img14.jpg", .viewItemUrl = "http://foo/14", .title = "Maximind", .description = desc4, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img15.jpg", .viewItemUrl = "http://foo/15", .title = "Blurrybus", .description = desc5, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img16.jpg", .viewItemUrl = "http://foo/16", .title = "Virva", .description = desc1, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img17.jpg", .viewItemUrl = "http://foo/17", .title = "Centregy", .description = desc2, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img18.jpg", .viewItemUrl = "http://foo/18", .title = "Dancerity", .description = desc3, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img19.jpg", .viewItemUrl = "http://foo/19", .title = "Oceanica", .description = desc4, .featured = true, .sizes = sizes },
|
||||
.{ .imgUrl = "img20.jpg", .viewItemUrl = "http://foo/20", .title = "Synkgen", .description = desc5, .featured = false, .sizes = null },
|
||||
},
|
||||
};
|
||||
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
timer.reset();
|
||||
_ = try engine.renderTpl(arena.allocator(), search_results_tpl, data);
|
||||
total_ns += timer.read();
|
||||
}
|
||||
|
||||
printResult("search-results", total_ns, 41);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// friends
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const friends_tpl =
|
||||
\\doctype html
|
||||
\\html(lang="en")
|
||||
\\ head
|
||||
\\ meta(charset="UTF-8")
|
||||
\\ title Friends
|
||||
\\ body
|
||||
\\ div.friends
|
||||
\\ each friend in friends
|
||||
\\ div.friend
|
||||
\\ ul
|
||||
\\ li Name: #{friend.name}
|
||||
\\ li Balance: #{friend.balance}
|
||||
\\ li Age: #{friend.age}
|
||||
\\ li Address: #{friend.address}
|
||||
\\ li Image:
|
||||
\\ img(src=friend.picture)
|
||||
\\ li Company: #{friend.company}
|
||||
\\ li Email:
|
||||
\\ a(href=friend.emailHref) #{friend.email}
|
||||
\\ li About: #{friend.about}
|
||||
\\ if friend.tags
|
||||
\\ li Tags:
|
||||
\\ ul
|
||||
\\ each tag in friend.tags
|
||||
\\ li #{tag}
|
||||
\\ if friend.friends
|
||||
\\ li Friends:
|
||||
\\ ul
|
||||
\\ each subFriend in friend.friends
|
||||
\\ li #{subFriend.name} (#{subFriend.id})
|
||||
;
|
||||
|
||||
const SubFriend = struct {
|
||||
id: i32,
|
||||
name: []const u8,
|
||||
};
|
||||
|
||||
const Friend = struct {
|
||||
name: []const u8,
|
||||
balance: []const u8,
|
||||
age: i32,
|
||||
address: []const u8,
|
||||
picture: []const u8,
|
||||
company: []const u8,
|
||||
email: []const u8,
|
||||
emailHref: []const u8,
|
||||
about: []const u8,
|
||||
tags: ?[]const []const u8,
|
||||
friends: ?[]const SubFriend,
|
||||
};
|
||||
|
||||
test "bench: friends" {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer if (gpa.deinit() == .leak) @panic("leadk");
|
||||
|
||||
const engine = pugz.ViewEngine.init(.{});
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
defer arena.deinit();
|
||||
|
||||
const friend_tags = &[_][]const u8{ "id", "amet", "non", "ut", "dolore", "commodo", "consequat" };
|
||||
const sub_friends = &[_]SubFriend{
|
||||
.{ .id = 0, .name = "Gates Lewis" },
|
||||
.{ .id = 1, .name = "Britt Stokes" },
|
||||
.{ .id = 2, .name = "Reed Wade" },
|
||||
};
|
||||
|
||||
var friends_data: [100]Friend = undefined;
|
||||
for (&friends_data, 0..) |*f, i| {
|
||||
f.* = .{
|
||||
.name = "Gardner Alvarez",
|
||||
.balance = "$1,509.00",
|
||||
.age = 30 + @as(i32, @intCast(i % 20)),
|
||||
.address = "282 Lancaster Avenue, Bowden, Kansas, 666",
|
||||
.picture = "http://placehold.it/32x32",
|
||||
.company = "Dentrex",
|
||||
.email = "gardneralvarez@dentrex.com",
|
||||
.emailHref = "mailto:gardneralvarez@dentrex.com",
|
||||
.about = "Minim elit tempor enim voluptate labore do non nisi sint nulla deserunt officia proident excepteur.",
|
||||
.tags = friend_tags,
|
||||
.friends = sub_friends,
|
||||
};
|
||||
}
|
||||
|
||||
var total_ns: u64 = 0;
|
||||
var timer = try std.time.Timer.start();
|
||||
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
timer.reset();
|
||||
_ = try engine.renderTpl(arena.allocator(), friends_tpl, .{
|
||||
.friends = &friends_data,
|
||||
});
|
||||
total_ns += timer.read();
|
||||
}
|
||||
|
||||
printResult("friends", total_ns, 110);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Helper
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
fn printResult(name: []const u8, total_ns: u64, pug_ref_ms: f64) void {
|
||||
const total_ms = @as(f64, @floatFromInt(total_ns)) / 1_000_000.0;
|
||||
const avg_us = @as(f64, @floatFromInt(total_ns)) / @as(f64, @floatFromInt(iterations)) / 1_000.0;
|
||||
const speedup = pug_ref_ms / total_ms;
|
||||
|
||||
std.debug.print("\n{s:<20} => {d:>6.1}ms ({d:.2}us/render) | Pug.js: {d:.0}ms | {d:.1}x\n", .{
|
||||
name,
|
||||
total_ms,
|
||||
avg_us,
|
||||
pug_ref_ms,
|
||||
speedup,
|
||||
});
|
||||
}
|
||||
170
src/benchmarks/profile_friends.zig
Normal file
170
src/benchmarks/profile_friends.zig
Normal file
@@ -0,0 +1,170 @@
|
||||
const std = @import("std");
|
||||
const pugz = @import("pugz");
|
||||
|
||||
const friends_tpl =
|
||||
\\doctype html
|
||||
\\html(lang="en")
|
||||
\\ head
|
||||
\\ meta(charset="UTF-8")
|
||||
\\ title Friends
|
||||
\\ body
|
||||
\\ div.friends
|
||||
\\ each friend in friends
|
||||
\\ div.friend
|
||||
\\ ul
|
||||
\\ li Name: #{friend.name}
|
||||
\\ li Balance: #{friend.balance}
|
||||
\\ li Age: #{friend.age}
|
||||
\\ li Address: #{friend.address}
|
||||
\\ li Image:
|
||||
\\ img(src=friend.picture)
|
||||
\\ li Company: #{friend.company}
|
||||
\\ li Email:
|
||||
\\ a(href=friend.emailHref) #{friend.email}
|
||||
\\ li About: #{friend.about}
|
||||
\\ if friend.tags
|
||||
\\ li Tags:
|
||||
\\ ul
|
||||
\\ each tag in friend.tags
|
||||
\\ li #{tag}
|
||||
\\ if friend.friends
|
||||
\\ li Friends:
|
||||
\\ ul
|
||||
\\ each subFriend in friend.friends
|
||||
\\ li #{subFriend.name} (#{subFriend.id})
|
||||
;
|
||||
|
||||
const SubFriend = struct { id: i32, name: []const u8 };
|
||||
const Friend = struct {
|
||||
name: []const u8,
|
||||
balance: []const u8,
|
||||
age: i32,
|
||||
address: []const u8,
|
||||
picture: []const u8,
|
||||
company: []const u8,
|
||||
email: []const u8,
|
||||
emailHref: []const u8,
|
||||
about: []const u8,
|
||||
tags: ?[]const []const u8,
|
||||
friends: ?[]const SubFriend,
|
||||
};
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
defer arena.deinit();
|
||||
|
||||
const engine = pugz.ViewEngine.init(.{});
|
||||
|
||||
const friend_tags = &[_][]const u8{ "id", "amet", "non", "ut", "dolore", "commodo", "consequat" };
|
||||
const sub_friends = &[_]SubFriend{
|
||||
.{ .id = 0, .name = "Gates Lewis" },
|
||||
.{ .id = 1, .name = "Britt Stokes" },
|
||||
.{ .id = 2, .name = "Reed Wade" },
|
||||
};
|
||||
|
||||
var friends_data: [100]Friend = undefined;
|
||||
for (&friends_data, 0..) |*f, i| {
|
||||
f.* = .{
|
||||
.name = "Gardner Alvarez",
|
||||
.balance = "$1,509.00",
|
||||
.age = 30 + @as(i32, @intCast(i % 20)),
|
||||
.address = "282 Lancaster Avenue, Bowden, Kansas, 666",
|
||||
.picture = "http://placehold.it/32x32",
|
||||
.company = "Dentrex",
|
||||
.email = "gardneralvarez@dentrex.com",
|
||||
.emailHref = "mailto:gardneralvarez@dentrex.com",
|
||||
.about = "Minim elit tempor enim voluptate labore do non nisi sint nulla deserunt officia proident excepteur.",
|
||||
.tags = friend_tags,
|
||||
.friends = sub_friends,
|
||||
};
|
||||
}
|
||||
|
||||
const data = .{ .friends = &friends_data };
|
||||
|
||||
// Warmup
|
||||
for (0..10) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
_ = try engine.renderTpl(arena.allocator(), friends_tpl, data);
|
||||
}
|
||||
|
||||
// Get output size
|
||||
_ = arena.reset(.retain_capacity);
|
||||
const output = try engine.renderTpl(arena.allocator(), friends_tpl, data);
|
||||
const output_size = output.len;
|
||||
|
||||
// Profile render
|
||||
const iterations: usize = 500;
|
||||
var total_render: u64 = 0;
|
||||
var timer = try std.time.Timer.start();
|
||||
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
timer.reset();
|
||||
_ = try engine.renderTpl(arena.allocator(), friends_tpl, data);
|
||||
total_render += timer.read();
|
||||
}
|
||||
|
||||
const avg_render_us = @as(f64, @floatFromInt(total_render)) / @as(f64, @floatFromInt(iterations)) / 1000.0;
|
||||
const total_ms = @as(f64, @floatFromInt(total_render)) / 1_000_000.0;
|
||||
|
||||
// Header
|
||||
std.debug.print("\n", .{});
|
||||
std.debug.print("╔══════════════════════════════════════════════════════════════╗\n", .{});
|
||||
std.debug.print("║ FRIENDS TEMPLATE CPU PROFILE ║\n", .{});
|
||||
std.debug.print("╠══════════════════════════════════════════════════════════════╣\n", .{});
|
||||
std.debug.print("║ Iterations: {d:<6} Output size: {d:<6} bytes ║\n", .{ iterations, output_size });
|
||||
std.debug.print("╚══════════════════════════════════════════════════════════════╝\n\n", .{});
|
||||
|
||||
// Results
|
||||
std.debug.print("┌────────────────────────────────────┬─────────────────────────┐\n", .{});
|
||||
std.debug.print("│ Metric │ Value │\n", .{});
|
||||
std.debug.print("├────────────────────────────────────┼─────────────────────────┤\n", .{});
|
||||
std.debug.print("│ Total time │ {d:>10.1} ms │\n", .{total_ms});
|
||||
std.debug.print("│ Avg per render │ {d:>10.1} µs │\n", .{avg_render_us});
|
||||
std.debug.print("│ Renders/sec │ {d:>10.0} │\n", .{1_000_000.0 / avg_render_us});
|
||||
std.debug.print("└────────────────────────────────────┴─────────────────────────┘\n", .{});
|
||||
|
||||
// Template complexity breakdown
|
||||
std.debug.print("\n📋 Template Complexity:\n", .{});
|
||||
std.debug.print(" • 100 friends (outer loop)\n", .{});
|
||||
std.debug.print(" • 7 tags per friend (nested loop) = 700 tag iterations\n", .{});
|
||||
std.debug.print(" • 3 sub-friends per friend (nested loop) = 300 sub-friend iterations\n", .{});
|
||||
std.debug.print(" • Total loop iterations: 100 + 700 + 300 = 1,100\n", .{});
|
||||
std.debug.print(" • ~10 interpolations per friend = 1,000+ variable lookups\n", .{});
|
||||
std.debug.print(" • 2 conditionals per friend = 200 conditional evaluations\n", .{});
|
||||
|
||||
// Cost breakdown estimate
|
||||
const loop_iterations: f64 = 1100;
|
||||
const var_lookups: f64 = 1500; // approximate
|
||||
|
||||
std.debug.print("\n💡 Estimated Cost Breakdown (per render):\n", .{});
|
||||
std.debug.print(" Total: {d:.1} µs\n", .{avg_render_us});
|
||||
std.debug.print(" Per loop iteration: ~{d:.2} µs ({d:.0} iterations)\n", .{ avg_render_us / loop_iterations, loop_iterations });
|
||||
std.debug.print(" Per variable lookup: ~{d:.3} µs ({d:.0} lookups)\n", .{ avg_render_us / var_lookups, var_lookups });
|
||||
|
||||
// Comparison
|
||||
std.debug.print("\n📊 Comparison with Pug.js:\n", .{});
|
||||
const pugjs_us: f64 = 55.0; // From benchmark: 110ms / 2000 = 55µs
|
||||
std.debug.print(" Pug.js: {d:.1} µs/render\n", .{pugjs_us});
|
||||
std.debug.print(" Pugz: {d:.1} µs/render\n", .{avg_render_us});
|
||||
const ratio = avg_render_us / pugjs_us;
|
||||
if (ratio > 1.0) {
|
||||
std.debug.print(" Status: Pugz is {d:.1}x SLOWER\n", .{ratio});
|
||||
} else {
|
||||
std.debug.print(" Status: Pugz is {d:.1}x FASTER\n", .{1.0 / ratio});
|
||||
}
|
||||
|
||||
std.debug.print("\nKey Bottlenecks (likely):\n", .{});
|
||||
std.debug.print(" 1. Data conversion: Zig struct -> pugz.Value (comptime reflection)\n", .{});
|
||||
std.debug.print(" 2. Variable lookup: HashMap get() for each interpolation\n", .{});
|
||||
std.debug.print(" 3. AST traversal: Walking tree nodes vs Pug.js compiled JS functions\n", .{});
|
||||
std.debug.print(" 4. Loop scope: Creating/clearing scope per loop iteration\n", .{});
|
||||
|
||||
std.debug.print("\nAlready optimized:\n", .{});
|
||||
std.debug.print(" - Scope pooling (reuse hashmap capacity)\n", .{});
|
||||
std.debug.print(" - Batched HTML escaping\n", .{});
|
||||
std.debug.print(" - Arena allocator with retain_capacity\n", .{});
|
||||
}
|
||||
30
src/benchmarks/templates/friends.pug
Normal file
30
src/benchmarks/templates/friends.pug
Normal file
@@ -0,0 +1,30 @@
|
||||
doctype html
|
||||
html(lang="en")
|
||||
head
|
||||
meta(charset="UTF-8")
|
||||
title Friends
|
||||
body
|
||||
div.friends
|
||||
each friend in friends
|
||||
div.friend
|
||||
ul
|
||||
li Name: #{friend.name}
|
||||
li Balance: #{friend.balance}
|
||||
li Age: #{friend.age}
|
||||
li Address: #{friend.address}
|
||||
li Image:
|
||||
img(src=friend.picture)
|
||||
li Company: #{friend.company}
|
||||
li Email:
|
||||
a(href=friend.emailHref) #{friend.email}
|
||||
li About: #{friend.about}
|
||||
if friend.tags
|
||||
li Tags:
|
||||
ul
|
||||
each tag in friend.tags
|
||||
li #{tag}
|
||||
if friend.friends
|
||||
li Friends:
|
||||
ul
|
||||
each subFriend in friend.friends
|
||||
li #{subFriend.name} (#{subFriend.id})
|
||||
13
src/benchmarks/templates/if-expression.pug
Normal file
13
src/benchmarks/templates/if-expression.pug
Normal file
@@ -0,0 +1,13 @@
|
||||
each account in accounts
|
||||
div
|
||||
if account.status == "closed"
|
||||
div Your account has been closed!
|
||||
if account.status == "suspended"
|
||||
div Your account has been temporarily suspended
|
||||
if account.status == "open"
|
||||
div
|
||||
| Bank balance:
|
||||
if account.negative
|
||||
span.negative= account.balanceFormatted
|
||||
else
|
||||
span.positive= account.balanceFormatted
|
||||
11
src/benchmarks/templates/projects-escaped.pug
Normal file
11
src/benchmarks/templates/projects-escaped.pug
Normal file
@@ -0,0 +1,11 @@
|
||||
doctype html
|
||||
html
|
||||
head
|
||||
title #{title}
|
||||
body
|
||||
p #{text}
|
||||
each project in projects
|
||||
a(href=project.url) #{project.name}
|
||||
p #{project.description}
|
||||
else
|
||||
p No projects
|
||||
33
src/benchmarks/templates/search-results.pug
Normal file
33
src/benchmarks/templates/search-results.pug
Normal file
@@ -0,0 +1,33 @@
|
||||
.search-results-container
|
||||
.searching#searching
|
||||
.wait-indicator-icon Searching...
|
||||
#resultsContainer
|
||||
.hd
|
||||
span.count
|
||||
span#count= totalCount
|
||||
| results
|
||||
.view-modifiers
|
||||
.view-select
|
||||
| View:
|
||||
.view-icon.view-icon-selected#viewIconGallery
|
||||
i.icon-th
|
||||
.view-icon#viewIconList
|
||||
i.icon-th-list
|
||||
#resultsTarget
|
||||
.search-results.view-gallery
|
||||
each searchRecord in searchRecords
|
||||
.search-item
|
||||
.search-item-container.drop-shadow
|
||||
.img-container
|
||||
img(src=searchRecord.imgUrl)
|
||||
h4.title
|
||||
a(href=searchRecord.viewItemUrl)= searchRecord.title
|
||||
| #{searchRecord.description}
|
||||
if searchRecord.featured
|
||||
div Featured!
|
||||
if searchRecord.sizes
|
||||
div
|
||||
| Sizes available:
|
||||
ul
|
||||
each size in searchRecord.sizes
|
||||
li= size
|
||||
1
src/benchmarks/templates/simple-0.pug
Normal file
1
src/benchmarks/templates/simple-0.pug
Normal file
@@ -0,0 +1 @@
|
||||
h1 Hello, #{name}
|
||||
14
src/benchmarks/templates/simple-1.pug
Normal file
14
src/benchmarks/templates/simple-1.pug
Normal file
@@ -0,0 +1,14 @@
|
||||
.simple-1(style="background-color: blue; border: 1px solid black")
|
||||
.colors
|
||||
span.hello Hello #{name}!
|
||||
strong You have #{messageCount} messages!
|
||||
if colors
|
||||
ul
|
||||
each color in colors
|
||||
li.color= color
|
||||
else
|
||||
div No colors!
|
||||
if primary
|
||||
button(type="button" class="primary") Click me!
|
||||
else
|
||||
button(type="button" class="secondary") Click me!
|
||||
10
src/benchmarks/templates/simple-2.pug
Normal file
10
src/benchmarks/templates/simple-2.pug
Normal file
@@ -0,0 +1,10 @@
|
||||
div
|
||||
h1.header #{header}
|
||||
h2.header2 #{header2}
|
||||
h3.header3 #{header3}
|
||||
h4.header4 #{header4}
|
||||
h5.header5 #{header5}
|
||||
h6.header6 #{header6}
|
||||
ul.list
|
||||
each item in list
|
||||
li.item #{item}
|
||||
@@ -21,18 +21,14 @@ const App = struct {
|
||||
allocator: Allocator,
|
||||
view: pugz.ViewEngine,
|
||||
|
||||
pub fn init(allocator: Allocator) !App {
|
||||
pub fn init(allocator: Allocator) App {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.view = try pugz.ViewEngine.init(allocator, .{
|
||||
.view = pugz.ViewEngine.init(.{
|
||||
.views_dir = "src/examples/demo/views",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *App) void {
|
||||
self.view.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
pub fn main() !void {
|
||||
@@ -42,8 +38,7 @@ pub fn main() !void {
|
||||
const allocator = gpa.allocator();
|
||||
|
||||
// Initialize view engine once at startup
|
||||
var app = try App.init(allocator);
|
||||
defer app.deinit();
|
||||
var app = App.init(allocator);
|
||||
|
||||
const port = 8080;
|
||||
var server = try httpz.Server(*App).init(allocator, .{ .port = port }, &app);
|
||||
@@ -82,6 +77,7 @@ pub fn main() !void {
|
||||
fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||
const html = app.view.render(app.allocator, "index", .{
|
||||
.title = "Home",
|
||||
.authenticated = true,
|
||||
}) catch |err| {
|
||||
res.status = 500;
|
||||
res.body = @errorName(err);
|
||||
|
||||
@@ -7,3 +7,9 @@ html
|
||||
| ballah
|
||||
| ballah
|
||||
+btn("click me ", "secondary")
|
||||
br
|
||||
a(href='//google.com' target="_blank") Google 1
|
||||
br
|
||||
a(class='button' href='//google.com' target="_blank") Google 2
|
||||
br
|
||||
a(class='button', href='//google.com' target="_blank") Google 3
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
//! - Calling mixins
|
||||
//! - Template inheritance (extends/block)
|
||||
//! - Includes
|
||||
//!
|
||||
//! **Memory Management**: Use an arena allocator for best performance and
|
||||
//! automatic cleanup. The runtime allocates intermediate strings during
|
||||
//! template processing that are cleaned up when the arena is reset/deinitialized.
|
||||
//!
|
||||
//! ```zig
|
||||
//! var arena = std.heap.ArenaAllocator.init(gpa.allocator());
|
||||
//! defer arena.deinit();
|
||||
//!
|
||||
//! const html = try engine.renderTpl(arena.allocator(), template, data);
|
||||
//! // Use html... arena.deinit() frees everything
|
||||
//! ```
|
||||
|
||||
const std = @import("std");
|
||||
const ast = @import("ast.zig");
|
||||
@@ -86,7 +98,10 @@ pub const RuntimeError = error{
|
||||
pub const Context = struct {
|
||||
allocator: std.mem.Allocator,
|
||||
/// Stack of variable scopes (innermost last).
|
||||
/// We keep all scopes allocated and track active depth with scope_depth.
|
||||
scopes: std.ArrayListUnmanaged(std.StringHashMapUnmanaged(Value)),
|
||||
/// Current active scope depth (scopes[0..scope_depth] are active).
|
||||
scope_depth: usize,
|
||||
/// Mixin definitions available in this context.
|
||||
mixins: std.StringHashMapUnmanaged(ast.MixinDef),
|
||||
|
||||
@@ -94,6 +109,7 @@ pub const Context = struct {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.scopes = .empty,
|
||||
.scope_depth = 0,
|
||||
.mixins = .empty,
|
||||
};
|
||||
}
|
||||
@@ -107,32 +123,40 @@ pub const Context = struct {
|
||||
}
|
||||
|
||||
/// Pushes a new scope onto the stack.
|
||||
/// Reuses previously allocated scopes when possible to avoid allocation overhead.
|
||||
pub fn pushScope(self: *Context) !void {
|
||||
try self.scopes.append(self.allocator, .empty);
|
||||
if (self.scope_depth < self.scopes.items.len) {
|
||||
// Reuse existing scope slot (already cleared on pop)
|
||||
} else {
|
||||
// Need to allocate a new scope
|
||||
try self.scopes.append(self.allocator, .empty);
|
||||
}
|
||||
self.scope_depth += 1;
|
||||
}
|
||||
|
||||
/// Pops the current scope from the stack.
|
||||
/// Clears scope for reuse but does NOT deallocate.
|
||||
pub fn popScope(self: *Context) void {
|
||||
if (self.scopes.items.len > 0) {
|
||||
var scope = self.scopes.items[self.scopes.items.len - 1];
|
||||
self.scopes.items.len -= 1;
|
||||
scope.deinit(self.allocator);
|
||||
if (self.scope_depth > 0) {
|
||||
self.scope_depth -= 1;
|
||||
// Clear the scope so old values don't leak into next use
|
||||
self.scopes.items[self.scope_depth].clearRetainingCapacity();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets a variable in the current scope.
|
||||
pub fn set(self: *Context, name: []const u8, value: Value) !void {
|
||||
if (self.scopes.items.len == 0) {
|
||||
if (self.scope_depth == 0) {
|
||||
try self.pushScope();
|
||||
}
|
||||
const current = &self.scopes.items[self.scopes.items.len - 1];
|
||||
const current = &self.scopes.items[self.scope_depth - 1];
|
||||
try current.put(self.allocator, name, value);
|
||||
}
|
||||
|
||||
/// Gets a variable, searching from innermost to outermost scope.
|
||||
pub fn get(self: *Context, name: []const u8) ?Value {
|
||||
// Search from innermost to outermost scope
|
||||
var i = self.scopes.items.len;
|
||||
var i = self.scope_depth;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
if (self.scopes.items[i].get(name)) |value| {
|
||||
@@ -570,10 +594,12 @@ pub const Runtime = struct {
|
||||
return;
|
||||
}
|
||||
|
||||
for (items, 0..) |item, index| {
|
||||
try self.context.pushScope();
|
||||
defer self.context.popScope();
|
||||
// Push scope once before the loop - reuse for all iterations
|
||||
try self.context.pushScope();
|
||||
defer self.context.popScope();
|
||||
|
||||
for (items, 0..) |item, index| {
|
||||
// Just overwrite the loop variable (no scope push/pop per iteration)
|
||||
try self.context.set(each.value_name, item);
|
||||
if (each.index_name) |idx_name| {
|
||||
try self.context.set(idx_name, Value.integer(@intCast(index)));
|
||||
@@ -592,12 +618,14 @@ pub const Runtime = struct {
|
||||
return;
|
||||
}
|
||||
|
||||
// Push scope once before the loop - reuse for all iterations
|
||||
try self.context.pushScope();
|
||||
defer self.context.popScope();
|
||||
|
||||
var iter = obj.iterator();
|
||||
var index: usize = 0;
|
||||
while (iter.next()) |entry| {
|
||||
try self.context.pushScope();
|
||||
defer self.context.popScope();
|
||||
|
||||
// Just overwrite the loop variable (no scope push/pop per iteration)
|
||||
try self.context.set(each.value_name, entry.value_ptr.*);
|
||||
if (each.index_name) |idx_name| {
|
||||
try self.context.set(idx_name, Value.str(entry.key_ptr.*));
|
||||
@@ -1274,16 +1302,29 @@ pub const Runtime = struct {
|
||||
}
|
||||
|
||||
fn writeEscaped(self: *Runtime, str: []const u8) Error!void {
|
||||
for (str) |c| {
|
||||
switch (c) {
|
||||
'&' => try self.write("&"),
|
||||
'<' => try self.write("<"),
|
||||
'>' => try self.write(">"),
|
||||
'"' => try self.write("""),
|
||||
'\'' => try self.write("'"),
|
||||
else => try self.output.append(self.allocator, c),
|
||||
var start: usize = 0;
|
||||
for (str, 0..) |c, i| {
|
||||
const escape: ?[]const u8 = switch (c) {
|
||||
'&' => "&",
|
||||
'<' => "<",
|
||||
'>' => ">",
|
||||
'"' => """,
|
||||
'\'' => "'",
|
||||
else => null,
|
||||
};
|
||||
if (escape) |esc| {
|
||||
// Write accumulated non-escaped chars first
|
||||
if (i > start) {
|
||||
try self.output.appendSlice(self.allocator, str[start..i]);
|
||||
}
|
||||
try self.output.appendSlice(self.allocator, esc);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
// Write remaining non-escaped chars
|
||||
if (start < str.len) {
|
||||
try self.output.appendSlice(self.allocator, str[start..]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
//! Template test cases for Pugz engine
|
||||
//!
|
||||
//! Run with: zig build test
|
||||
//! Or run specific: zig test src/test_templates.zig
|
||||
|
||||
const std = @import("std");
|
||||
const pugz = @import("root.zig");
|
||||
|
||||
/// Helper to compile and render a template with data
|
||||
fn render(allocator: std.mem.Allocator, source: []const u8, setData: fn (*pugz.Context) anyerror!void) ![]u8 {
|
||||
var lexer = pugz.Lexer.init(allocator, source);
|
||||
const tokens = try lexer.tokenize();
|
||||
|
||||
var parser = pugz.Parser.init(allocator, tokens);
|
||||
const doc = try parser.parse();
|
||||
|
||||
var ctx = pugz.Context.init(allocator);
|
||||
defer ctx.deinit();
|
||||
|
||||
try ctx.pushScope();
|
||||
try setData(&ctx);
|
||||
|
||||
var runtime = pugz.Runtime.init(allocator, &ctx, .{ .pretty = false });
|
||||
defer runtime.deinit();
|
||||
|
||||
return runtime.renderOwned(doc);
|
||||
}
|
||||
|
||||
/// Helper for templates with no data
|
||||
fn renderNoData(allocator: std.mem.Allocator, source: []const u8) ![]u8 {
|
||||
return render(allocator, source, struct {
|
||||
fn set(_: *pugz.Context) anyerror!void {}
|
||||
}.set);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test Cases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test "simple tag" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "p Hello");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<p>Hello</p>", html);
|
||||
}
|
||||
|
||||
test "tag with class" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "p.intro Hello");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<p class=\"intro\">Hello</p>", html);
|
||||
}
|
||||
|
||||
test "tag with id" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "div#main");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<div id=\"main\"></div>", html);
|
||||
}
|
||||
|
||||
test "tag with id and class" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "div#main.container");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<div id=\"main\" class=\"container\"></div>", html);
|
||||
}
|
||||
|
||||
test "multiple classes" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "div.foo.bar.baz");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<div class=\"foo bar baz\"></div>", html);
|
||||
}
|
||||
|
||||
test "interpolation with data" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try render(allocator, "p #{name}'s code", struct {
|
||||
fn set(ctx: *pugz.Context) anyerror!void {
|
||||
try ctx.set("name", pugz.Value.str("ankit patial"));
|
||||
}
|
||||
}.set);
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<p>ankit patial's code</p>", html);
|
||||
}
|
||||
|
||||
test "interpolation at start of text" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try render(allocator, "title #{title}", struct {
|
||||
fn set(ctx: *pugz.Context) anyerror!void {
|
||||
try ctx.set("title", pugz.Value.str("My Page"));
|
||||
}
|
||||
}.set);
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<title>My Page</title>", html);
|
||||
}
|
||||
|
||||
test "multiple interpolations" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try render(allocator, "p #{a} and #{b}", struct {
|
||||
fn set(ctx: *pugz.Context) anyerror!void {
|
||||
try ctx.set("a", pugz.Value.str("foo"));
|
||||
try ctx.set("b", pugz.Value.str("bar"));
|
||||
}
|
||||
}.set);
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<p>foo and bar</p>", html);
|
||||
}
|
||||
|
||||
test "integer interpolation" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try render(allocator, "p Count: #{count}", struct {
|
||||
fn set(ctx: *pugz.Context) anyerror!void {
|
||||
try ctx.set("count", pugz.Value.integer(42));
|
||||
}
|
||||
}.set);
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<p>Count: 42</p>", html);
|
||||
}
|
||||
|
||||
test "void element br" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "br");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<br />", html);
|
||||
}
|
||||
|
||||
test "void element img with attributes" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "img(src=\"logo.png\" alt=\"Logo\")");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<img src=\"logo.png\" alt=\"Logo\" />", html);
|
||||
}
|
||||
|
||||
test "attribute with single quotes" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "a(href='//google.com')");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<a href=\"//google.com\"></a>", html);
|
||||
}
|
||||
|
||||
test "attribute with double quotes" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "a(href=\"//google.com\")");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<a href=\"//google.com\"></a>", html);
|
||||
}
|
||||
|
||||
test "multiple attributes with comma" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "a(class='btn', href='/link')");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<a class=\"btn\" href=\"/link\"></a>", html);
|
||||
}
|
||||
|
||||
test "multiple attributes without comma" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "a(class='btn' href='/link')");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<a class=\"btn\" href=\"/link\"></a>", html);
|
||||
}
|
||||
|
||||
test "boolean attribute" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "input(type=\"checkbox\" checked)");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<input type=\"checkbox\" checked />", html);
|
||||
}
|
||||
|
||||
test "html comment" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "// This is a comment");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<!-- This is a comment -->", html);
|
||||
}
|
||||
|
||||
test "unbuffered comment not rendered" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "//- Hidden comment");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("", html);
|
||||
}
|
||||
|
||||
test "nested elements" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator,
|
||||
\\div
|
||||
\\ p Hello
|
||||
);
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<div><p>Hello</p></div>", html);
|
||||
}
|
||||
|
||||
test "deeply nested elements" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator,
|
||||
\\html
|
||||
\\ body
|
||||
\\ div
|
||||
\\ p Text
|
||||
);
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<html><body><div><p>Text</p></div></body></html>", html);
|
||||
}
|
||||
|
||||
test "sibling elements" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator,
|
||||
\\ul
|
||||
\\ li One
|
||||
\\ li Two
|
||||
\\ li Three
|
||||
);
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<ul><li>One</li><li>Two</li><li>Three</li></ul>", html);
|
||||
}
|
||||
|
||||
test "div shorthand with class only" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, ".container");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<div class=\"container\"></div>", html);
|
||||
}
|
||||
|
||||
test "div shorthand with id only" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "#main");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<div id=\"main\"></div>", html);
|
||||
}
|
||||
|
||||
test "class and id on div shorthand" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "#main.container.active");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<div id=\"main\" class=\"container active\"></div>", html);
|
||||
}
|
||||
|
||||
test "html escaping in text" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try renderNoData(allocator, "p <script>alert('xss')</script>");
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<p><script>alert('xss')</script></p>", html);
|
||||
}
|
||||
|
||||
test "html escaping in interpolation" {
|
||||
const allocator = std.testing.allocator;
|
||||
const html = try render(allocator, "p #{code}", struct {
|
||||
fn set(ctx: *pugz.Context) anyerror!void {
|
||||
try ctx.set("code", pugz.Value.str("<b>bold</b>"));
|
||||
}
|
||||
}.set);
|
||||
defer allocator.free(html);
|
||||
try std.testing.expectEqualStrings("<p><b>bold</b></p>", html);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Known Issues / TODO Tests (these document expected behavior not yet working)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TODO: Inline text after attributes
|
||||
// test "inline text after attributes" {
|
||||
// const allocator = std.testing.allocator;
|
||||
// const html = try renderNoData(allocator, "a(href='//google.com') Google");
|
||||
// defer allocator.free(html);
|
||||
// try std.testing.expectEqualStrings("<a href=\"//google.com\">Google</a>", html);
|
||||
// }
|
||||
|
||||
// TODO: Pipe text for newlines
|
||||
// test "pipe text" {
|
||||
// const allocator = std.testing.allocator;
|
||||
// const html = try renderNoData(allocator,
|
||||
// \\p
|
||||
// \\ | Line 1
|
||||
// \\ | Line 2
|
||||
// );
|
||||
// defer allocator.free(html);
|
||||
// try std.testing.expectEqualStrings("<p>Line 1Line 2</p>", html);
|
||||
// }
|
||||
|
||||
// TODO: Block expansion with colon
|
||||
// test "block expansion" {
|
||||
// const allocator = std.testing.allocator;
|
||||
// const html = try renderNoData(allocator, "ul: li Item");
|
||||
// defer allocator.free(html);
|
||||
// try std.testing.expectEqualStrings("<ul><li>Item</li></ul>", html);
|
||||
// }
|
||||
@@ -11,30 +11,32 @@
|
||||
//!
|
||||
//! Example:
|
||||
//! ```zig
|
||||
//! var engine = try ViewEngine.init(allocator, .{
|
||||
//! const engine = ViewEngine.init(.{
|
||||
//! .views_dir = "src/views",
|
||||
//! });
|
||||
//! defer engine.deinit();
|
||||
//!
|
||||
//! const html = try engine.render(arena.allocator(), "pages/home", .{
|
||||
//! .title = "Home",
|
||||
//! });
|
||||
//! // Render from file
|
||||
//! const html = try engine.render(allocator, "pages/home", .{ .title = "Home" });
|
||||
//! defer allocator.free(html);
|
||||
//!
|
||||
//! // Render from template string (for embedded or cached templates)
|
||||
//! const tpl = "h1 #{title}";
|
||||
//! const out = try engine.renderTpl(allocator, tpl, .{ .title = "Hello" });
|
||||
//! defer allocator.free(out);
|
||||
//! ```
|
||||
|
||||
const std = @import("std");
|
||||
const Lexer = @import("lexer.zig").Lexer;
|
||||
const Parser = @import("parser.zig").Parser;
|
||||
const runtime = @import("runtime.zig");
|
||||
const ast = @import("ast.zig");
|
||||
|
||||
const Runtime = runtime.Runtime;
|
||||
const Context = runtime.Context;
|
||||
const Value = runtime.Value;
|
||||
|
||||
/// Configuration options for the ViewEngine.
|
||||
pub const Options = struct {
|
||||
/// Root directory containing view templates.
|
||||
views_dir: []const u8,
|
||||
/// Root directory containing view templates. Defaults to current directory.
|
||||
views_dir: []const u8 = ".",
|
||||
/// Subdirectory within views_dir containing mixin files.
|
||||
/// Defaults to "mixins". Mixins are lazy-loaded on first use.
|
||||
/// Set to null to disable mixin directory lookup.
|
||||
@@ -57,57 +59,30 @@ pub const ViewEngineError = error{
|
||||
/// 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,
|
||||
/// Absolute path to mixins directory (resolved at init).
|
||||
mixins_path: []const u8,
|
||||
/// Cached file resolver (avoid creating new closure each render).
|
||||
file_resolver: runtime.FileResolver,
|
||||
|
||||
/// Initializes the ViewEngine with the given options.
|
||||
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);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
pub fn init(options: Options) ViewEngine {
|
||||
return ViewEngine{
|
||||
.allocator = allocator,
|
||||
.options = options,
|
||||
.views_path = views_path,
|
||||
.mixins_path = mixins_path,
|
||||
.file_resolver = createFileResolver(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Releases all resources held by the ViewEngine.
|
||||
pub fn deinit(self: *ViewEngine) void {
|
||||
self.allocator.free(self.views_path);
|
||||
if (self.mixins_path.len > 0) {
|
||||
self.allocator.free(self.mixins_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a template with the given data context.
|
||||
/// Renders a template file with the given data context.
|
||||
///
|
||||
/// 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", .{
|
||||
/// .title = "Home Page",
|
||||
/// });
|
||||
/// ```
|
||||
pub fn render(self: *ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, data: anytype) ![]u8 {
|
||||
pub fn render(self: *const ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, data: anytype) ![]u8 {
|
||||
// Build full path
|
||||
const full_path = try self.resolvePath(allocator, template_path);
|
||||
defer allocator.free(full_path);
|
||||
@@ -118,6 +93,29 @@ pub const ViewEngine = struct {
|
||||
};
|
||||
defer allocator.free(source);
|
||||
|
||||
return self.renderTpl(allocator, source, data);
|
||||
}
|
||||
|
||||
/// Renders a template string directly without file I/O.
|
||||
///
|
||||
/// Use this when you have the template source in memory (e.g., from a cache
|
||||
/// or embedded at compile time). This avoids file system overhead.
|
||||
///
|
||||
/// For high-performance loops, pass an arena allocator that resets between iterations.
|
||||
///
|
||||
/// Example:
|
||||
/// ```zig
|
||||
/// const tpl = "h1 Hello, #{name}";
|
||||
/// const html = try engine.renderTpl(allocator, tpl, .{ .name = "World" });
|
||||
/// ```
|
||||
pub fn renderTpl(self: *const ViewEngine, allocator: std.mem.Allocator, source: []const u8, data: anytype) ![]u8 {
|
||||
// Resolve mixins path
|
||||
const mixins_path = if (self.options.mixins_dir) |mixins_subdir|
|
||||
try std.fs.path.join(allocator, &.{ self.options.views_dir, mixins_subdir })
|
||||
else
|
||||
"";
|
||||
defer if (mixins_path.len > 0) allocator.free(mixins_path);
|
||||
|
||||
// Tokenize
|
||||
var lexer = Lexer.init(allocator, source);
|
||||
defer lexer.deinit();
|
||||
@@ -138,12 +136,12 @@ pub const ViewEngine = struct {
|
||||
try ctx.set(field.name, runtime.toValue(allocator, value));
|
||||
}
|
||||
|
||||
// Create runtime with file resolver for includes/extends and lazy mixin loading
|
||||
// Create runtime with cached file resolver
|
||||
var rt = Runtime.init(allocator, &ctx, .{
|
||||
.pretty = self.options.pretty,
|
||||
.base_dir = self.views_path,
|
||||
.mixins_dir = self.mixins_path,
|
||||
.file_resolver = createFileResolver(),
|
||||
.base_dir = self.options.views_dir,
|
||||
.mixins_dir = mixins_path,
|
||||
.file_resolver = self.file_resolver,
|
||||
});
|
||||
defer rt.deinit();
|
||||
|
||||
@@ -151,7 +149,7 @@ pub const ViewEngine = struct {
|
||||
}
|
||||
|
||||
/// Resolves a template path relative to views directory.
|
||||
fn resolvePath(self: *ViewEngine, allocator: std.mem.Allocator, template_path: []const u8) ![]const u8 {
|
||||
fn resolvePath(self: *const ViewEngine, allocator: std.mem.Allocator, template_path: []const u8) ![]const u8 {
|
||||
// Add extension if not present
|
||||
const with_ext = if (std.mem.endsWith(u8, template_path, self.options.extension))
|
||||
try allocator.dupe(u8, template_path)
|
||||
@@ -159,7 +157,7 @@ pub const ViewEngine = struct {
|
||||
try std.fmt.allocPrint(allocator, "{s}{s}", .{ template_path, self.options.extension });
|
||||
defer allocator.free(with_ext);
|
||||
|
||||
return std.fs.path.join(allocator, &.{ self.views_path, with_ext });
|
||||
return std.fs.path.join(allocator, &.{ self.options.views_dir, with_ext });
|
||||
}
|
||||
|
||||
/// Creates a file resolver function for the runtime.
|
||||
|
||||
Reference in New Issue
Block a user