- removed cache
- few comptime related changes
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
// ViewEngine - Template engine with include/mixin support for web servers
|
||||
//
|
||||
// Provides a high-level API for rendering Pug templates from a views directory.
|
||||
// Templates are parsed once and cached in memory for fast subsequent renders.
|
||||
// Handles include statements and mixin resolution automatically.
|
||||
//
|
||||
// Usage:
|
||||
// var engine = try ViewEngine.init(allocator, .{ .views_dir = "views" });
|
||||
// defer engine.deinit();
|
||||
// var engine = ViewEngine.init(.{ .views_dir = "views" });
|
||||
//
|
||||
// const html = try engine.render(request_allocator, "pages/home", .{ .title = "Home" });
|
||||
//
|
||||
@@ -26,7 +24,6 @@ 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;
|
||||
|
||||
@@ -40,7 +37,6 @@ pub const ViewEngineError = error{
|
||||
ViewsDirNotFound,
|
||||
IncludeNotFound,
|
||||
PathEscapesRoot,
|
||||
CacheInitError,
|
||||
};
|
||||
|
||||
pub const Options = struct {
|
||||
@@ -50,82 +46,19 @@ pub const Options = struct {
|
||||
extension: []const u8 = ".pug",
|
||||
/// 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(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 init(options: Options) ViewEngine {
|
||||
return .{
|
||||
.options = options,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
_ = self;
|
||||
}
|
||||
|
||||
/// Renders a template file with the given data context.
|
||||
@@ -136,134 +69,77 @@ pub const ViewEngine = struct {
|
||||
var registry = MixinRegistry.init(allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
// Get or parse the main AST and process includes
|
||||
const ast = try self.getOrParseWithIncludes(template_path, ®istry);
|
||||
// Parse the main AST and process includes
|
||||
const ast = try self.parseWithIncludes(allocator, template_path, ®istry);
|
||||
defer {
|
||||
ast.deinit(allocator);
|
||||
allocator.destroy(ast);
|
||||
}
|
||||
|
||||
// Render the AST with mixin registry - mixins are expanded inline during rendering
|
||||
// Render the AST with mixin registry
|
||||
return template.renderAstWithMixinsAndOptions(allocator, ast, data, ®istry, .{
|
||||
.pretty = self.options.pretty,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a template and process includes recursively
|
||||
fn parseWithIncludes(self: *ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, registry: *MixinRegistry) !*Node {
|
||||
// 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);
|
||||
const full_path = try self.resolvePath(allocator, template_path);
|
||||
defer allocator.free(full_path);
|
||||
|
||||
// Read template file
|
||||
const source = std.fs.cwd().readFileAlloc(self.cache_allocator, full_path, 10 * 1024 * 1024) catch |err| {
|
||||
const source = std.fs.cwd().readFileAlloc(allocator, full_path, 10 * 1024 * 1024) catch |err| {
|
||||
return switch (err) {
|
||||
error.FileNotFound => ViewEngineError.TemplateNotFound,
|
||||
else => ViewEngineError.ReadError,
|
||||
};
|
||||
};
|
||||
defer self.cache_allocator.free(source);
|
||||
defer 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 |err| {
|
||||
// Parse template
|
||||
var parse_result = template.parseWithSource(allocator, source) catch |err| {
|
||||
log.err("failed to parse template '{s}': {}", .{ full_path, err });
|
||||
return ViewEngineError.ParseError;
|
||||
};
|
||||
errdefer parse_result.deinit(self.cache_allocator);
|
||||
errdefer parse_result.deinit(allocator);
|
||||
|
||||
// Process extends (template inheritance) - must be done before includes
|
||||
const final_ast = try self.processExtends(parse_result.ast, registry);
|
||||
const final_ast = try self.processExtends(allocator, parse_result.ast, registry);
|
||||
|
||||
// Process includes in the AST
|
||||
try self.processIncludes(final_ast, registry);
|
||||
try self.processIncludes(allocator, final_ast, registry);
|
||||
|
||||
// Collect mixins from this template
|
||||
mixin.collectMixins(self.cache_allocator, final_ast, registry) catch {};
|
||||
mixin.collectMixins(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;
|
||||
return final_ast;
|
||||
}
|
||||
|
||||
/// Process all include statements in the AST
|
||||
fn processIncludes(self: *ViewEngine, node: *Node, registry: *MixinRegistry) ViewEngineError!void {
|
||||
fn processIncludes(self: *ViewEngine, allocator: std.mem.Allocator, 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| {
|
||||
const included_ast = self.parseWithIncludes(allocator, include_path, registry) catch |err| {
|
||||
// For includes, convert TemplateNotFound to IncludeNotFound
|
||||
if (err == ViewEngineError.TemplateNotFound) {
|
||||
return ViewEngineError.IncludeNotFound;
|
||||
}
|
||||
return err;
|
||||
};
|
||||
defer {
|
||||
included_ast.deinit(allocator);
|
||||
allocator.destroy(included_ast);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
node.nodes.append(allocator, child) catch {
|
||||
return ViewEngineError.OutOfMemory;
|
||||
};
|
||||
}
|
||||
@@ -274,12 +150,12 @@ pub const ViewEngine = struct {
|
||||
|
||||
// Recurse into children
|
||||
for (node.nodes.items) |child| {
|
||||
try self.processIncludes(child, registry);
|
||||
try self.processIncludes(allocator, child, registry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Process extends statement - loads parent template and merges blocks
|
||||
fn processExtends(self: *ViewEngine, ast: *Node, registry: *MixinRegistry) ViewEngineError!*Node {
|
||||
fn processExtends(self: *ViewEngine, allocator: std.mem.Allocator, ast: *Node, registry: *MixinRegistry) ViewEngineError!*Node {
|
||||
if (ast.nodes.items.len == 0) return ast;
|
||||
|
||||
// Check if first node is Extends
|
||||
@@ -291,15 +167,15 @@ pub const ViewEngine = struct {
|
||||
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);
|
||||
var child_blocks = std.StringHashMap(*Node).init(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| {
|
||||
// Load parent template
|
||||
const parent_ast = self.parseWithIncludes(allocator, parent_path.?, registry) catch |err| {
|
||||
if (err == ViewEngineError.TemplateNotFound) {
|
||||
return ViewEngineError.IncludeNotFound;
|
||||
}
|
||||
@@ -307,41 +183,11 @@ pub const ViewEngine = struct {
|
||||
};
|
||||
|
||||
// Replace blocks in parent with child blocks
|
||||
self.replaceBlocks(parent_ast, &child_blocks);
|
||||
self.replaceBlocks(allocator, parent_ast, &child_blocks);
|
||||
|
||||
return parent_ast;
|
||||
}
|
||||
|
||||
/// 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 |err| {
|
||||
log.err("failed to parse template '{s}': {}", .{ full_path, err });
|
||||
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) {
|
||||
@@ -355,7 +201,7 @@ pub const ViewEngine = struct {
|
||||
}
|
||||
|
||||
/// Replace named blocks in parent with child block content
|
||||
fn replaceBlocks(self: *ViewEngine, node: *Node, child_blocks: *std.StringHashMap(*Node)) void {
|
||||
fn replaceBlocks(self: *ViewEngine, allocator: std.mem.Allocator, node: *Node, child_blocks: *std.StringHashMap(*Node)) void {
|
||||
if (node.type == .NamedBlock) {
|
||||
if (node.name) |name| {
|
||||
if (child_blocks.get(name)) |child_block| {
|
||||
@@ -365,20 +211,20 @@ pub const ViewEngine = struct {
|
||||
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 {};
|
||||
node.nodes.append(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 {};
|
||||
node.nodes.insert(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 {};
|
||||
node.nodes.append(allocator, child_node) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,61 +233,10 @@ pub const ViewEngine = struct {
|
||||
|
||||
// Recurse into children
|
||||
for (node.nodes.items) |child| {
|
||||
self.replaceBlocks(child, child_blocks);
|
||||
self.replaceBlocks(allocator, 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 {
|
||||
@@ -465,16 +260,7 @@ pub const ViewEngine = struct {
|
||||
// ============================================================================
|
||||
|
||||
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,
|
||||
});
|
||||
var engine = ViewEngine.init(.{});
|
||||
defer engine.deinit();
|
||||
}
|
||||
|
||||
@@ -497,7 +283,7 @@ test "isPathSafe - unsafe paths" {
|
||||
test "ViewEngine - path escape protection" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var engine = try ViewEngine.init(allocator, .{
|
||||
var engine = ViewEngine.init(.{
|
||||
.views_dir = "tests/test_views",
|
||||
});
|
||||
defer engine.deinit();
|
||||
|
||||
Reference in New Issue
Block a user