feat: add template inheritance (extends/block) support
- ViewEngine now supports extends and named blocks - Each route gets exclusive cached AST (no shared parent layouts) - Fix iteration over struct arrays in each loops - Add demo app with full e-commerce layout using extends - Serve static files from public folder - Bump version to 0.3.0
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
//! Pugz Benchmark - Template Rendering
|
||||
//!
|
||||
//! This benchmark uses template.zig renderWithData function.
|
||||
//! This benchmark parses templates ONCE, then renders 2000 times.
|
||||
//! This matches how Pug.js benchmark works (compile once, render many).
|
||||
//!
|
||||
//! Run: zig build bench-v1
|
||||
//! Run: zig build bench
|
||||
|
||||
const std = @import("std");
|
||||
const pugz = @import("pugz");
|
||||
@@ -59,12 +60,12 @@ pub fn main() !void {
|
||||
|
||||
std.debug.print("\n", .{});
|
||||
std.debug.print("╔═══════════════════════════════════════════════════════════════╗\n", .{});
|
||||
std.debug.print("║ V1 Template Benchmark ({d} iterations) ║\n", .{iterations});
|
||||
std.debug.print("║ Pugz Benchmark ({d} iterations, parse once) ║\n", .{iterations});
|
||||
std.debug.print("║ Templates: {s}/*.pug ║\n", .{templates_dir});
|
||||
std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{});
|
||||
|
||||
// Load JSON data
|
||||
std.debug.print("\nLoading JSON data...\n", .{});
|
||||
std.debug.print("\nLoading JSON data and parsing templates...\n", .{});
|
||||
|
||||
var data_arena = std.heap.ArenaAllocator.init(allocator);
|
||||
defer data_arena.deinit();
|
||||
@@ -95,7 +96,7 @@ pub fn main() !void {
|
||||
const search = try loadJson(struct { searchRecords: []const SearchRecord }, data_alloc, "search-results.json");
|
||||
const friends_data = try loadJson(struct { friends: []const Friend }, data_alloc, "friends.json");
|
||||
|
||||
// Load template sources
|
||||
// Load and PARSE templates ONCE (like Pug.js compiles once)
|
||||
const simple0_tpl = try loadTemplate(data_alloc, "simple-0.pug");
|
||||
const simple1_tpl = try loadTemplate(data_alloc, "simple-1.pug");
|
||||
const simple2_tpl = try loadTemplate(data_alloc, "simple-2.pug");
|
||||
@@ -104,17 +105,26 @@ pub fn main() !void {
|
||||
const search_tpl = try loadTemplate(data_alloc, "search-results.pug");
|
||||
const friends_tpl = try loadTemplate(data_alloc, "friends.pug");
|
||||
|
||||
std.debug.print("Loaded. Starting benchmark...\n\n", .{});
|
||||
// Parse templates once
|
||||
const simple0_ast = try pugz.template.parse(data_alloc, simple0_tpl);
|
||||
const simple1_ast = try pugz.template.parse(data_alloc, simple1_tpl);
|
||||
const simple2_ast = try pugz.template.parse(data_alloc, simple2_tpl);
|
||||
const if_expr_ast = try pugz.template.parse(data_alloc, if_expr_tpl);
|
||||
const projects_ast = try pugz.template.parse(data_alloc, projects_tpl);
|
||||
const search_ast = try pugz.template.parse(data_alloc, search_tpl);
|
||||
const friends_ast = try pugz.template.parse(data_alloc, friends_tpl);
|
||||
|
||||
std.debug.print("Loaded. Starting benchmark (render only)...\n\n", .{});
|
||||
|
||||
var total: f64 = 0;
|
||||
|
||||
total += try bench("simple-0", allocator, simple0_tpl, simple0);
|
||||
total += try bench("simple-1", allocator, simple1_tpl, simple1);
|
||||
total += try bench("simple-2", allocator, simple2_tpl, simple2);
|
||||
total += try bench("if-expression", allocator, if_expr_tpl, if_expr);
|
||||
total += try bench("projects-escaped", allocator, projects_tpl, projects);
|
||||
total += try bench("search-results", allocator, search_tpl, search);
|
||||
total += try bench("friends", allocator, friends_tpl, friends_data);
|
||||
total += try bench("simple-0", allocator, simple0_ast, simple0);
|
||||
total += try bench("simple-1", allocator, simple1_ast, simple1);
|
||||
total += try bench("simple-2", allocator, simple2_ast, simple2);
|
||||
total += try bench("if-expression", allocator, if_expr_ast, if_expr);
|
||||
total += try bench("projects-escaped", allocator, projects_ast, projects);
|
||||
total += try bench("search-results", allocator, search_ast, search);
|
||||
total += try bench("friends", allocator, friends_ast, friends_data);
|
||||
|
||||
std.debug.print("\n", .{});
|
||||
std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ "TOTAL", total });
|
||||
@@ -136,7 +146,7 @@ fn loadTemplate(alloc: std.mem.Allocator, comptime filename: []const u8) ![]cons
|
||||
fn bench(
|
||||
name: []const u8,
|
||||
allocator: std.mem.Allocator,
|
||||
template: []const u8,
|
||||
ast: *pugz.parser.Node,
|
||||
data: anytype,
|
||||
) !f64 {
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
@@ -145,7 +155,7 @@ fn bench(
|
||||
var timer = try std.time.Timer.start();
|
||||
for (0..iterations) |_| {
|
||||
_ = arena.reset(.retain_capacity);
|
||||
_ = pugz.template.renderWithData(arena.allocator(), template, data) catch |err| {
|
||||
_ = pugz.template.renderAst(arena.allocator(), ast, data) catch |err| {
|
||||
std.debug.print(" {s:<20} => ERROR: {}\n", .{ name, err });
|
||||
return 0;
|
||||
};
|
||||
|
||||
@@ -335,6 +335,16 @@ pub const Lexer = struct {
|
||||
|
||||
const IndentType = enum { tabs, spaces };
|
||||
|
||||
/// Get current indent level (top of stack) - O(1)
|
||||
inline fn currentIndent(self: *const Lexer) usize {
|
||||
return self.indent_stack.items[self.indent_stack.items.len - 1];
|
||||
}
|
||||
|
||||
/// Get previous indent level (second from top) - O(1)
|
||||
inline fn previousIndent(self: *const Lexer) usize {
|
||||
return self.indent_stack.items[self.indent_stack.items.len - 2];
|
||||
}
|
||||
|
||||
pub fn init(allocator: Allocator, str: []const u8, options: LexerOptions) !Lexer {
|
||||
// Strip UTF-8 BOM if present
|
||||
var input = str;
|
||||
@@ -391,6 +401,16 @@ pub const Lexer = struct {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deinit without freeing input_allocated - caller takes ownership of it
|
||||
/// Returns the input_allocated slice that caller must free
|
||||
pub fn deinitKeepInput(self: *Lexer) []const u8 {
|
||||
self.indent_stack.deinit(self.allocator);
|
||||
self.tokens.deinit(self.allocator);
|
||||
const input = self.input_allocated;
|
||||
self.input_allocated = &.{}; // Clear so regular deinit won't double-free
|
||||
return input;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Error handling
|
||||
// ========================================================================
|
||||
@@ -634,9 +654,9 @@ pub const Lexer = struct {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add outdent tokens for remaining indentation
|
||||
var i: usize = 0;
|
||||
while (i < self.indent_stack.items.len and self.indent_stack.items[i] > 0) : (i += 1) {
|
||||
// Add outdent tokens for remaining indentation (pop from stack end)
|
||||
while (self.indent_stack.items.len > 1 and self.currentIndent() > 0) {
|
||||
_ = self.indent_stack.pop();
|
||||
var outdent_tok = self.tok(.outdent, .none);
|
||||
self.tokEnd(&outdent_tok);
|
||||
self.tokens.append(self.allocator, outdent_tok) catch return false;
|
||||
@@ -2211,34 +2231,34 @@ pub const Lexer = struct {
|
||||
}
|
||||
|
||||
// Outdent
|
||||
if (indents < self.indent_stack.items[0]) {
|
||||
if (indents < self.currentIndent()) {
|
||||
var outdent_count: usize = 0;
|
||||
while (self.indent_stack.items[0] > indents) {
|
||||
if (self.indent_stack.items.len > 1 and self.indent_stack.items[1] < indents) {
|
||||
while (self.currentIndent() > indents) {
|
||||
if (self.indent_stack.items.len > 1 and self.previousIndent() < indents) {
|
||||
self.setError(.INCONSISTENT_INDENTATION, "Inconsistent indentation");
|
||||
return false;
|
||||
}
|
||||
outdent_count += 1;
|
||||
_ = self.indent_stack.orderedRemove(0);
|
||||
_ = self.indent_stack.pop(); // O(1) instead of O(n)
|
||||
}
|
||||
while (outdent_count > 0) : (outdent_count -= 1) {
|
||||
self.colno = 1;
|
||||
var outdent_token = self.tok(.outdent, .none);
|
||||
self.colno = self.indent_stack.items[0] + 1;
|
||||
self.colno = self.currentIndent() + 1;
|
||||
self.tokens.append(self.allocator, outdent_token) catch return false;
|
||||
self.tokEnd(&outdent_token);
|
||||
}
|
||||
} else if (indents > 0 and indents != self.indent_stack.items[0]) {
|
||||
} else if (indents > 0 and indents != self.currentIndent()) {
|
||||
// Indent
|
||||
var indent_token = self.tok(.indent, .none);
|
||||
self.colno = 1 + indents;
|
||||
self.tokens.append(self.allocator, indent_token) catch return false;
|
||||
self.tokEnd(&indent_token);
|
||||
self.indent_stack.insert(self.allocator, 0, indents) catch return false;
|
||||
self.indent_stack.append(self.allocator, indents) catch return false; // O(1) instead of O(n)
|
||||
} else {
|
||||
// Newline
|
||||
var newline_token = self.tok(.newline, .none);
|
||||
self.colno = 1 + @min(self.indent_stack.items[0], indents);
|
||||
self.colno = 1 + @min(self.currentIndent(), indents);
|
||||
self.tokens.append(self.allocator, newline_token) catch return false;
|
||||
self.tokEnd(&newline_token);
|
||||
}
|
||||
@@ -2253,7 +2273,7 @@ pub const Lexer = struct {
|
||||
const captures = self.scanIndentation() orelse return false;
|
||||
const indents = forced_indents orelse captures.indent.len;
|
||||
|
||||
if (indents <= self.indent_stack.items[0]) return false;
|
||||
if (indents <= self.currentIndent()) return false;
|
||||
|
||||
var start_token = self.tok(.start_pipeless_text, .none);
|
||||
self.tokEnd(&start_token);
|
||||
@@ -2307,7 +2327,7 @@ pub const Lexer = struct {
|
||||
else
|
||||
"";
|
||||
tokens_list.append(self.allocator, text_content) catch return false;
|
||||
} else if (line_indent > self.indent_stack.items[0]) {
|
||||
} else if (line_indent > self.currentIndent()) {
|
||||
// line is indented less than the first line but is still indented
|
||||
// need to retry lexing the text block with new indent level
|
||||
_ = self.tokens.pop();
|
||||
|
||||
37
src/load.zig
37
src/load.zig
@@ -98,6 +98,7 @@ pub const LoadError = error{
|
||||
ParseError,
|
||||
WalkError,
|
||||
InvalidUtf8,
|
||||
PathEscapesRoot,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -121,7 +122,33 @@ pub const LoadResult = struct {
|
||||
// Default Implementations
|
||||
// ============================================================================
|
||||
|
||||
/// Check if path is safe (doesn't escape root via .. or other tricks)
|
||||
/// Returns false if path would escape the root directory.
|
||||
pub fn isPathSafe(path: []const u8) bool {
|
||||
// Reject absolute paths
|
||||
if (path.len > 0 and path[0] == '/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
var depth: i32 = 0;
|
||||
var iter = mem.splitScalar(u8, path, '/');
|
||||
|
||||
while (iter.next()) |component| {
|
||||
if (component.len == 0 or mem.eql(u8, component, ".")) {
|
||||
continue;
|
||||
}
|
||||
if (mem.eql(u8, component, "..")) {
|
||||
depth -= 1;
|
||||
if (depth < 0) return false; // Escaped root
|
||||
} else {
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Default path resolution - handles relative and absolute paths
|
||||
/// Rejects paths that would escape the base directory.
|
||||
pub fn defaultResolve(
|
||||
filename: []const u8,
|
||||
source: ?[]const u8,
|
||||
@@ -133,6 +160,11 @@ pub fn defaultResolve(
|
||||
return error.InvalidPath;
|
||||
}
|
||||
|
||||
// Security: reject paths that escape root
|
||||
if (!isPathSafe(trimmed)) {
|
||||
return error.PathEscapesRoot;
|
||||
}
|
||||
|
||||
// Absolute path (starts with /)
|
||||
if (trimmed[0] == '/') {
|
||||
if (options.basedir == null) {
|
||||
@@ -369,10 +401,11 @@ test "pathJoin - absolute paths" {
|
||||
try std.testing.expectEqualStrings("/absolute/path.pug", result);
|
||||
}
|
||||
|
||||
test "defaultResolve - missing basedir for absolute path" {
|
||||
test "defaultResolve - rejects absolute paths as path escape" {
|
||||
const options = LoadOptions{};
|
||||
const result = defaultResolve("/absolute/path.pug", null, &options);
|
||||
try std.testing.expectError(error.MissingBasedir, result);
|
||||
// Absolute paths are rejected as path escape (security boundary)
|
||||
try std.testing.expectError(error.PathEscapesRoot, result);
|
||||
}
|
||||
|
||||
test "defaultResolve - missing filename for relative path" {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub const pug = @import("pug.zig");
|
||||
pub const view_engine = @import("view_engine.zig");
|
||||
pub const template = @import("template.zig");
|
||||
pub const parser = @import("parser.zig");
|
||||
|
||||
// Re-export main types
|
||||
pub const ViewEngine = view_engine.ViewEngine;
|
||||
|
||||
@@ -89,66 +89,60 @@ pub const AttrValue = union(enum) {
|
||||
/// Returns empty string for false/null values.
|
||||
/// For true values, returns terse form " key" or full form " key="key"".
|
||||
pub fn attr(allocator: Allocator, key: []const u8, val: AttrValue, escaped: bool, terse: bool) ![]const u8 {
|
||||
var result: ArrayListUnmanaged(u8) = .{};
|
||||
errdefer result.deinit(allocator);
|
||||
try appendAttr(allocator, &result, key, val, escaped, terse);
|
||||
if (result.items.len == 0) {
|
||||
return "";
|
||||
}
|
||||
return try result.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Append attribute directly to output buffer - avoids intermediate allocations
|
||||
/// This is the preferred method for rendering attributes in hot paths
|
||||
pub fn appendAttr(allocator: Allocator, output: *ArrayListUnmanaged(u8), key: []const u8, val: AttrValue, escaped: bool, terse: bool) !void {
|
||||
switch (val) {
|
||||
.none => return try allocator.dupe(u8, ""),
|
||||
.none => return,
|
||||
.boolean => |b| {
|
||||
if (!b) return try allocator.dupe(u8, "");
|
||||
if (!b) return;
|
||||
// true value
|
||||
if (terse) {
|
||||
var result: ArrayListUnmanaged(u8) = .{};
|
||||
errdefer result.deinit(allocator);
|
||||
try result.append(allocator, ' ');
|
||||
try result.appendSlice(allocator, key);
|
||||
return try result.toOwnedSlice(allocator);
|
||||
} else {
|
||||
var result: ArrayListUnmanaged(u8) = .{};
|
||||
errdefer result.deinit(allocator);
|
||||
try result.append(allocator, ' ');
|
||||
try result.appendSlice(allocator, key);
|
||||
try result.appendSlice(allocator, "=\"");
|
||||
try result.appendSlice(allocator, key);
|
||||
try result.append(allocator, '"');
|
||||
return try result.toOwnedSlice(allocator);
|
||||
try output.append(allocator, ' ');
|
||||
try output.appendSlice(allocator, key);
|
||||
if (!terse) {
|
||||
try output.appendSlice(allocator, "=\"");
|
||||
try output.appendSlice(allocator, key);
|
||||
try output.append(allocator, '"');
|
||||
}
|
||||
},
|
||||
.number => |n| {
|
||||
var result: ArrayListUnmanaged(u8) = .{};
|
||||
errdefer result.deinit(allocator);
|
||||
try result.append(allocator, ' ');
|
||||
try result.appendSlice(allocator, key);
|
||||
try result.appendSlice(allocator, "=\"");
|
||||
try output.append(allocator, ' ');
|
||||
try output.appendSlice(allocator, key);
|
||||
try output.appendSlice(allocator, "=\"");
|
||||
|
||||
// Format number
|
||||
// Format number directly to buffer
|
||||
var buf: [32]u8 = undefined;
|
||||
const num_str = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return error.FormatError;
|
||||
try result.appendSlice(allocator, num_str);
|
||||
const num_str = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return;
|
||||
try output.appendSlice(allocator, num_str);
|
||||
|
||||
try result.append(allocator, '"');
|
||||
return try result.toOwnedSlice(allocator);
|
||||
try output.append(allocator, '"');
|
||||
},
|
||||
.string => |s| {
|
||||
// Empty class or style returns empty
|
||||
// Skip empty class or style
|
||||
if (s.len == 0 and (mem.eql(u8, key, "class") or mem.eql(u8, key, "style"))) {
|
||||
return try allocator.dupe(u8, "");
|
||||
return;
|
||||
}
|
||||
|
||||
var result: ArrayListUnmanaged(u8) = .{};
|
||||
errdefer result.deinit(allocator);
|
||||
|
||||
try result.append(allocator, ' ');
|
||||
try result.appendSlice(allocator, key);
|
||||
try result.appendSlice(allocator, "=\"");
|
||||
try output.append(allocator, ' ');
|
||||
try output.appendSlice(allocator, key);
|
||||
try output.appendSlice(allocator, "=\"");
|
||||
|
||||
if (escaped) {
|
||||
const escaped_val = try escape(allocator, s);
|
||||
defer allocator.free(escaped_val);
|
||||
try result.appendSlice(allocator, escaped_val);
|
||||
try appendEscaped(allocator, output, s);
|
||||
} else {
|
||||
try result.appendSlice(allocator, s);
|
||||
try output.appendSlice(allocator, s);
|
||||
}
|
||||
|
||||
try result.append(allocator, '"');
|
||||
return try result.toOwnedSlice(allocator);
|
||||
try output.append(allocator, '"');
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
502
src/template.zig
502
src/template.zig
@@ -10,6 +10,8 @@ const pug = @import("pug.zig");
|
||||
const parser = @import("parser.zig");
|
||||
const Node = parser.Node;
|
||||
const runtime = @import("runtime.zig");
|
||||
const mixin_mod = @import("mixin.zig");
|
||||
pub const MixinRegistry = mixin_mod.MixinRegistry;
|
||||
|
||||
pub const TemplateError = error{
|
||||
OutOfMemory,
|
||||
@@ -17,10 +19,40 @@ pub const TemplateError = error{
|
||||
ParserError,
|
||||
};
|
||||
|
||||
/// Render context tracks state like doctype mode
|
||||
/// Result of parsing - contains AST and the normalized source that AST slices point to
|
||||
pub const ParseResult = struct {
|
||||
ast: *Node,
|
||||
/// Normalized source - AST strings are slices into this, must stay alive while AST is used
|
||||
normalized_source: []const u8,
|
||||
|
||||
pub fn deinit(self: *ParseResult, allocator: Allocator) void {
|
||||
self.ast.deinit(allocator);
|
||||
allocator.destroy(self.ast);
|
||||
allocator.free(self.normalized_source);
|
||||
}
|
||||
};
|
||||
|
||||
/// Render context tracks state like doctype mode and mixin registry
|
||||
pub const RenderContext = struct {
|
||||
/// true = HTML5 terse mode (default), false = XHTML mode
|
||||
terse: bool = true,
|
||||
/// Mixin registry for expanding mixin calls (optional)
|
||||
mixins: ?*const MixinRegistry = null,
|
||||
/// Current mixin argument bindings (for substitution during mixin expansion)
|
||||
arg_bindings: ?*const std.StringHashMapUnmanaged([]const u8) = null,
|
||||
/// Block content passed to current mixin call (for `block` keyword)
|
||||
mixin_block: ?*Node = null,
|
||||
/// Enable pretty-printing with indentation and newlines
|
||||
pretty: bool = false,
|
||||
/// Current indentation level (for pretty printing)
|
||||
indent_level: u32 = 0,
|
||||
|
||||
/// Create a child context with incremented indent level
|
||||
fn indented(self: RenderContext) RenderContext {
|
||||
var child = self;
|
||||
child.indent_level += 1;
|
||||
return child;
|
||||
}
|
||||
};
|
||||
|
||||
/// Render a template with data
|
||||
@@ -36,10 +68,10 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) !
|
||||
defer stripped.deinit(allocator);
|
||||
|
||||
// Parse
|
||||
var parse = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source);
|
||||
defer parse.deinit();
|
||||
var pug_parser = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source);
|
||||
defer pug_parser.deinit();
|
||||
|
||||
const ast = parse.parse() catch {
|
||||
const ast = pug_parser.parse() catch {
|
||||
return error.ParserError;
|
||||
};
|
||||
defer {
|
||||
@@ -47,7 +79,12 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) !
|
||||
allocator.destroy(ast);
|
||||
}
|
||||
|
||||
// Render with data
|
||||
return renderAst(allocator, ast, data);
|
||||
}
|
||||
|
||||
/// Render a pre-parsed AST with data. Use this for better performance when
|
||||
/// rendering the same template multiple times - parse once, render many.
|
||||
pub fn renderAst(allocator: Allocator, ast: *Node, data: anytype) ![]const u8 {
|
||||
var output = std.ArrayListUnmanaged(u8){};
|
||||
errdefer output.deinit(allocator);
|
||||
|
||||
@@ -60,6 +97,78 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) !
|
||||
return output.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Render options for AST rendering
|
||||
pub const RenderOptions = struct {
|
||||
pretty: bool = false,
|
||||
};
|
||||
|
||||
/// Render a pre-parsed AST with data and mixin registry.
|
||||
/// Use this when templates include mixin definitions from other files.
|
||||
pub fn renderAstWithMixins(allocator: Allocator, ast: *Node, data: anytype, registry: *const MixinRegistry) ![]const u8 {
|
||||
return renderAstWithMixinsAndOptions(allocator, ast, data, registry, .{});
|
||||
}
|
||||
|
||||
/// Render a pre-parsed AST with data, mixin registry, and render options.
|
||||
pub fn renderAstWithMixinsAndOptions(allocator: Allocator, ast: *Node, data: anytype, registry: *const MixinRegistry, options: RenderOptions) ![]const u8 {
|
||||
var output = std.ArrayListUnmanaged(u8){};
|
||||
errdefer output.deinit(allocator);
|
||||
|
||||
// Detect doctype to set terse mode
|
||||
var ctx = RenderContext{
|
||||
.mixins = registry,
|
||||
.pretty = options.pretty,
|
||||
};
|
||||
detectDoctype(ast, &ctx);
|
||||
|
||||
try renderNode(allocator, &output, ast, data, &ctx);
|
||||
|
||||
return output.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Parse template source into AST. Caller owns the returned AST and must call
|
||||
/// ast.deinit(allocator) and allocator.destroy(ast) when done.
|
||||
/// WARNING: The returned AST contains slices into a normalized copy of source.
|
||||
/// This function frees that copy on return, so AST string values become invalid.
|
||||
/// Use parseWithSource() instead if you need to access AST string values.
|
||||
pub fn parse(allocator: Allocator, source: []const u8) !*Node {
|
||||
const result = try parseWithSource(allocator, source);
|
||||
// Free the normalized source - AST strings will be invalid after this!
|
||||
// This maintains backwards compatibility but is unsafe for include paths etc.
|
||||
allocator.free(result.normalized_source);
|
||||
return result.ast;
|
||||
}
|
||||
|
||||
/// Parse template source into AST, returning both AST and the normalized source.
|
||||
/// AST string values are slices into normalized_source, so it must stay alive.
|
||||
/// Caller must call result.deinit(allocator) when done.
|
||||
pub fn parseWithSource(allocator: Allocator, source: []const u8) !ParseResult {
|
||||
// Lex
|
||||
var lex = pug.lexer.Lexer.init(allocator, source, .{}) catch return error.OutOfMemory;
|
||||
errdefer lex.deinit();
|
||||
|
||||
const tokens = lex.getTokens() catch return error.LexerError;
|
||||
|
||||
// Strip comments
|
||||
var stripped = pug.strip_comments.stripComments(allocator, tokens, .{}) catch return error.OutOfMemory;
|
||||
defer stripped.deinit(allocator);
|
||||
|
||||
// Parse
|
||||
var pug_parser = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source);
|
||||
defer pug_parser.deinit();
|
||||
|
||||
const ast = pug_parser.parse() catch {
|
||||
return error.ParserError;
|
||||
};
|
||||
|
||||
// Transfer ownership of normalized input from lexer to caller
|
||||
const normalized = lex.deinitKeepInput();
|
||||
|
||||
return ParseResult{
|
||||
.ast = ast,
|
||||
.normalized_source = normalized,
|
||||
};
|
||||
}
|
||||
|
||||
/// Scan AST for doctype and set terse mode accordingly
|
||||
fn detectDoctype(node: *Node, ctx: *RenderContext) void {
|
||||
if (node.type == .Doctype) {
|
||||
@@ -86,6 +195,23 @@ fn detectDoctype(node: *Node, ctx: *RenderContext) void {
|
||||
}
|
||||
}
|
||||
|
||||
// Tags where whitespace is significant - don't add indentation inside these
|
||||
const whitespace_sensitive_tags = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "pre", {} },
|
||||
.{ "textarea", {} },
|
||||
.{ "script", {} },
|
||||
.{ "style", {} },
|
||||
.{ "code", {} },
|
||||
});
|
||||
|
||||
/// Write indentation (two spaces per level)
|
||||
fn writeIndent(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), level: u32) Allocator.Error!void {
|
||||
var i: u32 = 0;
|
||||
while (i < level) : (i += 1) {
|
||||
try output.appendSlice(allocator, " ");
|
||||
}
|
||||
}
|
||||
|
||||
fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
|
||||
switch (node.type) {
|
||||
.Block, .NamedBlock => {
|
||||
@@ -100,11 +226,13 @@ fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *
|
||||
.BlockComment => try renderBlockComment(allocator, output, node, data, ctx),
|
||||
.Doctype => try renderDoctype(allocator, output, node),
|
||||
.Each => try renderEach(allocator, output, node, data, ctx),
|
||||
.Mixin => {
|
||||
// Mixin definitions are skipped (only mixin calls render)
|
||||
if (!node.call) return;
|
||||
for (node.nodes.items) |child| {
|
||||
try renderNode(allocator, output, child, data, ctx);
|
||||
.Mixin => try renderMixin(allocator, output, node, data, ctx),
|
||||
.MixinBlock => {
|
||||
// Render the block content passed to the mixin
|
||||
if (ctx.mixin_block) |block| {
|
||||
for (block.nodes.items) |child| {
|
||||
try renderNode(allocator, output, child, data, ctx);
|
||||
}
|
||||
}
|
||||
},
|
||||
else => {
|
||||
@@ -117,19 +245,56 @@ fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *
|
||||
|
||||
fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
|
||||
const name = tag.name orelse "div";
|
||||
const is_whitespace_sensitive = whitespace_sensitive_tags.has(name);
|
||||
|
||||
// Check if children are only text/inline content (no block elements)
|
||||
const has_children = tag.nodes.items.len > 0;
|
||||
const has_block_children = has_children and hasBlockChildren(tag);
|
||||
|
||||
// Pretty print: add newline and indent before opening tag (except for inline elements)
|
||||
if (ctx.pretty and !tag.is_inline) {
|
||||
// Only add newline if we're not at the start of output
|
||||
if (output.items.len > 0) {
|
||||
try output.append(allocator, '\n');
|
||||
}
|
||||
try writeIndent(allocator, output, ctx.indent_level);
|
||||
}
|
||||
|
||||
try output.appendSlice(allocator, "<");
|
||||
try output.appendSlice(allocator, name);
|
||||
|
||||
// Render attributes using runtime.attr()
|
||||
// Collect class values separately to merge them into one attribute
|
||||
var class_parts = std.ArrayListUnmanaged([]const u8){};
|
||||
defer class_parts.deinit(allocator);
|
||||
|
||||
// Render attributes directly to output buffer (avoids intermediate allocations)
|
||||
for (tag.attrs.items) |attr| {
|
||||
const attr_val = try evaluateAttrValue(allocator, attr.val, data);
|
||||
const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) {
|
||||
error.FormatError => return error.OutOfMemory,
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
};
|
||||
defer allocator.free(attr_str);
|
||||
try output.appendSlice(allocator, attr_str);
|
||||
// Substitute mixin arguments in attribute value if we're inside a mixin
|
||||
const final_val = if (ctx.arg_bindings) |bindings|
|
||||
substituteArgValue(attr.val, bindings)
|
||||
else
|
||||
attr.val;
|
||||
const attr_val = try evaluateAttrValue(allocator, final_val, data);
|
||||
|
||||
// Collect class attributes for merging
|
||||
if (std.mem.eql(u8, attr.name, "class")) {
|
||||
switch (attr_val) {
|
||||
.string => |s| if (s.len > 0) try class_parts.append(allocator, s),
|
||||
else => {},
|
||||
}
|
||||
} else {
|
||||
try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse);
|
||||
}
|
||||
}
|
||||
|
||||
// Output merged class attribute
|
||||
if (class_parts.items.len > 0) {
|
||||
try output.appendSlice(allocator, " class=\"");
|
||||
for (class_parts.items, 0..) |part, i| {
|
||||
if (i > 0) try output.append(allocator, ' ');
|
||||
try output.appendSlice(allocator, part);
|
||||
}
|
||||
try output.append(allocator, '"');
|
||||
}
|
||||
|
||||
// Self-closing logic differs by mode:
|
||||
@@ -152,24 +317,68 @@ fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *No
|
||||
|
||||
try output.appendSlice(allocator, ">");
|
||||
|
||||
// Render text content
|
||||
// Render text content (with mixin argument substitution if applicable)
|
||||
if (tag.val) |val| {
|
||||
try processInterpolation(allocator, output, val, false, data);
|
||||
const final_val = if (ctx.arg_bindings) |bindings|
|
||||
substituteArgValue(val, bindings) orelse val
|
||||
else
|
||||
val;
|
||||
try processInterpolation(allocator, output, final_val, false, data);
|
||||
}
|
||||
|
||||
// Render children
|
||||
for (tag.nodes.items) |child| {
|
||||
try renderNode(allocator, output, child, data, ctx);
|
||||
// Render children with increased indent (unless whitespace-sensitive)
|
||||
if (has_children) {
|
||||
const child_ctx = if (ctx.pretty and !is_whitespace_sensitive)
|
||||
ctx.indented()
|
||||
else
|
||||
ctx.*;
|
||||
for (tag.nodes.items) |child| {
|
||||
try renderNode(allocator, output, child, data, &child_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// Close tag
|
||||
if (!is_self_closing) {
|
||||
// Pretty print: add newline and indent before closing tag
|
||||
// Only if we have block children (not just text/inline content)
|
||||
if (ctx.pretty and has_block_children and !tag.is_inline and !is_whitespace_sensitive) {
|
||||
try output.append(allocator, '\n');
|
||||
try writeIndent(allocator, output, ctx.indent_level);
|
||||
}
|
||||
try output.appendSlice(allocator, "</");
|
||||
try output.appendSlice(allocator, name);
|
||||
try output.appendSlice(allocator, ">");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a tag has block-level children (not just text/inline content)
|
||||
fn hasBlockChildren(tag: *Node) bool {
|
||||
for (tag.nodes.items) |child| {
|
||||
switch (child.type) {
|
||||
// Text and Code are inline
|
||||
.Text, .Code => continue,
|
||||
// Tags marked as inline are inline
|
||||
.Tag, .InterpolatedTag => {
|
||||
if (!child.is_inline) return true;
|
||||
},
|
||||
// Everything else is considered block
|
||||
else => return true,
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Substitute a single argument reference in a value (simple case - exact match)
|
||||
fn substituteArgValue(val: ?[]const u8, bindings: *const std.StringHashMapUnmanaged([]const u8)) ?[]const u8 {
|
||||
const v = val orelse return null;
|
||||
// Check if the entire value is a parameter name
|
||||
if (bindings.get(v)) |replacement| {
|
||||
return replacement;
|
||||
}
|
||||
// For now, return as-is (complex substitution would need allocation)
|
||||
return v;
|
||||
}
|
||||
|
||||
/// Evaluate attribute value from AST to runtime.AttrValue
|
||||
fn evaluateAttrValue(allocator: Allocator, val: ?[]const u8, data: anytype) !runtime.AttrValue {
|
||||
_ = allocator;
|
||||
@@ -211,6 +420,21 @@ fn renderCode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), code: *
|
||||
} else {
|
||||
try output.appendSlice(allocator, inner);
|
||||
}
|
||||
} else if (ctx.arg_bindings) |bindings| {
|
||||
// Inside a mixin - check argument bindings first
|
||||
if (bindings.get(val)) |value| {
|
||||
if (code.must_escape) {
|
||||
try runtime.appendEscaped(allocator, output, value);
|
||||
} else {
|
||||
try output.appendSlice(allocator, value);
|
||||
}
|
||||
} else if (getFieldValue(data, val)) |value| {
|
||||
if (code.must_escape) {
|
||||
try runtime.appendEscaped(allocator, output, value);
|
||||
} else {
|
||||
try output.appendSlice(allocator, value);
|
||||
}
|
||||
}
|
||||
} else if (getFieldValue(data, val)) |value| {
|
||||
if (code.must_escape) {
|
||||
try runtime.appendEscaped(allocator, output, value);
|
||||
@@ -226,6 +450,138 @@ fn renderCode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), code: *
|
||||
}
|
||||
}
|
||||
|
||||
/// Render mixin definition or call
|
||||
fn renderMixin(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
|
||||
// Mixin definitions are skipped (only mixin calls render)
|
||||
if (!node.call) return;
|
||||
|
||||
const mixin_name = node.name orelse return;
|
||||
|
||||
// Look up mixin definition in registry
|
||||
const mixin_def = if (ctx.mixins) |registry| registry.get(mixin_name) else null;
|
||||
|
||||
if (mixin_def) |def| {
|
||||
// Build argument bindings
|
||||
var bindings = std.StringHashMapUnmanaged([]const u8){};
|
||||
defer bindings.deinit(allocator);
|
||||
|
||||
if (def.args) |params| {
|
||||
if (node.args) |args| {
|
||||
bindMixinArguments(allocator, params, args, &bindings) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
// Create block node from call's children (if any) for `block` keyword
|
||||
var call_block: ?*Node = null;
|
||||
if (node.nodes.items.len > 0) {
|
||||
call_block = node;
|
||||
}
|
||||
|
||||
// Render the mixin body with argument bindings
|
||||
var mixin_ctx = RenderContext{
|
||||
.terse = ctx.terse,
|
||||
.mixins = ctx.mixins,
|
||||
.arg_bindings = &bindings,
|
||||
.mixin_block = call_block,
|
||||
};
|
||||
|
||||
for (def.nodes.items) |child| {
|
||||
try renderNode(allocator, output, child, data, &mixin_ctx);
|
||||
}
|
||||
} else {
|
||||
// Mixin not found - render children directly (fallback behavior)
|
||||
for (node.nodes.items) |child| {
|
||||
try renderNode(allocator, output, child, data, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind mixin call arguments to parameter names
|
||||
fn bindMixinArguments(
|
||||
allocator: Allocator,
|
||||
params: []const u8,
|
||||
args: []const u8,
|
||||
bindings: *std.StringHashMapUnmanaged([]const u8),
|
||||
) !void {
|
||||
// Parse parameter names from definition: "text, type" or "text, type='primary'"
|
||||
var param_names = std.ArrayListUnmanaged([]const u8){};
|
||||
defer param_names.deinit(allocator);
|
||||
|
||||
var param_iter = std.mem.splitSequence(u8, params, ",");
|
||||
while (param_iter.next()) |param_part| {
|
||||
const trimmed = std.mem.trim(u8, param_part, " \t");
|
||||
if (trimmed.len == 0) continue;
|
||||
|
||||
// Handle default values: "type='primary'" -> just get "type"
|
||||
var param_name = trimmed;
|
||||
if (std.mem.indexOf(u8, trimmed, "=")) |eq_pos| {
|
||||
param_name = std.mem.trim(u8, trimmed[0..eq_pos], " \t");
|
||||
}
|
||||
|
||||
// Handle rest args: "...items" -> "items"
|
||||
if (std.mem.startsWith(u8, param_name, "...")) {
|
||||
param_name = param_name[3..];
|
||||
}
|
||||
|
||||
try param_names.append(allocator, param_name);
|
||||
}
|
||||
|
||||
// Parse argument values from call: "'Click', 'primary'" or "text='Click'"
|
||||
var arg_values = std.ArrayListUnmanaged([]const u8){};
|
||||
defer arg_values.deinit(allocator);
|
||||
|
||||
// Simple argument parsing - split by comma but respect quotes
|
||||
var in_string = false;
|
||||
var string_char: u8 = 0;
|
||||
var paren_depth: usize = 0;
|
||||
var start: usize = 0;
|
||||
|
||||
for (args, 0..) |c, idx| {
|
||||
if (!in_string) {
|
||||
if (c == '"' or c == '\'') {
|
||||
in_string = true;
|
||||
string_char = c;
|
||||
} else if (c == '(') {
|
||||
paren_depth += 1;
|
||||
} else if (c == ')') {
|
||||
if (paren_depth > 0) paren_depth -= 1;
|
||||
} else if (c == ',' and paren_depth == 0) {
|
||||
const arg_val = std.mem.trim(u8, args[start..idx], " \t");
|
||||
try arg_values.append(allocator, stripQuotes(arg_val));
|
||||
start = idx + 1;
|
||||
}
|
||||
} else {
|
||||
if (c == string_char) {
|
||||
in_string = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add last argument
|
||||
if (start < args.len) {
|
||||
const arg_val = std.mem.trim(u8, args[start..], " \t");
|
||||
if (arg_val.len > 0) {
|
||||
try arg_values.append(allocator, stripQuotes(arg_val));
|
||||
}
|
||||
}
|
||||
|
||||
// Bind positional arguments
|
||||
const min_len = @min(param_names.items.len, arg_values.items.len);
|
||||
for (0..min_len) |i| {
|
||||
try bindings.put(allocator, param_names.items[i], arg_values.items[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn stripQuotes(val: []const u8) []const u8 {
|
||||
if (val.len < 2) return val;
|
||||
const first = val[0];
|
||||
const last = val[val.len - 1];
|
||||
if ((first == '"' and last == '"') or (first == '\'' and last == '\'')) {
|
||||
return val[1 .. val.len - 1];
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
fn renderEach(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), each: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
|
||||
const collection_name = each.obj orelse return;
|
||||
const item_name = each.val orelse "item";
|
||||
@@ -242,14 +598,26 @@ fn renderEach(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), each: *
|
||||
const CollType = @TypeOf(collection);
|
||||
const coll_info = @typeInfo(CollType);
|
||||
|
||||
if (coll_info == .pointer and coll_info.pointer.size == .slice) {
|
||||
// Handle both slices ([]T) and pointers to arrays (*[N]T)
|
||||
const is_slice = coll_info == .pointer and coll_info.pointer.size == .slice;
|
||||
const is_array_ptr = coll_info == .pointer and coll_info.pointer.size == .one and
|
||||
@typeInfo(coll_info.pointer.child) == .array;
|
||||
|
||||
if (is_slice or is_array_ptr) {
|
||||
for (collection) |item| {
|
||||
const ItemType = @TypeOf(item);
|
||||
if (ItemType == []const u8) {
|
||||
// Simple string item - use renderNodeWithItem
|
||||
for (each.nodes.items) |child| {
|
||||
try renderNodeWithItem(allocator, output, child, data, item, ctx);
|
||||
}
|
||||
} else if (@typeInfo(ItemType) == .@"struct") {
|
||||
// Struct item - render with item as the data context
|
||||
for (each.nodes.items) |child| {
|
||||
try renderNode(allocator, output, child, item, ctx);
|
||||
}
|
||||
} else {
|
||||
// Other types - skip
|
||||
for (each.nodes.items) |child| {
|
||||
try renderNode(allocator, output, child, data, ctx);
|
||||
}
|
||||
@@ -297,15 +665,33 @@ fn renderTagWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8),
|
||||
try output.appendSlice(allocator, "<");
|
||||
try output.appendSlice(allocator, name);
|
||||
|
||||
// Render attributes using runtime.attr()
|
||||
// Collect class values separately to merge them into one attribute
|
||||
var class_parts = std.ArrayListUnmanaged([]const u8){};
|
||||
defer class_parts.deinit(allocator);
|
||||
|
||||
// Render attributes directly to output buffer (avoids intermediate allocations)
|
||||
for (tag.attrs.items) |attr| {
|
||||
const attr_val = try evaluateAttrValue(allocator, attr.val, data);
|
||||
const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) {
|
||||
error.FormatError => return error.OutOfMemory,
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
};
|
||||
defer allocator.free(attr_str);
|
||||
try output.appendSlice(allocator, attr_str);
|
||||
|
||||
// Collect class attributes for merging
|
||||
if (std.mem.eql(u8, attr.name, "class")) {
|
||||
switch (attr_val) {
|
||||
.string => |s| if (s.len > 0) try class_parts.append(allocator, s),
|
||||
else => {},
|
||||
}
|
||||
} else {
|
||||
try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse);
|
||||
}
|
||||
}
|
||||
|
||||
// Output merged class attribute
|
||||
if (class_parts.items.len > 0) {
|
||||
try output.appendSlice(allocator, " class=\"");
|
||||
for (class_parts.items, 0..) |part, i| {
|
||||
if (i > 0) try output.append(allocator, ' ');
|
||||
try output.appendSlice(allocator, part);
|
||||
}
|
||||
try output.append(allocator, '"');
|
||||
}
|
||||
|
||||
const is_void = isSelfClosing(name);
|
||||
@@ -681,3 +1067,57 @@ test "nested tags with data" {
|
||||
|
||||
try std.testing.expectEqualStrings("<div><h1>Welcome</h1><p>Hello there!</p></div>", html);
|
||||
}
|
||||
|
||||
test "pretty print - nested tags" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var result = try parseWithSource(allocator,
|
||||
\\div
|
||||
\\ h1 Title
|
||||
\\ p Content
|
||||
);
|
||||
defer result.deinit(allocator);
|
||||
|
||||
var registry = MixinRegistry.init(allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
const html = try renderAstWithMixinsAndOptions(allocator, result.ast, .{}, ®istry, .{ .pretty = true });
|
||||
defer allocator.free(html);
|
||||
|
||||
const expected =
|
||||
\\<div>
|
||||
\\ <h1>Title</h1>
|
||||
\\ <p>Content</p>
|
||||
\\</div>
|
||||
;
|
||||
try std.testing.expectEqualStrings(expected, html);
|
||||
}
|
||||
|
||||
test "pretty print - deeply nested" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var result = try parseWithSource(allocator,
|
||||
\\html
|
||||
\\ body
|
||||
\\ div
|
||||
\\ p Hello
|
||||
);
|
||||
defer result.deinit(allocator);
|
||||
|
||||
var registry = MixinRegistry.init(allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
const html = try renderAstWithMixinsAndOptions(allocator, result.ast, .{}, ®istry, .{ .pretty = true });
|
||||
defer allocator.free(html);
|
||||
|
||||
const expected =
|
||||
\\<html>
|
||||
\\ <body>
|
||||
\\ <div>
|
||||
\\ <p>Hello</p>
|
||||
\\ </div>
|
||||
\\ </body>
|
||||
\\</html>
|
||||
;
|
||||
try std.testing.expectEqualStrings(expected, html);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ test "Link with class and href (space separated)" {
|
||||
try expectOutput(
|
||||
"a(class='button' href='//google.com') Google",
|
||||
.{},
|
||||
"<a class=\"button\" href=\"//google.com\">Google</a>",
|
||||
"<a href=\"//google.com\" class=\"button\">Google</a>",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ test "Link with class and href (comma separated)" {
|
||||
try expectOutput(
|
||||
"a(class='button', href='//google.com') Google",
|
||||
.{},
|
||||
"<a class=\"button\" href=\"//google.com\">Google</a>",
|
||||
"<a href=\"//google.com\" class=\"button\">Google</a>",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
24
src/tests/test_includes.zig
Normal file
24
src/tests/test_includes.zig
Normal file
@@ -0,0 +1,24 @@
|
||||
const std = @import("std");
|
||||
const pugz = @import("pugz");
|
||||
|
||||
pub fn main() !void {
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
const allocator = gpa.allocator();
|
||||
|
||||
var engine = pugz.ViewEngine.init(allocator, .{
|
||||
.views_dir = "test_views",
|
||||
}) catch |err| {
|
||||
std.debug.print("Init Error: {}\n", .{err});
|
||||
return err;
|
||||
};
|
||||
defer engine.deinit();
|
||||
|
||||
const html = engine.render(allocator, "home", .{}) catch |err| {
|
||||
std.debug.print("Error: {}\n", .{err});
|
||||
return err;
|
||||
};
|
||||
defer allocator.free(html);
|
||||
|
||||
std.debug.print("=== Rendered HTML ===\n{s}\n=== End ===\n", .{html});
|
||||
}
|
||||
8
src/tests/test_views/home.pug
Normal file
8
src/tests/test_views/home.pug
Normal file
@@ -0,0 +1,8 @@
|
||||
include mixins/_buttons.pug
|
||||
include mixins/_cards.pug
|
||||
|
||||
doctype html
|
||||
html
|
||||
body
|
||||
+primary-button("Click me")
|
||||
+card("Title", "content here")
|
||||
2
src/tests/test_views/mixins/_buttons.pug
Normal file
2
src/tests/test_views/mixins/_buttons.pug
Normal file
@@ -0,0 +1,2 @@
|
||||
mixin btn(text)
|
||||
button.btn= text
|
||||
4
src/tests/test_views/mixins/_cards.pug
Normal file
4
src/tests/test_views/mixins/_cards.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
mixin card(title, content)
|
||||
.card
|
||||
.card-header= title
|
||||
.card-body= content
|
||||
@@ -1,59 +1,451 @@
|
||||
// ViewEngine - Simple template engine for web servers
|
||||
// ViewEngine - Template engine with include/mixin support for web servers
|
||||
//
|
||||
// Provides a high-level API for rendering Pug templates from a views directory.
|
||||
// Works with any web server that provides an allocator (httpz, zap, etc).
|
||||
// Templates are parsed once and cached in memory for fast subsequent renders.
|
||||
// Handles include statements and mixin resolution automatically.
|
||||
//
|
||||
// Usage:
|
||||
// const engine = ViewEngine.init(.{ .views_dir = "views" });
|
||||
// const html = try engine.render(allocator, "pages/home", .{ .title = "Home" });
|
||||
// var engine = try ViewEngine.init(allocator, .{ .views_dir = "views" });
|
||||
// defer engine.deinit();
|
||||
//
|
||||
// const html = try engine.render(request_allocator, "pages/home", .{ .title = "Home" });
|
||||
//
|
||||
// Include/Mixin pattern:
|
||||
// // views/pages/home.pug
|
||||
// include mixins/_buttons.pug
|
||||
// include mixins/_cards.pug
|
||||
//
|
||||
// doctype html
|
||||
// html
|
||||
// body
|
||||
// +primary-button("Click me")
|
||||
// +card("Title", "content")
|
||||
|
||||
const std = @import("std");
|
||||
const pug = @import("pug.zig");
|
||||
const template = @import("template.zig");
|
||||
const parser = @import("parser.zig");
|
||||
const mixin = @import("mixin.zig");
|
||||
const load = @import("load.zig");
|
||||
const cache = @import("cache");
|
||||
const Node = parser.Node;
|
||||
const MixinRegistry = mixin.MixinRegistry;
|
||||
|
||||
pub const ViewEngineError = error{
|
||||
OutOfMemory,
|
||||
TemplateNotFound,
|
||||
ReadError,
|
||||
ParseError,
|
||||
ViewsDirNotFound,
|
||||
IncludeNotFound,
|
||||
PathEscapesRoot,
|
||||
CacheInitError,
|
||||
};
|
||||
|
||||
pub const Options = struct {
|
||||
/// Root directory containing view templates
|
||||
/// Root directory containing view templates (all paths relative to this)
|
||||
views_dir: []const u8 = "views",
|
||||
/// File extension for templates
|
||||
extension: []const u8 = ".pug",
|
||||
/// Enable pretty-printing with indentation
|
||||
pretty: bool = true,
|
||||
/// Enable pretty-printing with indentation and newlines
|
||||
pretty: bool = false,
|
||||
/// Enable AST caching (disable for development hot-reload)
|
||||
cache_enabled: bool = true,
|
||||
/// Maximum number of templates to keep in cache (0 = unlimited). When set, uses LRU eviction.
|
||||
max_cached_templates: u32 = 0,
|
||||
/// Cache TTL in seconds (0 = never expires). For development, set to e.g. 5.
|
||||
/// Only works when max_cached_templates > 0 (LRU cache mode).
|
||||
cache_ttl_seconds: u32 = 0,
|
||||
};
|
||||
|
||||
/// Cached template entry - stores AST and normalized source (AST contains slices into it)
|
||||
const CachedTemplate = struct {
|
||||
ast: *Node,
|
||||
/// Normalized source from lexer - AST strings are slices into this
|
||||
normalized_source: []const u8,
|
||||
/// Key stored for cleanup when using LRU cache
|
||||
key: []const u8,
|
||||
|
||||
fn deinit(self: *CachedTemplate, allocator: std.mem.Allocator) void {
|
||||
self.ast.deinit(allocator);
|
||||
allocator.destroy(self.ast);
|
||||
allocator.free(self.normalized_source);
|
||||
if (self.key.len > 0) {
|
||||
allocator.free(self.key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// LRU cache type for templates
|
||||
const LruCache = cache.Cache(*CachedTemplate);
|
||||
|
||||
pub const ViewEngine = struct {
|
||||
options: Options,
|
||||
/// Allocator for cached ASTs (long-lived, typically GPA)
|
||||
cache_allocator: std.mem.Allocator,
|
||||
/// Simple hashmap cache (unlimited size, when max_cached_templates = 0)
|
||||
simple_cache: ?std.StringHashMap(CachedTemplate),
|
||||
/// LRU cache (limited size, when max_cached_templates > 0)
|
||||
lru_cache: ?LruCache,
|
||||
|
||||
pub fn init(options: Options) ViewEngine {
|
||||
return .{ .options = options };
|
||||
pub fn init(allocator: std.mem.Allocator, options: Options) ViewEngineError!ViewEngine {
|
||||
if (options.max_cached_templates > 0) {
|
||||
// Use LRU cache with size limit
|
||||
const lru = LruCache.init(allocator, .{
|
||||
.max_size = options.max_cached_templates,
|
||||
}) catch return ViewEngineError.CacheInitError;
|
||||
return .{
|
||||
.options = options,
|
||||
.cache_allocator = allocator,
|
||||
.simple_cache = null,
|
||||
.lru_cache = lru,
|
||||
};
|
||||
} else {
|
||||
// Use simple unlimited hashmap
|
||||
return .{
|
||||
.options = options,
|
||||
.cache_allocator = allocator,
|
||||
.simple_cache = std.StringHashMap(CachedTemplate).init(allocator),
|
||||
.lru_cache = null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ViewEngine) void {
|
||||
if (self.simple_cache) |*sc| {
|
||||
var it = sc.iterator();
|
||||
while (it.next()) |entry| {
|
||||
self.cache_allocator.free(entry.key_ptr.*);
|
||||
entry.value_ptr.ast.deinit(self.cache_allocator);
|
||||
self.cache_allocator.destroy(entry.value_ptr.ast);
|
||||
self.cache_allocator.free(entry.value_ptr.normalized_source);
|
||||
}
|
||||
sc.deinit();
|
||||
}
|
||||
if (self.lru_cache) |*lru| {
|
||||
lru.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a template file with the given data context.
|
||||
/// Template path is relative to views_dir, extension added automatically.
|
||||
pub fn render(self: *const ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, data: anytype) ![]const u8 {
|
||||
_ = data; // TODO: pass data to template
|
||||
/// Processes includes and resolves mixin calls.
|
||||
pub fn render(self: *ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, data: anytype) ![]const u8 {
|
||||
// Build mixin registry from all includes
|
||||
var registry = MixinRegistry.init(allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
// Build full path
|
||||
const full_path = try self.resolvePath(allocator, template_path);
|
||||
defer allocator.free(full_path);
|
||||
// Get or parse the main AST and process includes
|
||||
const ast = try self.getOrParseWithIncludes(template_path, ®istry);
|
||||
|
||||
// Compile the template
|
||||
var result = pug.compileFile(allocator, full_path, .{
|
||||
// Render the AST with mixin registry - mixins are expanded inline during rendering
|
||||
return template.renderAstWithMixinsAndOptions(allocator, ast, data, ®istry, .{
|
||||
.pretty = self.options.pretty,
|
||||
.filename = full_path,
|
||||
}) catch |err| {
|
||||
});
|
||||
}
|
||||
|
||||
/// Get cached AST or parse it, processing includes recursively
|
||||
fn getOrParseWithIncludes(self: *ViewEngine, template_path: []const u8, registry: *MixinRegistry) !*Node {
|
||||
// Check cache first (only if caching is enabled for read)
|
||||
if (self.options.cache_enabled) {
|
||||
if (self.lru_cache) |*lru| {
|
||||
if (lru.get(template_path)) |entry| {
|
||||
defer entry.release();
|
||||
const cached = entry.value;
|
||||
mixin.collectMixins(self.cache_allocator, cached.ast, registry) catch {};
|
||||
return cached.ast;
|
||||
}
|
||||
} else if (self.simple_cache) |*sc| {
|
||||
if (sc.get(template_path)) |cached| {
|
||||
mixin.collectMixins(self.cache_allocator, cached.ast, registry) catch {};
|
||||
return cached.ast;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build full path (relative to views_dir)
|
||||
const full_path = try self.resolvePath(self.cache_allocator, template_path);
|
||||
defer self.cache_allocator.free(full_path);
|
||||
|
||||
// Read template file
|
||||
const source = std.fs.cwd().readFileAlloc(self.cache_allocator, full_path, 10 * 1024 * 1024) catch |err| {
|
||||
return switch (err) {
|
||||
error.FileNotFound => ViewEngineError.TemplateNotFound,
|
||||
else => ViewEngineError.ReadError,
|
||||
};
|
||||
};
|
||||
defer self.cache_allocator.free(source);
|
||||
|
||||
// Parse template - returns AST and normalized source that AST strings point to
|
||||
var parse_result = template.parseWithSource(self.cache_allocator, source) catch {
|
||||
return ViewEngineError.ParseError;
|
||||
};
|
||||
errdefer parse_result.deinit(self.cache_allocator);
|
||||
|
||||
// Process extends (template inheritance) - must be done before includes
|
||||
const final_ast = try self.processExtends(parse_result.ast, registry);
|
||||
|
||||
// Process includes in the AST
|
||||
try self.processIncludes(final_ast, registry);
|
||||
|
||||
// Collect mixins from this template
|
||||
mixin.collectMixins(self.cache_allocator, final_ast, registry) catch {};
|
||||
|
||||
// Update parse_result.ast to point to final_ast for caching
|
||||
parse_result.ast = final_ast;
|
||||
|
||||
// Cache the AST
|
||||
if (self.lru_cache) |*lru| {
|
||||
// For LRU cache, we need to allocate the CachedTemplate struct
|
||||
const cached_ptr = self.cache_allocator.create(CachedTemplate) catch {
|
||||
parse_result.deinit(self.cache_allocator);
|
||||
return ViewEngineError.OutOfMemory;
|
||||
};
|
||||
const cache_key = self.cache_allocator.dupe(u8, template_path) catch {
|
||||
self.cache_allocator.destroy(cached_ptr);
|
||||
parse_result.deinit(self.cache_allocator);
|
||||
return ViewEngineError.OutOfMemory;
|
||||
};
|
||||
cached_ptr.* = .{
|
||||
.ast = parse_result.ast,
|
||||
.normalized_source = parse_result.normalized_source,
|
||||
.key = cache_key,
|
||||
};
|
||||
// TTL: 0 means never expires, otherwise use configured seconds
|
||||
const ttl = if (self.options.cache_ttl_seconds == 0)
|
||||
std.math.maxInt(u32)
|
||||
else
|
||||
self.options.cache_ttl_seconds;
|
||||
lru.put(cache_key, cached_ptr, .{ .ttl = ttl }) catch {
|
||||
cached_ptr.deinit(self.cache_allocator);
|
||||
self.cache_allocator.destroy(cached_ptr);
|
||||
return ViewEngineError.OutOfMemory;
|
||||
};
|
||||
return parse_result.ast;
|
||||
} else if (self.simple_cache) |*sc| {
|
||||
const cache_key = self.cache_allocator.dupe(u8, template_path) catch {
|
||||
parse_result.deinit(self.cache_allocator);
|
||||
return ViewEngineError.OutOfMemory;
|
||||
};
|
||||
sc.put(cache_key, .{
|
||||
.ast = parse_result.ast,
|
||||
.normalized_source = parse_result.normalized_source,
|
||||
.key = &.{},
|
||||
}) catch {
|
||||
self.cache_allocator.free(cache_key);
|
||||
parse_result.deinit(self.cache_allocator);
|
||||
return ViewEngineError.OutOfMemory;
|
||||
};
|
||||
return parse_result.ast;
|
||||
}
|
||||
|
||||
return parse_result.ast;
|
||||
}
|
||||
|
||||
/// Process all include statements in the AST
|
||||
fn processIncludes(self: *ViewEngine, node: *Node, registry: *MixinRegistry) ViewEngineError!void {
|
||||
// Process Include nodes - load the file and inline its content
|
||||
if (node.type == .Include or node.type == .RawInclude) {
|
||||
if (node.file) |file| {
|
||||
if (file.path) |include_path| {
|
||||
// Load the included file (path relative to views_dir)
|
||||
const included_ast = self.getOrParseWithIncludes(include_path, registry) catch |err| {
|
||||
// For includes, convert TemplateNotFound to IncludeNotFound
|
||||
if (err == ViewEngineError.TemplateNotFound) {
|
||||
return ViewEngineError.IncludeNotFound;
|
||||
}
|
||||
return err;
|
||||
};
|
||||
|
||||
// For pug includes, inline the content into the node
|
||||
if (node.type == .Include) {
|
||||
// Copy children from included AST to this node
|
||||
for (included_ast.nodes.items) |child| {
|
||||
node.nodes.append(self.cache_allocator, child) catch {
|
||||
return ViewEngineError.OutOfMemory;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
for (node.nodes.items) |child| {
|
||||
try self.processIncludes(child, registry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process extends statement - loads parent template and merges blocks
|
||||
fn processExtends(self: *ViewEngine, ast: *Node, registry: *MixinRegistry) ViewEngineError!*Node {
|
||||
if (ast.nodes.items.len == 0) return ast;
|
||||
|
||||
// Check if first node is Extends
|
||||
const first_node = ast.nodes.items[0];
|
||||
if (first_node.type != .Extends) return ast;
|
||||
|
||||
// Get parent template path
|
||||
const parent_path = if (first_node.file) |file| file.path else null;
|
||||
if (parent_path == null) return ast;
|
||||
|
||||
// Collect named blocks from child template (excluding the extends node)
|
||||
var child_blocks = std.StringHashMap(*Node).init(self.cache_allocator);
|
||||
defer child_blocks.deinit();
|
||||
|
||||
for (ast.nodes.items[1..]) |node| {
|
||||
self.collectNamedBlocks(node, &child_blocks);
|
||||
}
|
||||
|
||||
// Load parent template WITHOUT caching (each child gets its own copy)
|
||||
const parent_ast = self.parseTemplateNoCache(parent_path.?, registry) catch |err| {
|
||||
if (err == ViewEngineError.TemplateNotFound) {
|
||||
return ViewEngineError.IncludeNotFound;
|
||||
}
|
||||
return err;
|
||||
};
|
||||
|
||||
if (result.err) |*e| {
|
||||
e.deinit();
|
||||
return error.ParseError;
|
||||
}
|
||||
// Replace blocks in parent with child blocks
|
||||
self.replaceBlocks(parent_ast, &child_blocks);
|
||||
|
||||
return result.html;
|
||||
return parent_ast;
|
||||
}
|
||||
|
||||
/// Resolves a template path relative to views directory
|
||||
/// Parse a template without caching - used for parent layouts in extends
|
||||
fn parseTemplateNoCache(self: *ViewEngine, template_path: []const u8, registry: *MixinRegistry) ViewEngineError!*Node {
|
||||
const full_path = try self.resolvePath(self.cache_allocator, template_path);
|
||||
defer self.cache_allocator.free(full_path);
|
||||
|
||||
const source = std.fs.cwd().readFileAlloc(self.cache_allocator, full_path, 10 * 1024 * 1024) catch |err| {
|
||||
return switch (err) {
|
||||
error.FileNotFound => ViewEngineError.TemplateNotFound,
|
||||
else => ViewEngineError.ReadError,
|
||||
};
|
||||
};
|
||||
defer self.cache_allocator.free(source);
|
||||
|
||||
const parse_result = template.parseWithSource(self.cache_allocator, source) catch {
|
||||
return ViewEngineError.ParseError;
|
||||
};
|
||||
|
||||
// Process nested extends if parent also extends another layout
|
||||
const final_ast = try self.processExtends(parse_result.ast, registry);
|
||||
|
||||
// Process includes
|
||||
try self.processIncludes(final_ast, registry);
|
||||
|
||||
// Collect mixins
|
||||
mixin.collectMixins(self.cache_allocator, final_ast, registry) catch {};
|
||||
|
||||
return final_ast;
|
||||
}
|
||||
|
||||
/// Collect all named blocks from a node tree
|
||||
fn collectNamedBlocks(self: *ViewEngine, node: *Node, blocks: *std.StringHashMap(*Node)) void {
|
||||
if (node.type == .NamedBlock) {
|
||||
if (node.name) |name| {
|
||||
blocks.put(name, node) catch {};
|
||||
}
|
||||
}
|
||||
for (node.nodes.items) |child| {
|
||||
self.collectNamedBlocks(child, blocks);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace named blocks in parent with child block content
|
||||
fn replaceBlocks(self: *ViewEngine, node: *Node, child_blocks: *std.StringHashMap(*Node)) void {
|
||||
if (node.type == .NamedBlock) {
|
||||
if (node.name) |name| {
|
||||
if (child_blocks.get(name)) |child_block| {
|
||||
// Get the block mode from child
|
||||
const mode = child_block.mode orelse "replace";
|
||||
|
||||
if (std.mem.eql(u8, mode, "append")) {
|
||||
// Append child content to parent block
|
||||
for (child_block.nodes.items) |child_node| {
|
||||
node.nodes.append(self.cache_allocator, child_node) catch {};
|
||||
}
|
||||
} else if (std.mem.eql(u8, mode, "prepend")) {
|
||||
// Prepend child content to parent block
|
||||
var i: usize = 0;
|
||||
for (child_block.nodes.items) |child_node| {
|
||||
node.nodes.insert(self.cache_allocator, i, child_node) catch {};
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
// Replace (default): clear parent and use child content
|
||||
node.nodes.clearRetainingCapacity();
|
||||
for (child_block.nodes.items) |child_node| {
|
||||
node.nodes.append(self.cache_allocator, child_node) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
for (node.nodes.items) |child| {
|
||||
self.replaceBlocks(child, child_blocks);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-load and cache all templates from views directory
|
||||
pub fn preload(self: *ViewEngine) !usize {
|
||||
var count: usize = 0;
|
||||
var dir = std.fs.cwd().openDir(self.options.views_dir, .{ .iterate = true }) catch {
|
||||
return ViewEngineError.ViewsDirNotFound;
|
||||
};
|
||||
defer dir.close();
|
||||
|
||||
var walker = dir.walk(self.cache_allocator) catch return ViewEngineError.OutOfMemory;
|
||||
defer walker.deinit();
|
||||
|
||||
while (walker.next() catch null) |entry| {
|
||||
if (entry.kind != .file) continue;
|
||||
if (!std.mem.endsWith(u8, entry.basename, self.options.extension)) continue;
|
||||
|
||||
const name_len = entry.path.len - self.options.extension.len;
|
||||
const template_name = entry.path[0..name_len];
|
||||
|
||||
var registry = MixinRegistry.init(self.cache_allocator);
|
||||
defer registry.deinit();
|
||||
_ = self.getOrParseWithIncludes(template_name, ®istry) catch continue;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Clear all cached templates
|
||||
pub fn clearCache(self: *ViewEngine) void {
|
||||
if (self.simple_cache) |*sc| {
|
||||
var it = sc.iterator();
|
||||
while (it.next()) |entry| {
|
||||
self.cache_allocator.free(entry.key_ptr.*);
|
||||
entry.value_ptr.ast.deinit(self.cache_allocator);
|
||||
self.cache_allocator.destroy(entry.value_ptr.ast);
|
||||
self.cache_allocator.free(entry.value_ptr.normalized_source);
|
||||
}
|
||||
sc.clearRetainingCapacity();
|
||||
}
|
||||
// Note: LRU cache doesn't have a clear method, would need to recreate
|
||||
}
|
||||
|
||||
/// Returns the number of cached templates
|
||||
pub fn cacheCount(self: *const ViewEngine) usize {
|
||||
if (self.simple_cache) |sc| {
|
||||
return sc.count();
|
||||
}
|
||||
// LRU cache doesn't expose count easily
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Resolves a template path relative to views directory.
|
||||
/// Rejects paths that escape the views root (e.g., "../etc/passwd").
|
||||
fn resolvePath(self: *const ViewEngine, allocator: std.mem.Allocator, template_path: []const u8) ![]const u8 {
|
||||
// Add extension if not present
|
||||
// Security: reject paths that escape root
|
||||
if (!load.isPathSafe(template_path)) {
|
||||
return ViewEngineError.PathEscapesRoot;
|
||||
}
|
||||
|
||||
const with_ext = if (std.mem.endsWith(u8, template_path, self.options.extension))
|
||||
try allocator.dupe(u8, template_path)
|
||||
else
|
||||
@@ -63,3 +455,54 @@ pub const ViewEngine = struct {
|
||||
return std.fs.path.join(allocator, &.{ self.options.views_dir, with_ext });
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
test "ViewEngine - basic init and deinit" {
|
||||
const allocator = std.testing.allocator;
|
||||
var engine = try ViewEngine.init(allocator, .{});
|
||||
defer engine.deinit();
|
||||
}
|
||||
|
||||
test "ViewEngine - init with LRU cache" {
|
||||
const allocator = std.testing.allocator;
|
||||
var engine = try ViewEngine.init(allocator, .{
|
||||
.max_cached_templates = 100,
|
||||
});
|
||||
defer engine.deinit();
|
||||
}
|
||||
|
||||
test "isPathSafe - safe paths" {
|
||||
try std.testing.expect(load.isPathSafe("home"));
|
||||
try std.testing.expect(load.isPathSafe("pages/home"));
|
||||
try std.testing.expect(load.isPathSafe("mixins/_buttons"));
|
||||
try std.testing.expect(load.isPathSafe("a/b/c/d"));
|
||||
try std.testing.expect(load.isPathSafe("a/b/../b/c")); // Goes up then back down, still safe
|
||||
}
|
||||
|
||||
test "isPathSafe - unsafe paths" {
|
||||
try std.testing.expect(!load.isPathSafe("../etc/passwd"));
|
||||
try std.testing.expect(!load.isPathSafe(".."));
|
||||
try std.testing.expect(!load.isPathSafe("a/../../b"));
|
||||
try std.testing.expect(!load.isPathSafe("a/b/c/../../../.."));
|
||||
try std.testing.expect(!load.isPathSafe("/etc/passwd")); // Absolute paths
|
||||
}
|
||||
|
||||
test "ViewEngine - path escape protection" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var engine = try ViewEngine.init(allocator, .{
|
||||
.views_dir = "src/tests/test_views",
|
||||
});
|
||||
defer engine.deinit();
|
||||
|
||||
// Should reject paths that escape the views root
|
||||
const result = engine.render(allocator, "../etc/passwd", .{});
|
||||
try std.testing.expectError(ViewEngineError.PathEscapesRoot, result);
|
||||
|
||||
// Absolute paths should also be rejected
|
||||
const result2 = engine.render(allocator, "/etc/passwd", .{});
|
||||
try std.testing.expectError(ViewEngineError.PathEscapesRoot, result2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user