diff --git a/build.zig.zon b/build.zig.zon index 4fdc8c6..f47672d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .pugz, - .version = "0.3.2", + .version = "0.3.3", .fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications. .minimum_zig_version = "0.15.2", .dependencies = .{}, diff --git a/src/compile_tpls.zig b/src/compile_tpls.zig index 97a7588..f6a3839 100644 --- a/src/compile_tpls.zig +++ b/src/compile_tpls.zig @@ -204,8 +204,8 @@ fn compileSingleFile( else trimmed_views; - // Parse template with full resolution - const final_ast = try engine.parseWithIncludes(arena_allocator, template_name, registry); + // Parse template with full resolution (handles includes, extends, mixins) + const final_ast = try engine.parseTemplate(arena_allocator, template_name, registry); // Extract field names const fields = try zig_codegen.extractFieldNames(arena_allocator, final_ast); diff --git a/src/tpl_compiler/main.zig b/src/tpl_compiler/main.zig index 3d40557..559a91e 100644 --- a/src/tpl_compiler/main.zig +++ b/src/tpl_compiler/main.zig @@ -112,9 +112,8 @@ fn compileSingleFile(allocator: mem.Allocator, input_path: []const u8, output_pa else template_path; - // Parse template with full includes/extends resolution - // This loads all parent templates and includes, processes extends, and collects mixins - const final_ast = try engine.parseWithIncludes(allocator, template_name, ®istry); + // Parse template with full resolution (handles includes, extends, mixins) + const final_ast = try engine.parseTemplate(allocator, template_name, ®istry); // Note: Don't free final_ast as it's managed by the ViewEngine // The normalized_source is intentionally leaked as AST strings point into it // Both will be cleaned up by the allocator when the CLI exits diff --git a/src/view_engine.zig b/src/view_engine.zig index 8abcf5a..4a6e6a3 100644 --- a/src/view_engine.zig +++ b/src/view_engine.zig @@ -69,8 +69,8 @@ pub const ViewEngine = struct { var registry = MixinRegistry.init(allocator); defer registry.deinit(); - // Parse the main AST and process includes - const ast = try self.parseWithIncludes(allocator, template_path, ®istry); + // Parse the template (handles includes, extends, mixins) + const ast = try self.parseTemplate(allocator, template_path, ®istry); defer { ast.deinit(allocator); allocator.destroy(ast); @@ -82,10 +82,21 @@ pub const ViewEngine = struct { }); } - /// Parse a template and process includes recursively - pub fn parseWithIncludes(self: *ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, registry: *MixinRegistry) !*Node { + /// Parse a template file and process all Pug features (includes, extends, mixins). + /// template_path is relative to views_dir (e.g., "pages/home" for views/pages/home.pug) + pub fn parseTemplate(self: *ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, registry: *MixinRegistry) !*Node { + return self.parseTemplateInternal(allocator, template_path, null, registry); + } + + /// Internal parse function that tracks the current file's directory for resolving relative paths. + /// current_dir: directory of the current file (relative to views_dir), or null for top-level + fn parseTemplateInternal(self: *ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, current_dir: ?[]const u8, registry: *MixinRegistry) !*Node { + // Resolve the template path relative to current file's directory + const resolved_template_path = try self.resolveRelativePath(allocator, template_path, current_dir); + defer allocator.free(resolved_template_path); + // Build full path (relative to views_dir) - const full_path = self.resolvePath(allocator, template_path) catch |err| { + const full_path = self.resolvePath(allocator, resolved_template_path) catch |err| { log.debug("failed to resolve path '{s}': {}", .{ template_path, err }); return switch (err) { error.PathEscapesRoot => ViewEngineError.PathEscapesRoot, @@ -115,11 +126,14 @@ pub const ViewEngine = struct { }; errdefer parse_result.deinit(allocator); + // Get the directory of the current template (relative to views_dir) for resolving includes + const template_dir = std.fs.path.dirname(resolved_template_path); + // Process extends (template inheritance) - must be done before includes - const final_ast = try self.processExtends(allocator, parse_result.ast, registry); + const final_ast = try self.processExtends(allocator, parse_result.ast, template_dir, registry); // Process includes in the AST - try self.processIncludes(allocator, final_ast, registry); + try self.processIncludes(allocator, final_ast, template_dir, registry); // Collect mixins from this template mixin.collectMixins(allocator, final_ast, registry) catch {}; @@ -131,13 +145,23 @@ pub const ViewEngine = struct { } /// Process all include statements in the AST - pub fn processIncludes(self: *ViewEngine, allocator: std.mem.Allocator, node: *Node, registry: *MixinRegistry) ViewEngineError!void { + /// current_dir: directory of the current template (relative to views_dir) for resolving relative paths + pub fn processIncludes(self: *ViewEngine, allocator: std.mem.Allocator, node: *Node, current_dir: ?[]const u8, registry: *MixinRegistry) ViewEngineError!void { // Process Include nodes - load the file and inline its content if (node.type == .Include or node.type == .RawInclude) { + // Skip if already processed (has children inlined) + if (node.nodes.items.len > 0) { + // Already processed, just recurse into children + for (node.nodes.items) |child| { + try self.processIncludes(allocator, child, current_dir, registry); + } + return; + } + if (node.file) |file| { if (file.path) |include_path| { - // Load the included file (path relative to views_dir) - const included_ast = self.parseWithIncludes(allocator, include_path, registry) catch |err| { + // Parse the included file (path is resolved relative to current file's directory) + const included_ast = self.parseTemplateInternal(allocator, include_path, current_dir, registry) catch |err| { // For includes, convert TemplateNotFound to IncludeNotFound if (err == ViewEngineError.TemplateNotFound) { return ViewEngineError.IncludeNotFound; @@ -166,12 +190,13 @@ pub const ViewEngine = struct { // Recurse into children for (node.nodes.items) |child| { - try self.processIncludes(allocator, child, registry); + try self.processIncludes(allocator, child, current_dir, registry); } } /// Process extends statement - loads parent template and merges blocks - pub fn processExtends(self: *ViewEngine, allocator: std.mem.Allocator, ast: *Node, registry: *MixinRegistry) ViewEngineError!*Node { + /// current_dir: directory of the current template (relative to views_dir) for resolving relative paths + pub fn processExtends(self: *ViewEngine, allocator: std.mem.Allocator, ast: *Node, current_dir: ?[]const u8, registry: *MixinRegistry) ViewEngineError!*Node { if (ast.nodes.items.len == 0) return ast; // Check if first node is Extends @@ -190,8 +215,8 @@ pub const ViewEngine = struct { self.collectNamedBlocks(node, &child_blocks); } - // Load parent template - const parent_ast = self.parseWithIncludes(allocator, parent_path.?, registry) catch |err| { + // Parse parent template (path is resolved relative to current file's directory) + const parent_ast = self.parseTemplateInternal(allocator, parent_path.?, current_dir, registry) catch |err| { if (err == ViewEngineError.TemplateNotFound) { return ViewEngineError.IncludeNotFound; } @@ -253,6 +278,66 @@ pub const ViewEngine = struct { } } + /// Resolves a path relative to the current file's directory. + /// - Paths starting with "/" are absolute from views_dir root + /// - Paths starting with "./" or "../" are relative to current file's directory + /// - Other paths are relative to views_dir root (Pug convention) + /// Returns a path relative to views_dir. + fn resolveRelativePath(self: *const ViewEngine, allocator: std.mem.Allocator, path: []const u8, current_dir: ?[]const u8) ![]const u8 { + _ = self; + + // If path starts with "/", treat as absolute from views_dir root + if (path.len > 0 and path[0] == '/') { + return allocator.dupe(u8, path[1..]); + } + + // Check if path is explicitly relative (starts with "./" or "../") + const is_explicit_relative = std.mem.startsWith(u8, path, "./") or std.mem.startsWith(u8, path, "../"); + + // If not explicitly relative, treat as relative to views_dir root (Pug convention) + if (!is_explicit_relative) { + return allocator.dupe(u8, path); + } + + // If no current directory (top-level call), path is already relative to views_dir + const dir = current_dir orelse { + return allocator.dupe(u8, path); + }; + + // Join current directory with path and normalize + // e.g., current_dir="pages", path="../partials/header" -> "partials/header" + const joined = try std.fs.path.join(allocator, &.{ dir, path }); + defer allocator.free(joined); + + // Normalize the path (resolve ".." and ".") + // We need to handle this manually since std.fs.path.resolve needs absolute paths + var components = std.ArrayListUnmanaged([]const u8){}; + defer components.deinit(allocator); + + var iter = std.mem.splitScalar(u8, joined, '/'); + while (iter.next()) |part| { + if (std.mem.eql(u8, part, "..")) { + // Go up one directory if possible + if (components.items.len > 0) { + _ = components.pop(); + } + // If no components left, we're at root - ".." is ignored (handled by security check later) + } else if (std.mem.eql(u8, part, ".") or part.len == 0) { + // Skip "." and empty parts + continue; + } else { + try components.append(allocator, part); + } + } + + // Join components back together + if (components.items.len == 0) { + return allocator.dupe(u8, ""); + } + + return std.mem.join(allocator, "/", components.items); + } + /// 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 { @@ -350,3 +435,62 @@ test "ViewEngine - path escape protection" { const result2 = engine.render(allocator, "/etc/passwd", .{}); try std.testing.expectError(ViewEngineError.PathEscapesRoot, result2); } + +test "resolveRelativePath - relative paths from subdirectory" { + const allocator = std.testing.allocator; + const engine = ViewEngine.init(.{}); + + // From pages/, include ../partials/header -> partials/header + const result1 = try engine.resolveRelativePath(allocator, "../partials/header", "pages"); + defer allocator.free(result1); + try std.testing.expectEqualStrings("partials/header", result1); + + // From pages/admin/, include ../../partials/header -> partials/header + const result2 = try engine.resolveRelativePath(allocator, "../../partials/header", "pages/admin"); + defer allocator.free(result2); + try std.testing.expectEqualStrings("partials/header", result2); + + // From pages/, include ./utils -> pages/utils (explicit relative) + const result3 = try engine.resolveRelativePath(allocator, "./utils", "pages"); + defer allocator.free(result3); + try std.testing.expectEqualStrings("pages/utils", result3); + + // From pages/, include header (no ./) -> header (relative to views root, Pug convention) + const result4 = try engine.resolveRelativePath(allocator, "header", "pages"); + defer allocator.free(result4); + try std.testing.expectEqualStrings("header", result4); + + // From pages/, include includes/partial -> includes/partial (relative to views root) + const result5 = try engine.resolveRelativePath(allocator, "includes/partial", "pages"); + defer allocator.free(result5); + try std.testing.expectEqualStrings("includes/partial", result5); +} + +test "resolveRelativePath - absolute paths from views root" { + const allocator = std.testing.allocator; + const engine = ViewEngine.init(.{}); + + // /partials/header from any directory -> partials/header + const result1 = try engine.resolveRelativePath(allocator, "/partials/header", "pages/admin"); + defer allocator.free(result1); + try std.testing.expectEqualStrings("partials/header", result1); + + // /layouts/base from pages/ -> layouts/base + const result2 = try engine.resolveRelativePath(allocator, "/layouts/base", "pages"); + defer allocator.free(result2); + try std.testing.expectEqualStrings("layouts/base", result2); +} + +test "resolveRelativePath - no current directory (top-level)" { + const allocator = std.testing.allocator; + const engine = ViewEngine.init(.{}); + + // When current_dir is null, path is returned as-is + const result1 = try engine.resolveRelativePath(allocator, "pages/home", null); + defer allocator.free(result1); + try std.testing.expectEqualStrings("pages/home", result1); + + const result2 = try engine.resolveRelativePath(allocator, "partials/header", null); + defer allocator.free(result2); + try std.testing.expectEqualStrings("partials/header", result2); +}