Add ViewEngine for easy server integration
- ViewEngine manages views directory with path resolution - Auto-loads mixins from views/mixins/ directory - Simplifies template paths (relative to views dir, auto-adds extension) - Updated example app to use ViewEngine - Added example mixins (buttons.pug, cards.pug) - Updated CLAUDE.md with ViewEngine documentation
This commit is contained in:
66
CLAUDE.md
66
CLAUDE.md
@@ -9,8 +9,8 @@ Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It implements Pug
|
|||||||
## Build Commands
|
## Build Commands
|
||||||
|
|
||||||
- `zig build` - Build the project (output in `zig-out/`)
|
- `zig build` - Build the project (output in `zig-out/`)
|
||||||
- `zig build run` - Build and run the executable
|
- `zig build test` - Run all tests
|
||||||
- `zig build test` - Run all tests (113 tests currently)
|
- `zig build app-01` - Run the example web app (http://localhost:8080)
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ Source → Lexer → Tokens → Parser → AST → Runtime → HTML
|
|||||||
| **src/ast.zig** | AST node definitions (Element, Text, Conditional, Each, Mixin, etc.) |
|
| **src/ast.zig** | AST node definitions (Element, Text, Conditional, Each, Mixin, etc.) |
|
||||||
| **src/runtime.zig** | Evaluates AST with data context, produces final HTML. Handles variable interpolation, conditionals, loops, mixins. |
|
| **src/runtime.zig** | Evaluates AST with data context, produces final HTML. Handles variable interpolation, conditionals, loops, mixins. |
|
||||||
| **src/codegen.zig** | Static HTML generation (without runtime evaluation). Outputs placeholders for dynamic content. |
|
| **src/codegen.zig** | Static HTML generation (without runtime evaluation). Outputs placeholders for dynamic content. |
|
||||||
| **src/root.zig** | Public library API - exports `renderTemplate()` and core types. |
|
| **src/view_engine.zig** | High-level ViewEngine for web servers. Manages views directory, auto-loads mixins. |
|
||||||
| **src/main.zig** | CLI executable example. |
|
| **src/root.zig** | Public library API - exports `ViewEngine`, `renderTemplate()` and core types. |
|
||||||
|
|
||||||
### Test Files
|
### Test Files
|
||||||
|
|
||||||
@@ -239,7 +239,63 @@ block prepend styles
|
|||||||
//- This is a silent comment (not in output)
|
//- This is a silent comment (not in output)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Server Usage Example
|
## Server Usage
|
||||||
|
|
||||||
|
### ViewEngine (Recommended)
|
||||||
|
|
||||||
|
The `ViewEngine` provides the simplest API for web servers:
|
||||||
|
|
||||||
|
```zig
|
||||||
|
const std = @import("std");
|
||||||
|
const pugz = @import("pugz");
|
||||||
|
|
||||||
|
// Initialize once at server startup
|
||||||
|
var engine = try pugz.ViewEngine.init(allocator, .{
|
||||||
|
.views_dir = "src/views", // Root views directory
|
||||||
|
.mixins_dir = "mixins", // Auto-load mixins from views/mixins/ (optional)
|
||||||
|
.extension = ".pug", // File extension (default: .pug)
|
||||||
|
.pretty = true, // Pretty-print output (default: true)
|
||||||
|
});
|
||||||
|
defer engine.deinit();
|
||||||
|
|
||||||
|
// In request handler - use arena allocator per request
|
||||||
|
pub fn handleRequest(engine: *pugz.ViewEngine, allocator: std.mem.Allocator) ![]u8 {
|
||||||
|
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
|
||||||
|
// Template path is relative to views_dir, extension added automatically
|
||||||
|
return try engine.render(arena.allocator(), "pages/home", .{
|
||||||
|
.title = "Home",
|
||||||
|
.user = .{ .name = "Alice" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/views/
|
||||||
|
├── mixins/ # Auto-loaded mixins (optional)
|
||||||
|
│ ├── buttons.pug # mixin btn(text), mixin btn-link(href, text)
|
||||||
|
│ └── cards.pug # mixin card(title), mixin card-simple(title, body)
|
||||||
|
├── layouts/
|
||||||
|
│ └── base.pug # Base layout with blocks
|
||||||
|
├── partials/
|
||||||
|
│ ├── header.pug
|
||||||
|
│ └── footer.pug
|
||||||
|
└── pages/
|
||||||
|
├── home.pug # extends layouts/base
|
||||||
|
└── about.pug # extends layouts/base
|
||||||
|
```
|
||||||
|
|
||||||
|
Templates can use:
|
||||||
|
- `extends layouts/base` - Paths relative to views_dir
|
||||||
|
- `include partials/header` - Paths relative to views_dir
|
||||||
|
- `+btn("Click")` - Mixins from mixins/ dir available automatically
|
||||||
|
|
||||||
|
### Low-Level API
|
||||||
|
|
||||||
|
For inline templates or custom use cases:
|
||||||
|
|
||||||
```zig
|
```zig
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|||||||
@@ -1,36 +1,8 @@
|
|||||||
.{
|
.{
|
||||||
// This is the default name used by packages depending on this one. For
|
|
||||||
// example, when a user runs `zig fetch --save <url>`, this field is used
|
|
||||||
// as the key in the `dependencies` table. Although the user can choose a
|
|
||||||
// different name, most users will stick with this provided value.
|
|
||||||
//
|
|
||||||
// It is redundant to include "zig" in this name because it is already
|
|
||||||
// within the Zig package namespace.
|
|
||||||
.name = .pugz,
|
.name = .pugz,
|
||||||
// This is a [Semantic Version](https://semver.org/).
|
|
||||||
// In a future version of Zig it will be used for package deduplication.
|
|
||||||
.version = "0.0.0",
|
.version = "0.0.0",
|
||||||
// Together with name, this represents a globally unique package
|
|
||||||
// identifier. This field is generated by the Zig toolchain when the
|
|
||||||
// package is first created, and then *never changes*. This allows
|
|
||||||
// unambiguous detection of one package being an updated version of
|
|
||||||
// another.
|
|
||||||
//
|
|
||||||
// When forking a Zig project, this id should be regenerated (delete the
|
|
||||||
// field and run `zig build`) if the upstream project is still maintained.
|
|
||||||
// Otherwise, the fork is *hostile*, attempting to take control over the
|
|
||||||
// original project's identity. Thus it is recommended to leave the comment
|
|
||||||
// on the following line intact, so that it shows up in code reviews that
|
|
||||||
// modify the field.
|
|
||||||
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
|
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
|
||||||
// Tracks the earliest Zig version that the package considers to be a
|
|
||||||
// supported use case.
|
|
||||||
.minimum_zig_version = "0.15.2",
|
.minimum_zig_version = "0.15.2",
|
||||||
// This field is optional.
|
|
||||||
// Each dependency must either provide a `url` and `hash`, or a `path`.
|
|
||||||
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
|
|
||||||
// Once all dependencies are fetched, `zig build` no longer requires
|
|
||||||
// internet connectivity.
|
|
||||||
.dependencies = .{
|
.dependencies = .{
|
||||||
.httpz = .{
|
.httpz = .{
|
||||||
.url = "git+https://github.com/karlseguin/http.zig?ref=master#9ef2ffe8d611ff2e1081e5cf39cb4632c145c5b9",
|
.url = "git+https://github.com/karlseguin/http.zig?ref=master#9ef2ffe8d611ff2e1081e5cf39cb4632c145c5b9",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Pugz Template Inheritance Demo
|
//! Pugz Template Inheritance Demo
|
||||||
//!
|
//!
|
||||||
//! A web application demonstrating Pug-style template inheritance
|
//! A web application demonstrating Pug-style template inheritance
|
||||||
//! using the Pugz template engine with http.zig server.
|
//! using the Pugz ViewEngine with http.zig server.
|
||||||
//!
|
//!
|
||||||
//! Routes:
|
//! Routes:
|
||||||
//! GET / - Home page (layout.pug)
|
//! GET / - Home page (layout.pug)
|
||||||
@@ -19,58 +19,25 @@ const Allocator = std.mem.Allocator;
|
|||||||
/// Application state shared across all requests
|
/// Application state shared across all requests
|
||||||
const App = struct {
|
const App = struct {
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
views_dir: []const u8,
|
engine: pugz.ViewEngine,
|
||||||
|
|
||||||
/// File resolver for loading templates from disk
|
pub fn init(allocator: Allocator) !App {
|
||||||
pub fn fileResolver(allocator: Allocator, path: []const u8) ?[]const u8 {
|
return .{
|
||||||
const file = std.fs.cwd().openFile(path, .{}) catch return null;
|
.allocator = allocator,
|
||||||
defer file.close();
|
.engine = try pugz.ViewEngine.init(allocator, .{
|
||||||
return file.readToEndAlloc(allocator, 1024 * 1024) catch null;
|
.views_dir = "src/examples/app_01/views",
|
||||||
}
|
}),
|
||||||
|
|
||||||
/// Render a template with data
|
|
||||||
pub fn render(self: *App, template_name: []const u8, data: anytype) ![]u8 {
|
|
||||||
// Build full path
|
|
||||||
const template_path = try std.fs.path.join(self.allocator, &.{ self.views_dir, template_name });
|
|
||||||
defer self.allocator.free(template_path);
|
|
||||||
|
|
||||||
// Load template source
|
|
||||||
const source = fileResolver(self.allocator, template_path) orelse {
|
|
||||||
return error.TemplateNotFound;
|
|
||||||
};
|
};
|
||||||
defer self.allocator.free(source);
|
|
||||||
|
|
||||||
// Parse template
|
|
||||||
var lexer = pugz.Lexer.init(self.allocator, source);
|
|
||||||
const tokens = try lexer.tokenize();
|
|
||||||
|
|
||||||
var parser = pugz.Parser.init(self.allocator, tokens);
|
|
||||||
const doc = try parser.parse();
|
|
||||||
|
|
||||||
// Setup context with data
|
|
||||||
var ctx = pugz.runtime.Context.init(self.allocator);
|
|
||||||
defer ctx.deinit();
|
|
||||||
|
|
||||||
try ctx.pushScope();
|
|
||||||
inline for (std.meta.fields(@TypeOf(data))) |field| {
|
|
||||||
const value = @field(data, field.name);
|
|
||||||
try ctx.set(field.name, pugz.runtime.toValue(self.allocator, value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render with file resolver for includes/extends
|
pub fn deinit(self: *App) void {
|
||||||
var runtime = pugz.runtime.Runtime.init(self.allocator, &ctx, .{
|
self.engine.deinit();
|
||||||
.file_resolver = fileResolver,
|
|
||||||
.base_dir = self.views_dir,
|
|
||||||
});
|
|
||||||
defer runtime.deinit();
|
|
||||||
|
|
||||||
return runtime.renderOwned(doc);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Handler for GET /
|
/// Handler for GET /
|
||||||
fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
const html = app.render("layout.pug", .{
|
const html = app.engine.render(app.allocator, "layout", .{
|
||||||
.title = "Home",
|
.title = "Home",
|
||||||
}) catch |err| {
|
}) catch |err| {
|
||||||
res.status = 500;
|
res.status = 500;
|
||||||
@@ -84,7 +51,7 @@ fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
|||||||
|
|
||||||
/// Handler for GET /page-a - demonstrates extends and block override
|
/// Handler for GET /page-a - demonstrates extends and block override
|
||||||
fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
const html = app.render("page-a.pug", .{
|
const html = app.engine.render(app.allocator, "page-a", .{
|
||||||
.title = "Page A - Pets",
|
.title = "Page A - Pets",
|
||||||
.items = &[_][]const u8{ "A", "B", "C" },
|
.items = &[_][]const u8{ "A", "B", "C" },
|
||||||
.n = 0,
|
.n = 0,
|
||||||
@@ -100,7 +67,7 @@ fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
|||||||
|
|
||||||
/// Handler for GET /page-b - demonstrates sub-layout inheritance
|
/// Handler for GET /page-b - demonstrates sub-layout inheritance
|
||||||
fn pageB(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
fn pageB(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
const html = app.render("page-b.pug", .{
|
const html = app.engine.render(app.allocator, "page-b", .{
|
||||||
.title = "Page B - Sub Layout",
|
.title = "Page B - Sub Layout",
|
||||||
}) catch |err| {
|
}) catch |err| {
|
||||||
res.status = 500;
|
res.status = 500;
|
||||||
@@ -114,7 +81,7 @@ fn pageB(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
|||||||
|
|
||||||
/// Handler for GET /append - demonstrates block append
|
/// Handler for GET /append - demonstrates block append
|
||||||
fn pageAppend(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
fn pageAppend(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
const html = app.render("page-append.pug", .{
|
const html = app.engine.render(app.allocator, "page-append", .{
|
||||||
.title = "Page Append",
|
.title = "Page Append",
|
||||||
}) catch |err| {
|
}) catch |err| {
|
||||||
res.status = 500;
|
res.status = 500;
|
||||||
@@ -128,7 +95,7 @@ fn pageAppend(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
|||||||
|
|
||||||
/// Handler for GET /append-opt - demonstrates optional block keyword
|
/// Handler for GET /append-opt - demonstrates optional block keyword
|
||||||
fn pageAppendOptional(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
fn pageAppendOptional(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
|
||||||
const html = app.render("page-appen-optional-blk.pug", .{
|
const html = app.engine.render(app.allocator, "page-appen-optional-blk", .{
|
||||||
.title = "Page Append Optional",
|
.title = "Page Append Optional",
|
||||||
}) catch |err| {
|
}) catch |err| {
|
||||||
res.status = 500;
|
res.status = 500;
|
||||||
@@ -145,13 +112,9 @@ pub fn main() !void {
|
|||||||
defer _ = gpa.deinit();
|
defer _ = gpa.deinit();
|
||||||
const allocator = gpa.allocator();
|
const allocator = gpa.allocator();
|
||||||
|
|
||||||
// Views directory - relative to current working directory
|
// Initialize view engine once at startup
|
||||||
const views_dir = "src/examples/app_01/views";
|
var app = try App.init(allocator);
|
||||||
|
defer app.deinit();
|
||||||
var app = App{
|
|
||||||
.allocator = allocator,
|
|
||||||
.views_dir = views_dir,
|
|
||||||
};
|
|
||||||
|
|
||||||
var server = try httpz.Server(*App).init(allocator, .{ .port = 8080 }, &app);
|
var server = try httpz.Server(*App).init(allocator, .{ .port = 8080 }, &app);
|
||||||
defer server.deinit();
|
defer server.deinit();
|
||||||
|
|||||||
5
src/examples/app_01/views/mixins/buttons.pug
Normal file
5
src/examples/app_01/views/mixins/buttons.pug
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mixin btn(text, type="primary")
|
||||||
|
button(class="btn btn-" + type)= text
|
||||||
|
|
||||||
|
mixin btn-link(href, text)
|
||||||
|
a.btn.btn-link(href=href)= text
|
||||||
11
src/examples/app_01/views/mixins/cards.pug
Normal file
11
src/examples/app_01/views/mixins/cards.pug
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mixin card(title)
|
||||||
|
.card
|
||||||
|
.card-header
|
||||||
|
h3= title
|
||||||
|
.card-body
|
||||||
|
block
|
||||||
|
|
||||||
|
mixin card-simple(title, body)
|
||||||
|
.card
|
||||||
|
h3= title
|
||||||
|
p= body
|
||||||
24
src/root.zig
24
src/root.zig
@@ -7,12 +7,33 @@
|
|||||||
//! - Attributes and text interpolation
|
//! - Attributes and text interpolation
|
||||||
//! - Control flow (if/else, each, while)
|
//! - Control flow (if/else, each, while)
|
||||||
//! - Mixins and template inheritance
|
//! - Mixins and template inheritance
|
||||||
|
//!
|
||||||
|
//! ## Quick Start (Server Usage)
|
||||||
|
//!
|
||||||
|
//! ```zig
|
||||||
|
//! const pugz = @import("pugz");
|
||||||
|
//!
|
||||||
|
//! // Initialize view engine once at startup
|
||||||
|
//! var engine = try pugz.ViewEngine.init(allocator, .{
|
||||||
|
//! .views_dir = "src/views",
|
||||||
|
//! });
|
||||||
|
//! defer engine.deinit();
|
||||||
|
//!
|
||||||
|
//! // Render templates (use arena allocator per request)
|
||||||
|
//! var arena = std.heap.ArenaAllocator.init(allocator);
|
||||||
|
//! defer arena.deinit();
|
||||||
|
//!
|
||||||
|
//! const html = try engine.render(arena.allocator(), "pages/home", .{
|
||||||
|
//! .title = "Home",
|
||||||
|
//! });
|
||||||
|
//! ```
|
||||||
|
|
||||||
pub const lexer = @import("lexer.zig");
|
pub const lexer = @import("lexer.zig");
|
||||||
pub const ast = @import("ast.zig");
|
pub const ast = @import("ast.zig");
|
||||||
pub const parser = @import("parser.zig");
|
pub const parser = @import("parser.zig");
|
||||||
pub const codegen = @import("codegen.zig");
|
pub const codegen = @import("codegen.zig");
|
||||||
pub const runtime = @import("runtime.zig");
|
pub const runtime = @import("runtime.zig");
|
||||||
|
pub const view_engine = @import("view_engine.zig");
|
||||||
|
|
||||||
// Re-export main types for convenience
|
// Re-export main types for convenience
|
||||||
pub const Lexer = lexer.Lexer;
|
pub const Lexer = lexer.Lexer;
|
||||||
@@ -32,6 +53,9 @@ pub const Value = runtime.Value;
|
|||||||
pub const render = runtime.render;
|
pub const render = runtime.render;
|
||||||
pub const renderTemplate = runtime.renderTemplate;
|
pub const renderTemplate = runtime.renderTemplate;
|
||||||
|
|
||||||
|
// High-level API
|
||||||
|
pub const ViewEngine = view_engine.ViewEngine;
|
||||||
|
|
||||||
test {
|
test {
|
||||||
_ = @import("std").testing.refAllDecls(@This());
|
_ = @import("std").testing.refAllDecls(@This());
|
||||||
}
|
}
|
||||||
|
|||||||
243
src/view_engine.zig
Normal file
243
src/view_engine.zig
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
//! ViewEngine - High-level template engine for web servers.
|
||||||
|
//!
|
||||||
|
//! Provides a simple API for rendering Pug templates with:
|
||||||
|
//! - Views directory configuration
|
||||||
|
//! - Auto-loading mixins from a mixins subdirectory
|
||||||
|
//! - Relative path resolution for includes and extends
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
/// Defaults to "mixins". Set to null to disable auto-loading.
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A pre-parsed mixin definition.
|
||||||
|
const MixinEntry = struct {
|
||||||
|
name: []const u8,
|
||||||
|
def: ast.MixinDef,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// ViewEngine manages template rendering with a configured views directory.
|
||||||
|
pub const ViewEngine = struct {
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
options: Options,
|
||||||
|
/// Absolute path to views directory.
|
||||||
|
views_path: []const u8,
|
||||||
|
/// Pre-loaded mixin definitions.
|
||||||
|
mixins: std.ArrayListUnmanaged(MixinEntry),
|
||||||
|
/// Cached mixin source files (to keep slices valid).
|
||||||
|
mixin_sources: std.ArrayListUnmanaged([]const u8),
|
||||||
|
|
||||||
|
/// Initializes the ViewEngine with the given options.
|
||||||
|
/// Loads all mixins from the mixins directory if configured.
|
||||||
|
pub fn init(allocator: std.mem.Allocator, options: Options) !ViewEngine {
|
||||||
|
// Resolve views directory to absolute path
|
||||||
|
const views_path = try std.fs.cwd().realpathAlloc(allocator, options.views_dir);
|
||||||
|
errdefer allocator.free(views_path);
|
||||||
|
|
||||||
|
var engine = ViewEngine{
|
||||||
|
.allocator = allocator,
|
||||||
|
.options = options,
|
||||||
|
.views_path = views_path,
|
||||||
|
.mixins = .empty,
|
||||||
|
.mixin_sources = .empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-load mixins if configured
|
||||||
|
if (options.mixins_dir) |mixins_subdir| {
|
||||||
|
try engine.loadMixins(mixins_subdir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases all resources held by the ViewEngine.
|
||||||
|
pub fn deinit(self: *ViewEngine) void {
|
||||||
|
self.allocator.free(self.views_path);
|
||||||
|
self.mixins.deinit(self.allocator);
|
||||||
|
for (self.mixin_sources.items) |source| {
|
||||||
|
self.allocator.free(source);
|
||||||
|
}
|
||||||
|
self.mixin_sources.deinit(self.allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all mixin files from the specified subdirectory.
|
||||||
|
fn loadMixins(self: *ViewEngine, mixins_subdir: []const u8) !void {
|
||||||
|
const mixins_path = try std.fs.path.join(self.allocator, &.{ self.views_path, mixins_subdir });
|
||||||
|
defer self.allocator.free(mixins_path);
|
||||||
|
|
||||||
|
var dir = std.fs.openDirAbsolute(mixins_path, .{ .iterate = true }) catch |err| {
|
||||||
|
if (err == error.FileNotFound) {
|
||||||
|
// Mixins directory doesn't exist - that's OK
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
defer dir.close();
|
||||||
|
|
||||||
|
var iter = dir.iterate();
|
||||||
|
while (try iter.next()) |entry| {
|
||||||
|
if (entry.kind != .file) continue;
|
||||||
|
|
||||||
|
// Check for .pug extension
|
||||||
|
if (!std.mem.endsWith(u8, entry.name, self.options.extension)) continue;
|
||||||
|
|
||||||
|
// Read and parse the mixin file
|
||||||
|
try self.loadMixinFile(dir, entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a single mixin file and extracts its mixin definitions.
|
||||||
|
fn loadMixinFile(self: *ViewEngine, dir: std.fs.Dir, filename: []const u8) !void {
|
||||||
|
const source = try dir.readFileAlloc(self.allocator, filename, 1024 * 1024);
|
||||||
|
errdefer self.allocator.free(source);
|
||||||
|
|
||||||
|
// Keep source alive for string slices
|
||||||
|
try self.mixin_sources.append(self.allocator, source);
|
||||||
|
|
||||||
|
// Parse the file
|
||||||
|
var lexer = Lexer.init(self.allocator, source);
|
||||||
|
defer lexer.deinit();
|
||||||
|
|
||||||
|
const tokens = lexer.tokenize() catch return;
|
||||||
|
|
||||||
|
var parser = Parser.init(self.allocator, tokens);
|
||||||
|
const doc = parser.parse() catch return;
|
||||||
|
|
||||||
|
// Extract mixin definitions
|
||||||
|
for (doc.nodes) |node| {
|
||||||
|
if (node == .mixin_def) {
|
||||||
|
try self.mixins.append(self.allocator, .{
|
||||||
|
.name = node.mixin_def.name,
|
||||||
|
.def = node.mixin_def,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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();
|
||||||
|
|
||||||
|
// Register pre-loaded mixins
|
||||||
|
for (self.mixins.items) |mixin_entry| {
|
||||||
|
try ctx.defineMixin(mixin_entry.def);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create runtime with file resolver for includes/extends
|
||||||
|
var rt = Runtime.init(allocator, &ctx, .{
|
||||||
|
.pretty = self.options.pretty,
|
||||||
|
.base_dir = self.views_path,
|
||||||
|
.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/
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user