diff --git a/CLAUDE.md b/CLAUDE.md index 95bfda0..de9bc42 100644 --- a/CLAUDE.md +++ b/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 - `zig build` - Build the project (output in `zig-out/`) -- `zig build run` - Build and run the executable -- `zig build test` - Run all tests (113 tests currently) +- `zig build test` - Run all tests +- `zig build app-01` - Run the example web app (http://localhost:8080) ## 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/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/root.zig** | Public library API - exports `renderTemplate()` and core types. | -| **src/main.zig** | CLI executable example. | +| **src/view_engine.zig** | High-level ViewEngine for web servers. Manages views directory, auto-loads mixins. | +| **src/root.zig** | Public library API - exports `ViewEngine`, `renderTemplate()` and core types. | ### Test Files @@ -239,7 +239,63 @@ block prepend styles //- 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 const std = @import("std"); diff --git a/build.zig.zon b/build.zig.zon index c544f10..7416bea 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 `, 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, - // 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", - // 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. - // Tracks the earliest Zig version that the package considers to be a - // supported use case. .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 = .{ .httpz = .{ .url = "git+https://github.com/karlseguin/http.zig?ref=master#9ef2ffe8d611ff2e1081e5cf39cb4632c145c5b9", diff --git a/src/examples/app_01/main.zig b/src/examples/app_01/main.zig index de43087..e1cc5f2 100644 --- a/src/examples/app_01/main.zig +++ b/src/examples/app_01/main.zig @@ -1,7 +1,7 @@ //! Pugz Template Inheritance Demo //! //! 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: //! GET / - Home page (layout.pug) @@ -19,58 +19,25 @@ const Allocator = std.mem.Allocator; /// Application state shared across all requests const App = struct { allocator: Allocator, - views_dir: []const u8, + engine: pugz.ViewEngine, - /// File resolver for loading templates from disk - pub fn fileResolver(allocator: Allocator, path: []const u8) ?[]const u8 { - const file = std.fs.cwd().openFile(path, .{}) catch return null; - defer file.close(); - return file.readToEndAlloc(allocator, 1024 * 1024) catch null; + pub fn init(allocator: Allocator) !App { + return .{ + .allocator = allocator, + .engine = try pugz.ViewEngine.init(allocator, .{ + .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 - var runtime = pugz.runtime.Runtime.init(self.allocator, &ctx, .{ - .file_resolver = fileResolver, - .base_dir = self.views_dir, - }); - defer runtime.deinit(); - - return runtime.renderOwned(doc); + pub fn deinit(self: *App) void { + self.engine.deinit(); } }; /// Handler for GET / 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", }) catch |err| { 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 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", .items = &[_][]const u8{ "A", "B", "C" }, .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 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", }) catch |err| { res.status = 500; @@ -114,7 +81,7 @@ fn pageB(app: *App, _: *httpz.Request, res: *httpz.Response) !void { /// Handler for GET /append - demonstrates block append 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", }) catch |err| { 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 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", }) catch |err| { res.status = 500; @@ -145,13 +112,9 @@ pub fn main() !void { defer _ = gpa.deinit(); const allocator = gpa.allocator(); - // Views directory - relative to current working directory - const views_dir = "src/examples/app_01/views"; - - var app = App{ - .allocator = allocator, - .views_dir = views_dir, - }; + // Initialize view engine once at startup + var app = try App.init(allocator); + defer app.deinit(); var server = try httpz.Server(*App).init(allocator, .{ .port = 8080 }, &app); defer server.deinit(); diff --git a/src/examples/app_01/views/mixins/buttons.pug b/src/examples/app_01/views/mixins/buttons.pug new file mode 100644 index 0000000..e96740f --- /dev/null +++ b/src/examples/app_01/views/mixins/buttons.pug @@ -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 diff --git a/src/examples/app_01/views/mixins/cards.pug b/src/examples/app_01/views/mixins/cards.pug new file mode 100644 index 0000000..9845b64 --- /dev/null +++ b/src/examples/app_01/views/mixins/cards.pug @@ -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 diff --git a/src/root.zig b/src/root.zig index 13d0438..9b89326 100644 --- a/src/root.zig +++ b/src/root.zig @@ -7,12 +7,33 @@ //! - Attributes and text interpolation //! - Control flow (if/else, each, while) //! - 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 ast = @import("ast.zig"); pub const parser = @import("parser.zig"); pub const codegen = @import("codegen.zig"); pub const runtime = @import("runtime.zig"); +pub const view_engine = @import("view_engine.zig"); // Re-export main types for convenience pub const Lexer = lexer.Lexer; @@ -32,6 +53,9 @@ pub const Value = runtime.Value; pub const render = runtime.render; pub const renderTemplate = runtime.renderTemplate; +// High-level API +pub const ViewEngine = view_engine.ViewEngine; + test { _ = @import("std").testing.refAllDecls(@This()); } diff --git a/src/view_engine.zig b/src/view_engine.zig new file mode 100644 index 0000000..6f07905 --- /dev/null +++ b/src/view_engine.zig @@ -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/ +}