2026-01-17 18:50:16 +05:30
|
|
|
//! ViewEngine - High-level template engine for web servers.
|
|
|
|
|
//!
|
|
|
|
|
//! Provides a simple API for rendering Pug templates with:
|
|
|
|
|
//! - Views directory configuration
|
2026-01-17 20:01:37 +05:30
|
|
|
//! - Lazy-loading mixins from a mixins subdirectory (on-demand)
|
2026-01-17 18:50:16 +05:30
|
|
|
//! - Relative path resolution for includes and extends
|
|
|
|
|
//!
|
2026-01-17 20:01:37 +05:30
|
|
|
//! Mixins are resolved in the following order:
|
|
|
|
|
//! 1. Mixins defined in the same template file
|
|
|
|
|
//! 2. Mixins from the mixins directory (lazy-loaded when first called)
|
|
|
|
|
//!
|
2026-01-17 18:50:16 +05:30
|
|
|
//! Example:
|
|
|
|
|
//! ```zig
|
|
|
|
|
//! var engine = try ViewEngine.init(allocator, .{
|
|
|
|
|
//! .views_dir = "src/views",
|
|
|
|
|
//! });
|
|
|
|
|
//! defer engine.deinit();
|
|
|
|
|
//!
|
|
|
|
|
//! const html = try engine.render(arena.allocator(), "pages/home", .{
|
|
|
|
|
//! .title = "Home",
|
|
|
|
|
//! });
|
|
|
|
|
//! ```
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
/// Subdirectory within views_dir containing mixin files.
|
2026-01-17 20:01:37 +05:30
|
|
|
/// Defaults to "mixins". Mixins are lazy-loaded on first use.
|
|
|
|
|
/// Set to null to disable mixin directory lookup.
|
2026-01-17 18:50:16 +05:30
|
|
|
mixins_dir: ?[]const u8 = "mixins",
|
|
|
|
|
/// File extension for templates. Defaults to ".pug".
|
|
|
|
|
extension: []const u8 = ".pug",
|
|
|
|
|
/// Enable pretty-printing with indentation.
|
|
|
|
|
pretty: bool = true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Error types for ViewEngine operations.
|
|
|
|
|
pub const ViewEngineError = error{
|
|
|
|
|
TemplateNotFound,
|
|
|
|
|
ParseError,
|
|
|
|
|
OutOfMemory,
|
|
|
|
|
AccessDenied,
|
|
|
|
|
InvalidPath,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// ViewEngine manages template rendering with a configured views directory.
|
2026-01-17 20:01:37 +05:30
|
|
|
/// Mixins are lazy-loaded from the mixins directory when first called.
|
2026-01-17 18:50:16 +05:30
|
|
|
pub const ViewEngine = struct {
|
|
|
|
|
allocator: std.mem.Allocator,
|
|
|
|
|
options: Options,
|
|
|
|
|
/// Absolute path to views directory.
|
|
|
|
|
views_path: []const u8,
|
2026-01-17 20:01:37 +05:30
|
|
|
/// Absolute path to mixins directory (resolved at init).
|
|
|
|
|
mixins_path: []const u8,
|
2026-01-17 18:50:16 +05:30
|
|
|
|
|
|
|
|
/// 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);
|
|
|
|
|
|
2026-01-17 20:01:37 +05:30
|
|
|
// Resolve mixins directory path (may not exist yet)
|
|
|
|
|
var mixins_path: []const u8 = "";
|
|
|
|
|
if (options.mixins_dir) |mixins_subdir| {
|
|
|
|
|
mixins_path = try std.fs.path.join(allocator, &.{ views_path, mixins_subdir });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ViewEngine{
|
2026-01-17 18:50:16 +05:30
|
|
|
.allocator = allocator,
|
|
|
|
|
.options = options,
|
|
|
|
|
.views_path = views_path,
|
2026-01-17 20:01:37 +05:30
|
|
|
.mixins_path = mixins_path,
|
2026-01-17 18:50:16 +05:30
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Releases all resources held by the ViewEngine.
|
|
|
|
|
pub fn deinit(self: *ViewEngine) void {
|
|
|
|
|
self.allocator.free(self.views_path);
|
2026-01-17 20:01:37 +05:30
|
|
|
if (self.mixins_path.len > 0) {
|
|
|
|
|
self.allocator.free(self.mixins_path);
|
2026-01-17 18:50:16 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Renders a template with the given data context.
|
|
|
|
|
///
|
|
|
|
|
/// The template path is relative to the views directory.
|
|
|
|
|
/// The .pug extension is added automatically if not present.
|
|
|
|
|
///
|
2026-01-17 20:01:37 +05:30
|
|
|
/// Mixins are resolved in order:
|
|
|
|
|
/// 1. Mixins defined in the template itself
|
|
|
|
|
/// 2. Mixins from the mixins directory (lazy-loaded)
|
|
|
|
|
///
|
2026-01-17 18:50:16 +05:30
|
|
|
/// 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 {
|
|
|
|
|
// Build 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(allocator, full_path, 1024 * 1024) catch {
|
|
|
|
|
return ViewEngineError.TemplateNotFound;
|
|
|
|
|
};
|
|
|
|
|
defer allocator.free(source);
|
|
|
|
|
|
|
|
|
|
// Tokenize
|
|
|
|
|
var lexer = Lexer.init(allocator, source);
|
|
|
|
|
defer lexer.deinit();
|
|
|
|
|
const tokens = lexer.tokenize() catch return ViewEngineError.ParseError;
|
|
|
|
|
|
|
|
|
|
// Parse
|
|
|
|
|
var parser = Parser.init(allocator, tokens);
|
|
|
|
|
const doc = parser.parse() catch return ViewEngineError.ParseError;
|
|
|
|
|
|
|
|
|
|
// Create context with data
|
|
|
|
|
var ctx = Context.init(allocator);
|
|
|
|
|
defer ctx.deinit();
|
|
|
|
|
|
|
|
|
|
// Populate context from data struct
|
|
|
|
|
try ctx.pushScope();
|
|
|
|
|
inline for (std.meta.fields(@TypeOf(data))) |field| {
|
|
|
|
|
const value = @field(data, field.name);
|
|
|
|
|
try ctx.set(field.name, runtime.toValue(allocator, value));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-17 20:01:37 +05:30
|
|
|
// Create runtime with file resolver for includes/extends and lazy mixin loading
|
2026-01-17 18:50:16 +05:30
|
|
|
var rt = Runtime.init(allocator, &ctx, .{
|
|
|
|
|
.pretty = self.options.pretty,
|
|
|
|
|
.base_dir = self.views_path,
|
2026-01-17 20:01:37 +05:30
|
|
|
.mixins_dir = self.mixins_path,
|
2026-01-17 18:50:16 +05:30
|
|
|
.file_resolver = createFileResolver(),
|
|
|
|
|
});
|
|
|
|
|
defer rt.deinit();
|
|
|
|
|
|
|
|
|
|
return rt.renderOwned(doc);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolves a template path relative to views directory.
|
|
|
|
|
fn resolvePath(self: *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)
|
|
|
|
|
else
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Creates a file resolver function for the runtime.
|
|
|
|
|
fn createFileResolver() runtime.FileResolver {
|
|
|
|
|
return struct {
|
|
|
|
|
fn resolve(allocator: std.mem.Allocator, path: []const u8) ?[]const u8 {
|
|
|
|
|
return std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch null;
|
|
|
|
|
}
|
|
|
|
|
}.resolve;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Tests
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
test "ViewEngine resolves paths correctly" {
|
|
|
|
|
// This test requires a views directory - skip in unit tests
|
|
|
|
|
// Full integration tests are in src/tests/
|
|
|
|
|
}
|