diff --git a/README.md b/README.md index bcf5eb9..c55ca44 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ -*! I am using ClaudeCode to build it* -*! Its Yet not ready for production use* - # Pugz A Pug template engine for Zig, supporting both build-time compilation and runtime interpretation. @@ -15,6 +12,8 @@ A Pug template engine for Zig, supporting both build-time compilation and runtim - Includes - Mixins with parameters, defaults, rest args, and block content - Comments (rendered and unbuffered) +- Pretty printing with indentation +- LRU cache with configurable size and TTL ## Installation @@ -24,8 +23,6 @@ Add pugz as a dependency in your `build.zig.zon`: zig fetch --save "git+https://github.com/ankitpatial/pugz#main" ``` -> **Note:** The primary repository is hosted at `code.patial.tech`. GitHub is a mirror. For dependencies, prefer the GitHub mirror for better availability. - --- ## Usage @@ -99,11 +96,16 @@ const std = @import("std"); const pugz = @import("pugz"); pub fn main() !void { - var engine = pugz.ViewEngine.init(.{ + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var engine = try pugz.ViewEngine.init(allocator, .{ .views_dir = "views", }); + defer engine.deinit(); - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const html = try engine.render(arena.allocator(), "index", .{ @@ -131,6 +133,32 @@ const html = try pugz.renderTemplate(allocator, --- +### ViewEngine Options + +```zig +var engine = try pugz.ViewEngine.init(allocator, .{ + .views_dir = "views", // Root directory for templates + .extension = ".pug", // File extension (default: .pug) + .pretty = false, // Enable pretty-printed output + .cache_enabled = true, // Enable AST caching + .max_cached_templates = 100, // LRU cache size (0 = unlimited) + .cache_ttl_seconds = 5, // Cache TTL for development (0 = never expires) +}); +``` + +**Options:** + +| Option | Default | Description | +|--------|---------|-------------| +| `views_dir` | `"views"` | Root directory containing templates | +| `extension` | `".pug"` | File extension for templates | +| `pretty` | `false` | Enable pretty-printed HTML with indentation | +| `cache_enabled` | `true` | Enable AST caching for performance | +| `max_cached_templates` | `0` | Max templates in LRU cache (0 = unlimited hashmap) | +| `cache_ttl_seconds` | `0` | Cache TTL in seconds (0 = never expires) | + +--- + ### With http.zig ```zig diff --git a/build.zig b/build.zig index 9d80855..57708d7 100644 --- a/build.zig +++ b/build.zig @@ -3,10 +3,19 @@ const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // Get cache.zig dependency + const cache_dep = b.dependency("cache", .{ + .target = target, + .optimize = optimize, + }); + const mod = b.addModule("pugz", .{ .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, + .imports = &.{ + .{ .name = "cache", .module = cache_dep.module("cache") }, + }, }); // Creates an executable that will run `test` blocks from the provided module. @@ -17,6 +26,32 @@ pub fn build(b: *std.Build) void { // A run step that will run the test executable. const run_mod_tests = b.addRunArtifact(mod_tests); + // Source file unit tests (lexer, parser, runtime, etc.) + const source_files_with_tests = [_][]const u8{ + "src/lexer.zig", + "src/parser.zig", + "src/runtime.zig", + "src/template.zig", + "src/codegen.zig", + "src/strip_comments.zig", + "src/linker.zig", + "src/load.zig", + "src/error.zig", + "src/pug.zig", + }; + + var source_test_steps: [source_files_with_tests.len]*std.Build.Step.Run = undefined; + inline for (source_files_with_tests, 0..) |file, i| { + const file_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path(file), + .target = target, + .optimize = optimize, + }), + }); + source_test_steps[i] = b.addRunArtifact(file_tests); + } + // Integration tests - general template tests const general_tests = b.addTest(.{ .root_module = b.createModule(.{ @@ -62,6 +97,10 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&run_general_tests.step); test_step.dependOn(&run_doctype_tests.step); test_step.dependOn(&run_check_list_tests.step); + // Add source file tests + for (&source_test_steps) |step| { + test_step.dependOn(&step.step); + } // Individual test steps const test_general_step = b.step("test-general", "Run general template tests"); @@ -72,6 +111,9 @@ pub fn build(b: *std.Build) void { const test_unit_step = b.step("test-unit", "Run unit tests (lexer, parser, etc.)"); test_unit_step.dependOn(&run_mod_tests.step); + for (&source_test_steps) |step| { + test_unit_step.dependOn(&step.step); + } const test_check_list_step = b.step("test-check-list", "Run check_list template tests"); test_check_list_step.dependOn(&run_check_list_tests.step); @@ -94,4 +136,23 @@ pub fn build(b: *std.Build) void { run_bench.setCwd(b.path(".")); const bench_step = b.step("bench", "Run benchmark"); bench_step.dependOn(&run_bench.step); + + // Test includes example + const test_includes_exe = b.addExecutable(.{ + .name = "test-includes", + .root_module = b.createModule(.{ + .root_source_file = b.path("examples/test_includes.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "pugz", .module = mod }, + }, + }), + }); + b.installArtifact(test_includes_exe); + + const run_test_includes = b.addRunArtifact(test_includes_exe); + run_test_includes.setCwd(b.path(".")); + const test_includes_step = b.step("test-includes", "Test include/mixin rendering"); + test_includes_step.dependOn(&run_test_includes.step); } diff --git a/build.zig.zon b/build.zig.zon index ea40aa9..d428f92 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,9 +1,14 @@ .{ .name = .pugz, - .version = "0.2.2", + .version = "0.3.0", .fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications. .minimum_zig_version = "0.15.2", - .dependencies = .{}, + .dependencies = .{ + .cache = .{ + .url = "https://github.com/karlseguin/cache.zig/archive/b8b04054bc56bac1026ad72487983a89e5b7f93c.tar.gz", + .hash = "cache-0.0.0-winRwGaTAABp4XWPw3uPq-zvkue-fQi0L5KxpyyJEePO", + }, + }, .paths = .{ "build.zig", "build.zig.zon", diff --git a/examples/demo/public/css/style.css b/examples/demo/public/css/style.css new file mode 100644 index 0000000..eb63d0e --- /dev/null +++ b/examples/demo/public/css/style.css @@ -0,0 +1,752 @@ +/* Pugz Store - Clean Modern CSS */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --primary: #3b82f6; + --primary-dark: #2563eb; + --text: #1f2937; + --text-muted: #6b7280; + --bg: #ffffff; + --bg-alt: #f9fafb; + --border: #e5e7eb; + --success: #10b981; + --radius: 8px; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + line-height: 1.6; + color: var(--text); + background: var(--bg); +} + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Layout */ +.container { + max-width: 1100px; + margin: 0 auto; + padding: 0 20px; +} + +/* Header */ +.header { + background: var(--bg); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; +} + +.logo { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary); +} + +.logo:hover { + text-decoration: none; +} + +.nav { + display: flex; + gap: 24px; +} + +.nav-link { + color: var(--text-muted); + font-weight: 500; +} + +.nav-link:hover { + color: var(--primary); + text-decoration: none; +} + +.header-actions { + display: flex; + align-items: center; +} + +.cart-link { + color: var(--text); + font-weight: 500; +} + +/* Footer */ +.footer { + background: var(--text); + color: white; + padding: 40px 0; + margin-top: 60px; +} + +.footer-content { + text-align: center; +} + +.footer-content p { + color: #9ca3af; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + border-radius: var(--radius); + border: none; + cursor: pointer; + text-align: center; + transition: all 0.2s; +} + +.btn:hover { + text-decoration: none; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +.btn-outline:hover { + border-color: var(--primary); + color: var(--primary); +} + +.btn-sm { + padding: 6px 12px; + font-size: 13px; +} + +.btn-block { + display: block; + width: 100%; +} + +/* Hero Section */ +.hero { + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + color: white; + padding: 80px 0; + text-align: center; +} + +.hero h1 { + font-size: 2.5rem; + margin-bottom: 16px; +} + +.hero p { + font-size: 1.1rem; + opacity: 0.9; + margin-bottom: 32px; +} + +.hero-actions { + display: flex; + gap: 12px; + justify-content: center; +} + +.hero .btn-outline { + border-color: rgba(255, 255, 255, 0.4); + color: white; +} + +.hero .btn-outline:hover { + border-color: white; + background: rgba(255, 255, 255, 0.1); +} + +/* Sections */ +.section { + padding: 60px 0; +} + +.section-alt { + background: var(--bg-alt); +} + +.section h2 { + font-size: 1.75rem; + margin-bottom: 32px; +} + +.page-header { + background: var(--bg-alt); + padding: 40px 0; + border-bottom: 1px solid var(--border); +} + +.page-header h1 { + font-size: 2rem; + margin-bottom: 8px; +} + +.page-header p { + color: var(--text-muted); +} + +/* Feature Grid */ +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 24px; +} + +.feature-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} + +.feature-card h3 { + font-size: 1.1rem; + margin-bottom: 12px; +} + +.feature-card p { + color: var(--text-muted); + font-size: 14px; +} + +.feature-card ul { + margin: 0; + padding-left: 20px; + color: var(--text-muted); + font-size: 14px; +} + +.feature-card li { + margin-bottom: 4px; +} + +/* Category Grid */ +.category-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 24px; +} + +.category-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px 24px; + text-align: center; + transition: all 0.2s; +} + +.category-card:hover { + border-color: var(--primary); + text-decoration: none; + transform: translateY(-2px); +} + +.category-icon { + width: 60px; + height: 60px; + margin: 0 auto 16px; + background: var(--bg-alt); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: 600; + color: var(--primary); +} + +.category-card h3 { + font-size: 1rem; + color: var(--text); + margin-bottom: 4px; +} + +.category-card span { + font-size: 14px; + color: var(--text-muted); +} + +/* Product Grid */ +.product-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 24px; + margin-bottom: 40px; +} + +.product-card { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: all 0.2s; +} + +.product-card:hover { + border-color: var(--primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.product-image { + position: relative; + height: 180px; + background: var(--bg-alt); +} + +.product-badge { + position: absolute; + top: 12px; + left: 12px; + background: #ef4444; + color: white; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + border-radius: 4px; +} + +.product-info { + padding: 16px; +} + +.product-category { + font-size: 12px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.product-name { + font-size: 1rem; + margin: 6px 0 12px; +} + +.product-price { + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + margin-bottom: 12px; +} + +/* Products Toolbar */ +.products-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.results-count { + color: var(--text-muted); +} + +.sort-options { + display: flex; + align-items: center; + gap: 8px; +} + +.sort-options label { + color: var(--text-muted); + font-size: 14px; +} + +.sort-options select { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 14px; +} + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + gap: 8px; +} + +.page-link { + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 14px; +} + +.page-link:hover { + border-color: var(--primary); + color: var(--primary); + text-decoration: none; +} + +.page-link.active { + background: var(--primary); + border-color: var(--primary); + color: white; +} + +/* Cart */ +.cart-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 32px; +} + +.cart-items { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.cart-item { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 20px; + padding: 20px; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.cart-item:last-child { + border-bottom: none; +} + +.cart-item-info h3 { + font-size: 1rem; + margin-bottom: 4px; +} + +.cart-item-price { + color: var(--text-muted); + font-size: 14px; +} + +.cart-item-qty { + display: flex; + align-items: center; +} + +.qty-btn { + width: 32px; + height: 32px; + border: 1px solid var(--border); + background: var(--bg); + cursor: pointer; + font-size: 16px; +} + +.qty-input { + width: 48px; + height: 32px; + border: 1px solid var(--border); + border-left: none; + border-right: none; + text-align: center; + font-size: 14px; +} + +.cart-item-total { + font-weight: 600; + min-width: 80px; + text-align: right; +} + +.cart-item-remove { + width: 32px; + height: 32px; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + font-size: 18px; +} + +.cart-item-remove:hover { + color: #ef4444; +} + +.cart-actions { + padding: 20px; + border-top: 1px solid var(--border); +} + +.cart-summary { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + height: fit-content; +} + +.cart-summary h3 { + font-size: 1.1rem; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.summary-row { + display: flex; + justify-content: space-between; + margin-bottom: 12px; + font-size: 14px; +} + +.summary-total { + font-size: 1.1rem; + font-weight: 600; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +/* About Page */ +.about-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 40px; +} + +.about-main h2 { + margin-bottom: 16px; +} + +.about-main h3 { + margin: 24px 0 12px; + font-size: 1.1rem; +} + +.about-main p { + color: var(--text-muted); + margin-bottom: 12px; +} + +.feature-list { + list-style: none; + padding: 0; +} + +.feature-list li { + padding: 10px 0; + border-bottom: 1px solid var(--border); + font-size: 14px; +} + +.about-sidebar { + display: flex; + flex-direction: column; + gap: 20px; +} + +.info-card { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.info-card h3 { + font-size: 1rem; + margin-bottom: 12px; +} + +.info-card ul { + margin: 0; + padding-left: 18px; + font-size: 14px; + color: var(--text-muted); +} + +.info-card li { + margin-bottom: 6px; +} + +/* Product Detail */ +.product-detail { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 48px; +} + +.product-detail-image { + background: var(--bg-alt); + border-radius: var(--radius); + aspect-ratio: 1; +} + +.product-image-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.product-detail-info h1 { + font-size: 2rem; + margin: 8px 0 16px; +} + +.product-price-large { + font-size: 1.75rem; + font-weight: 600; + color: var(--primary); + margin-bottom: 16px; +} + +.product-description { + color: var(--text-muted); + margin-bottom: 24px; + line-height: 1.7; +} + +.product-actions { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 24px; +} + +.quantity-selector { + display: flex; + align-items: center; + gap: 8px; +} + +.quantity-selector label { + font-weight: 500; +} + +.product-meta { + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.product-meta p { + color: var(--text-muted); + font-size: 14px; + margin-bottom: 8px; +} + +.breadcrumb { + font-size: 14px; + color: var(--text-muted); +} + +.breadcrumb a { + color: var(--text-muted); +} + +.breadcrumb a:hover { + color: var(--primary); +} + +.breadcrumb span { + margin: 0 8px; +} + +/* Error Page */ +.error-page { + padding: 100px 0; + text-align: center; +} + +.error-code { + font-size: 8rem; + font-weight: 700; + color: var(--border); + line-height: 1; +} + +.error-content h2 { + margin: 16px 0 8px; +} + +.error-content p { + color: var(--text-muted); + margin-bottom: 32px; +} + +.error-actions { + display: flex; + gap: 12px; + justify-content: center; +} + +/* Utility Classes */ +.text-success { + color: var(--success); +} + +.text-muted { + color: var(--text-muted); +} + +/* Responsive */ +@media (max-width: 768px) { + .header-content { + flex-wrap: wrap; + gap: 16px; + } + + .nav { + order: 3; + width: 100%; + justify-content: center; + } + + .hero h1 { + font-size: 2rem; + } + + .hero-actions { + flex-direction: column; + align-items: center; + } + + .cart-layout, + .about-grid { + grid-template-columns: 1fr; + } + + .cart-item { + grid-template-columns: 1fr; + gap: 12px; + } +} diff --git a/examples/demo/src/main.zig b/examples/demo/src/main.zig index d8d2ab8..2131fde 100644 --- a/examples/demo/src/main.zig +++ b/examples/demo/src/main.zig @@ -1,11 +1,13 @@ -//! Pugz Demo - ViewEngine Template Rendering +//! Pugz Store Demo - A comprehensive e-commerce demo showcasing Pugz capabilities //! -//! This demo shows how to use ViewEngine for server-side rendering. -//! -//! Routes: -//! GET / - Home page -//! GET /users - Users list -//! GET /page-a - Page with data +//! Features demonstrated: +//! - Template inheritance (extends/block) +//! - Partial includes (header, footer) +//! - Mixins with parameters (product-card, rating, forms) +//! - Conditionals and loops +//! - Data binding +//! - Pretty printing +//! - LRU cache with TTL const std = @import("std"); const httpz = @import("httpz"); @@ -13,28 +15,367 @@ const pugz = @import("pugz"); const Allocator = std.mem.Allocator; -/// Application state shared across all requests +// ============================================================================ +// Data Types +// ============================================================================ + +const Product = struct { + id: []const u8, + name: []const u8, + price: []const u8, + image: []const u8, + rating: u8, + category: []const u8, + categorySlug: []const u8, + sale: bool = false, + description: []const u8 = "", + reviewCount: []const u8 = "0", +}; + +const Category = struct { + name: []const u8, + slug: []const u8, + icon: []const u8, + count: []const u8, + active: bool = false, +}; + +const CartItem = struct { + id: []const u8, + name: []const u8, + price: []const u8, + image: []const u8, + variant: []const u8, + quantity: []const u8, + total: []const u8, +}; + +const Cart = struct { + items: []const CartItem, + subtotal: []const u8, + shipping: []const u8, + discount: ?[]const u8 = null, + discountCode: ?[]const u8 = null, + tax: []const u8, + total: []const u8, +}; + +const ShippingMethod = struct { + id: []const u8, + name: []const u8, + time: []const u8, + price: []const u8, +}; + +const State = struct { + code: []const u8, + name: []const u8, +}; + +// ============================================================================ +// Sample Data +// ============================================================================ + +const sample_products = [_]Product{ + .{ + .id = "1", + .name = "Wireless Headphones", + .price = "79.99", + .image = "/images/headphones.jpg", + .rating = 4, + .category = "Electronics", + .categorySlug = "electronics", + .sale = true, + .description = "Premium wireless headphones with noise cancellation", + .reviewCount = "128", + }, + .{ + .id = "2", + .name = "Smart Watch Pro", + .price = "199.99", + .image = "/images/watch.jpg", + .rating = 5, + .category = "Electronics", + .categorySlug = "electronics", + .description = "Advanced fitness tracking and notifications", + .reviewCount = "256", + }, + .{ + .id = "3", + .name = "Laptop Stand", + .price = "49.99", + .image = "/images/stand.jpg", + .rating = 4, + .category = "Accessories", + .categorySlug = "accessories", + .description = "Ergonomic aluminum laptop stand", + .reviewCount = "89", + }, + .{ + .id = "4", + .name = "USB-C Hub", + .price = "39.99", + .image = "/images/hub.jpg", + .rating = 4, + .category = "Accessories", + .categorySlug = "accessories", + .sale = true, + .description = "7-in-1 USB-C hub with HDMI and card reader", + .reviewCount = "312", + }, + .{ + .id = "5", + .name = "Mechanical Keyboard", + .price = "129.99", + .image = "/images/keyboard.jpg", + .rating = 5, + .category = "Electronics", + .categorySlug = "electronics", + .description = "RGB mechanical keyboard with Cherry MX switches", + .reviewCount = "445", + }, + .{ + .id = "6", + .name = "Desk Lamp", + .price = "34.99", + .image = "/images/lamp.jpg", + .rating = 4, + .category = "Home Office", + .categorySlug = "home-office", + .description = "LED desk lamp with adjustable brightness", + .reviewCount = "67", + }, +}; + +const sample_categories = [_]Category{ + .{ .name = "Electronics", .slug = "electronics", .icon = "E", .count = "24" }, + .{ .name = "Accessories", .slug = "accessories", .icon = "A", .count = "18" }, + .{ .name = "Home Office", .slug = "home-office", .icon = "H", .count = "12" }, + .{ .name = "Clothing", .slug = "clothing", .icon = "C", .count = "36" }, +}; + +const sample_cart_items = [_]CartItem{ + .{ + .id = "1", + .name = "Wireless Headphones", + .price = "79.99", + .image = "/images/headphones.jpg", + .variant = "Black", + .quantity = "1", + .total = "79.99", + }, + .{ + .id = "5", + .name = "Mechanical Keyboard", + .price = "129.99", + .image = "/images/keyboard.jpg", + .variant = "RGB", + .quantity = "1", + .total = "129.99", + }, +}; + +const sample_cart = Cart{ + .items = &sample_cart_items, + .subtotal = "209.98", + .shipping = "0", + .tax = "18.90", + .total = "228.88", +}; + +const shipping_methods = [_]ShippingMethod{ + .{ .id = "standard", .name = "Standard Shipping", .time = "5-7 business days", .price = "0" }, + .{ .id = "express", .name = "Express Shipping", .time = "2-3 business days", .price = "9.99" }, + .{ .id = "overnight", .name = "Overnight Shipping", .time = "Next business day", .price = "19.99" }, +}; + +const us_states = [_]State{ + .{ .code = "CA", .name = "California" }, + .{ .code = "NY", .name = "New York" }, + .{ .code = "TX", .name = "Texas" }, + .{ .code = "FL", .name = "Florida" }, + .{ .code = "WA", .name = "Washington" }, +}; + +// ============================================================================ +// Application +// ============================================================================ + const App = struct { allocator: Allocator, view: pugz.ViewEngine, - pub fn init(allocator: Allocator) App { + pub fn init(allocator: Allocator) !App { return .{ .allocator = allocator, - .view = pugz.ViewEngine.init(.{ + .view = try pugz.ViewEngine.init(allocator, .{ .views_dir = "views", + .pretty = true, + .max_cached_templates = 50, + .cache_ttl_seconds = 10, // 10s TTL for development }), }; } + + pub fn deinit(self: *App) void { + self.view.deinit(); + } }; +// ============================================================================ +// Request Handlers +// ============================================================================ + +fn home(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.view.render(res.arena, "pages/home", .{ + .title = "Home", + .cartCount = "2", + .authenticated = true, + .items = &[_][]const u8{ "Wireless Headphones", "Smart Watch", "Laptop Stand", "USB-C Hub" }, + }) catch |err| { + return renderError(res, err); + }; + + res.content_type = .HTML; + res.body = html; +} + +fn products(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.view.render(res.arena, "pages/products", .{ + .title = "All Products", + .cartCount = "2", + .productCount = "6", + }) catch |err| { + return renderError(res, err); + }; + + res.content_type = .HTML; + res.body = html; +} + +fn productDetail(app: *App, req: *httpz.Request, res: *httpz.Response) !void { + const id = req.param("id") orelse "1"; + _ = id; + + const html = app.view.render(res.arena, "pages/product-detail", .{ + .cartCount = "2", + .productName = "Wireless Headphones", + .category = "Electronics", + .price = "79.99", + .description = "Premium wireless headphones with active noise cancellation. Experience crystal-clear audio whether you're working, traveling, or relaxing at home.", + .sku = "WH-001-BLK", + }) catch |err| { + return renderError(res, err); + }; + + res.content_type = .HTML; + res.body = html; +} + +fn cart(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.view.render(res.arena, "pages/cart", .{ + .title = "Shopping Cart", + .cartCount = "2", + .cartItems = &sample_cart_items, + .subtotal = sample_cart.subtotal, + .shipping = sample_cart.shipping, + .tax = sample_cart.tax, + .total = sample_cart.total, + }) catch |err| { + return renderError(res, err); + }; + + res.content_type = .HTML; + res.body = html; +} + +fn about(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.view.render(res.arena, "pages/about", .{ + .title = "About", + .cartCount = "2", + }) catch |err| { + return renderError(res, err); + }; + + res.content_type = .HTML; + res.body = html; +} + +fn notFound(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + res.status = 404; + + const html = app.view.render(res.arena, "pages/404", .{ + .title = "Page Not Found", + .cartCount = "2", + }) catch |err| { + return renderError(res, err); + }; + + res.content_type = .HTML; + res.body = html; +} + +fn renderError(res: *httpz.Response, err: anyerror) void { + res.status = 500; + res.content_type = .HTML; + res.body = std.fmt.allocPrint(res.arena, + \\ + \\ + \\Error + \\ + \\

500 - Server Error

+ \\

Error: {s}

+ \\ + \\ + , .{@errorName(err)}) catch "Internal Server Error"; +} + +// ============================================================================ +// Static Files +// ============================================================================ + +fn serveStatic(_: *App, req: *httpz.Request, res: *httpz.Response) !void { + const path = req.url.path; + + // Strip leading slash and prepend public folder + const rel_path = if (path.len > 0 and path[0] == '/') path[1..] else path; + const full_path = std.fmt.allocPrint(res.arena, "public/{s}", .{rel_path}) catch { + res.status = 500; + res.body = "Internal Server Error"; + return; + }; + + // Read file from disk + const content = std.fs.cwd().readFileAlloc(res.arena, full_path, 10 * 1024 * 1024) catch { + res.status = 404; + res.body = "Not Found"; + return; + }; + + // Set content type based on extension + if (std.mem.endsWith(u8, path, ".css")) { + res.content_type = .CSS; + } else if (std.mem.endsWith(u8, path, ".js")) { + res.content_type = .JS; + } else if (std.mem.endsWith(u8, path, ".html")) { + res.content_type = .HTML; + } + + res.body = content; +} + +// ============================================================================ +// Main +// ============================================================================ + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer if (gpa.deinit() == .leak) @panic("leak"); const allocator = gpa.allocator(); - var app = App.init(allocator); + var app = try App.init(allocator); + defer app.deinit(); const port = 8081; var server = try httpz.Server(*App).init(allocator, .{ .port = port }, &app); @@ -42,83 +383,37 @@ pub fn main() !void { var router = try server.router(.{}); - router.get("/", index, .{}); - router.get("/users", users, .{}); - router.get("/page-a", pageA, .{}); - router.get("/mixin-test", mixinTest, .{}); + // Pages + router.get("/", home, .{}); + router.get("/products", products, .{}); + router.get("/products/:id", productDetail, .{}); + router.get("/cart", cart, .{}); + router.get("/about", about, .{}); + + // Static files + router.get("/css/*", serveStatic, .{}); std.debug.print( \\ - \\Pugz Demo - ViewEngine Template Rendering - \\========================================== - \\Server running at http://localhost:{d} + \\ ____ ____ _ + \\ | _ \ _ _ __ _ ____ / ___|| |_ ___ _ __ ___ + \\ | |_) | | | |/ _` |_ / \___ \| __/ _ \| '__/ _ \ + \\ | __/| |_| | (_| |/ / ___) | || (_) | | | __/ + \\ |_| \__,_|\__, /___| |____/ \__\___/|_| \___| + \\ |___/ \\ - \\Routes: - \\ GET / - Home page - \\ GET /users - Users list - \\ GET /page-a - Page with data - \\ GET /mixin-test - Mixin test page + \\ Server running at http://localhost:{d} \\ - \\Press Ctrl+C to stop. + \\ Routes: + \\ GET / - Home page + \\ GET /products - Products page + \\ GET /products/:id - Product detail + \\ GET /cart - Shopping cart + \\ GET /about - About page + \\ + \\ Press Ctrl+C to stop. \\ , .{port}); try server.listen(); } - -/// GET / - Home page -fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void { - const html = app.view.render(res.arena, "index", .{ - .title = "Welcome", - .authenticated = true, - }) catch |err| { - res.status = 500; - res.body = @errorName(err); - return; - }; - - res.content_type = .HTML; - res.body = html; -} - -/// GET /users - Users list -fn users(app: *App, _: *httpz.Request, res: *httpz.Response) !void { - const html = app.view.render(res.arena, "users", .{ - .title = "Users", - }) catch |err| { - res.status = 500; - res.body = @errorName(err); - return; - }; - - res.content_type = .HTML; - res.body = html; -} - -/// GET /page-a - Page with data -fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void { - const html = app.view.render(res.arena, "page-a", .{ - .title = "Page A - Pets", - .items = &[_][]const u8{ "A", "B", "C" }, - .n = 0, - }) catch |err| { - res.status = 500; - res.body = @errorName(err); - return; - }; - - res.content_type = .HTML; - res.body = html; -} - -/// GET /mixin-test - Mixin test page -fn mixinTest(app: *App, _: *httpz.Request, res: *httpz.Response) !void { - const html = app.view.render(res.arena, "mixin-test", .{}) catch |err| { - res.status = 500; - res.body = @errorName(err); - return; - }; - - res.content_type = .HTML; - res.body = html; -} diff --git a/examples/demo/views/404.pug b/examples/demo/views/404.pug deleted file mode 100644 index 35797cd..0000000 --- a/examples/demo/views/404.pug +++ /dev/null @@ -1,2 +0,0 @@ -p - | Route no found diff --git a/examples/demo/views/generated.zig b/examples/demo/views/generated.zig deleted file mode 100644 index d534caa..0000000 --- a/examples/demo/views/generated.zig +++ /dev/null @@ -1,336 +0,0 @@ -//! Auto-generated by pugz.compileTemplates() -//! Do not edit manually - regenerate by running: zig build - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const ArrayList = std.ArrayList(u8); - -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -const esc_lut: [256]?[]const u8 = blk: { - var t: [256]?[]const u8 = .{null} ** 256; - t['&'] = "&"; - t['<'] = "<"; - t['>'] = ">"; - t['"'] = """; - t['\''] = "'"; - break :blk t; -}; - -fn esc(o: *ArrayList, a: Allocator, s: []const u8) Allocator.Error!void { - var i: usize = 0; - for (s, 0..) |c, j| { - if (esc_lut[c]) |e| { - if (j > i) try o.appendSlice(a, s[i..j]); - try o.appendSlice(a, e); - i = j + 1; - } - } - if (i < s.len) try o.appendSlice(a, s[i..]); -} - -fn truthy(v: anytype) bool { - return switch (@typeInfo(@TypeOf(v))) { - .bool => v, - .optional => v != null, - .pointer => |p| if (p.size == .slice) v.len > 0 else true, - .int, .comptime_int => v != 0, - else => true, - }; -} - -var int_buf: [32]u8 = undefined; - -fn strVal(v: anytype) []const u8 { - const T = @TypeOf(v); - switch (@typeInfo(T)) { - .pointer => |p| switch (p.size) { - .slice => return v, - .one => { - // For pointer-to-array, slice it - const child_info = @typeInfo(p.child); - if (child_info == .array) { - const arr_info = child_info.array; - const ptr: [*]const arr_info.child = @ptrCast(v); - return ptr[0..arr_info.len]; - } - return strVal(v.*); - }, - else => @compileError("unsupported pointer type"), - }, - .array => @compileError("arrays must be passed by pointer"), - .int, .comptime_int => return std.fmt.bufPrint(&int_buf, "{d}", .{v}) catch "0", - .optional => return if (v) |val| strVal(val) else "", - else => @compileError("strVal: unsupported type " ++ @typeName(T)), - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Templates -// ───────────────────────────────────────────────────────────────────────────── - -pub fn index(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "hello

some thing

ballahballah"); - { - const text = "click me "; - const @"type" = "secondary"; - try o.appendSlice(a, ""); - try esc(&o, a, strVal(text)); - try o.appendSlice(a, ""); - } - try o.appendSlice(a, "
Google 1
Google 2
Google 3"); - _ = d; - return o.items; -} - -pub fn sub_layout(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "My Site - "); - try esc(&o, a, strVal(@field(d, "title"))); - try o.appendSlice(a, "

nothing

nothing

some footer content

"); - return o.items; -} - -pub fn _404(a: Allocator, d: anytype) Allocator.Error![]u8 { - _ = .{ a, d }; - return "

Route no found

"; -} - -pub fn mixins_alert(a: Allocator, d: anytype) Allocator.Error![]u8 { - _ = .{ a, d }; - return ""; -} - -pub fn mixins_buttons(a: Allocator, d: anytype) Allocator.Error![]u8 { - _ = .{ a, d }; - return ""; -} - -pub fn mixins_cards(a: Allocator, d: anytype) Allocator.Error![]u8 { - _ = .{ a, d }; - return ""; -} - -pub fn mixins_alert_error(a: Allocator, d: anytype) Allocator.Error![]u8 { - _ = .{ a, d }; - return ""; -} - -pub fn mixins_input_text(a: Allocator, d: anytype) Allocator.Error![]u8 { - _ = .{ a, d }; - return ""; -} - -pub fn home(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, ""); - try esc(&o, a, strVal(@field(d, "title"))); - try o.appendSlice(a, "

"); - try esc(&o, a, strVal(@field(d, "title"))); - try o.appendSlice(a, "

"); - if (@hasField(@TypeOf(d), "authenticated") and truthy(@field(d, "authenticated"))) { - try o.appendSlice(a, "Welcome back!"); - } - try o.appendSlice(a, "

This page is rendered using a compiled template.

Compiled templates are 3x faster than Pug.js!

"); - return o.items; -} - -pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "My Site - "); - try esc(&o, a, strVal(@field(d, "title"))); - try o.appendSlice(a, "

"); - try esc(&o, a, strVal(@field(d, "title"))); - try o.appendSlice(a, "

Welcome to the pets page!


one
two
sdfsdfsbtn
"); - { - const name = "firstName"; - const label = "First Name"; - const placeholder = "first name"; - try o.appendSlice(a, "
"); - try esc(&o, a, strVal(label)); - try o.appendSlice(a, "
"); - } - try o.appendSlice(a, "
"); - { - const name = "lastName"; - const label = "Last Name"; - const placeholder = "last name"; - try o.appendSlice(a, "
"); - try esc(&o, a, strVal(label)); - try o.appendSlice(a, "
"); - } - try o.appendSlice(a, "sumit"); - if (@hasField(@TypeOf(d), "error") and truthy(@field(d, "error"))) { - { - const message = @field(d, "error"); - { - const mixin_attrs_1: struct { - class: []const u8 = "", - id: []const u8 = "", - style: []const u8 = "", - } = .{ - .class = "alert-error", - }; - try o.appendSlice(a, ""); - try esc(&o, a, strVal(message)); - try o.appendSlice(a, ""); - } - } - } - try o.appendSlice(a, "

some footer content

"); - return o.items; -} - -pub fn mixin_test(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "Mixin Test

Mixin Test Page

Testing button mixin:

"); - { - const text = "Click Me"; - const @"type" = "primary"; - try o.appendSlice(a, ""); - try esc(&o, a, strVal(text)); - try o.appendSlice(a, ""); - } - { - const text = "Cancel"; - const @"type" = "btn btn-secondary"; - try o.appendSlice(a, ""); - try esc(&o, a, strVal(text)); - try o.appendSlice(a, ""); - } - try o.appendSlice(a, "

Testing link mixin:

"); - { - const href = "/home"; - const text = "Go Home"; - try o.appendSlice(a, ""); - try esc(&o, a, strVal(text)); - try o.appendSlice(a, ""); - } - try o.appendSlice(a, ""); - _ = d; - return o.items; -} - -pub fn page_b(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "My Site - "); - try esc(&o, a, strVal(@field(d, "title"))); - try o.appendSlice(a, "

nothing

nothing

some footer content

"); - return o.items; -} - -pub fn layout_2(a: Allocator, d: anytype) Allocator.Error![]u8 { - _ = .{ a, d }; - return ""; -} - -pub fn layout(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "My Site - "); - try esc(&o, a, strVal(@field(d, "title"))); - try o.appendSlice(a, "

some footer content

"); - return o.items; -} - -pub fn page_append(a: Allocator, d: anytype) Allocator.Error![]u8 { - _ = .{ a, d }; - return "

cheks manually the head section
hello there

"; -} - -pub fn users(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "Users

User List

    "); - for (@field(d, "users")) |user| { - try o.appendSlice(a, "
  • "); - try esc(&o, a, strVal(user.name)); - try o.appendSlice(a, ""); - try esc(&o, a, strVal(user.email)); - try o.appendSlice(a, "
  • "); - } - try o.appendSlice(a, "
"); - return o.items; -} - -pub fn page_appen_optional_blk(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "My Site - "); - try esc(&o, a, strVal(@field(d, "title"))); - try o.appendSlice(a, "

some footer content

"); - return o.items; -} - -pub fn pet(a: Allocator, d: anytype) Allocator.Error![]u8 { - var o: ArrayList = .empty; - try o.appendSlice(a, "

"); - try esc(&o, a, strVal(@field(d, "petName"))); - try o.appendSlice(a, "

"); - return o.items; -} - -pub const template_names = [_][]const u8{ - "index", - "sub_layout", - "_404", - "mixins_alert", - "mixins_buttons", - "mixins_cards", - "mixins_alert_error", - "mixins_input_text", - "home", - "page_a", - "mixin_test", - "page_b", - "layout_2", - "layout", - "page_append", - "users", - "page_appen_optional_blk", - "pet", -}; diff --git a/examples/demo/views/home.pug b/examples/demo/views/home.pug deleted file mode 100644 index 542035d..0000000 --- a/examples/demo/views/home.pug +++ /dev/null @@ -1,15 +0,0 @@ -doctype html -html - head - title #{title} - link(rel="stylesheet" href="/style.css") - body - header - h1 #{title} - if authenticated - span.user Welcome back! - main - p This page is rendered using a compiled template. - p Compiled templates are 3x faster than Pug.js! - footer - p © 2024 Pugz Demo diff --git a/examples/demo/views/index.pug b/examples/demo/views/index.pug deleted file mode 100644 index dbe2d49..0000000 --- a/examples/demo/views/index.pug +++ /dev/null @@ -1,15 +0,0 @@ -doctype html -html - head - title hello - body - p some thing - | ballah - | ballah - +btn("click me ", "secondary") - br - a(href='//google.com' target="_blank") Google 1 - br - a(class='button' href='//google.com' target="_blank") Google 2 - br - a(class='button', href='//google.com' target="_blank") Google 3 diff --git a/examples/demo/views/layout-2.pug b/examples/demo/views/layout-2.pug deleted file mode 100644 index c809925..0000000 --- a/examples/demo/views/layout-2.pug +++ /dev/null @@ -1,7 +0,0 @@ -html - head - block head - script(src='/vendor/jquery.js') - script(src='/vendor/caustic.js') - body - block content diff --git a/examples/demo/views/layout.pug b/examples/demo/views/layout.pug deleted file mode 100644 index 871e071..0000000 --- a/examples/demo/views/layout.pug +++ /dev/null @@ -1,10 +0,0 @@ -html - head - title My Site - #{title} - block scripts - script(src='/jquery.js') - body - block content - block foot - #footer - p some footer content diff --git a/examples/demo/views/layouts/base.pug b/examples/demo/views/layouts/base.pug new file mode 100644 index 0000000..db156db --- /dev/null +++ b/examples/demo/views/layouts/base.pug @@ -0,0 +1,28 @@ +doctype html +html(lang="en") + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1.0") + link(rel="stylesheet" href="/css/style.css") + block title + title Pugz Store + body + header.header + .container + .header-content + a.logo(href="/") Pugz Store + nav.nav + a.nav-link(href="/") Home + a.nav-link(href="/products") Products + a.nav-link(href="/about") About + .header-actions + a.cart-link(href="/cart") + | Cart (#{cartCount}) + + main + block content + + footer.footer + .container + .footer-content + p Built with Pugz - A Pug template engine for Zig diff --git a/examples/demo/views/mixin-test.pug b/examples/demo/views/mixin-test.pug deleted file mode 100644 index 2d0bd9b..0000000 --- a/examples/demo/views/mixin-test.pug +++ /dev/null @@ -1,15 +0,0 @@ -include mixins/buttons.pug - -doctype html -html - head - title Mixin Test - body - h1 Mixin Test Page - - p Testing button mixin: - +btn("Click Me") - +btn("Cancel", "secondary") - - p Testing link mixin: - +btn-link("/home", "Go Home") diff --git a/examples/demo/views/mixins/alert.pug b/examples/demo/views/mixins/alert.pug deleted file mode 100644 index 3d86db2..0000000 --- a/examples/demo/views/mixins/alert.pug +++ /dev/null @@ -1,5 +0,0 @@ -mixin alert(message) - div.alert(role="alert" class!=attributes.class) - svg(xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24") - path(stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z") - span= message diff --git a/examples/demo/views/mixins/alert_error.pug b/examples/demo/views/mixins/alert_error.pug deleted file mode 100644 index 60061c5..0000000 --- a/examples/demo/views/mixins/alert_error.pug +++ /dev/null @@ -1,2 +0,0 @@ -mixin alert_error(message) - +alert(message)(class="alert-error") diff --git a/examples/demo/views/mixins/alerts.pug b/examples/demo/views/mixins/alerts.pug new file mode 100644 index 0000000..34b4e4b --- /dev/null +++ b/examples/demo/views/mixins/alerts.pug @@ -0,0 +1,12 @@ +//- Alert/notification mixins + +mixin alert(message, type) + - var alertClass = type ? "alert alert-" + type : "alert alert-info" + .alert(class=alertClass) + p= message + +mixin alert-dismissible(message, type) + - var alertClass = type ? "alert alert-" + type : "alert alert-info" + .alert.alert-dismissible(class=alertClass) + p= message + button.alert-close(type="button" aria-label="Close") x diff --git a/examples/demo/views/mixins/buttons.pug b/examples/demo/views/mixins/buttons.pug index e96740f..9078166 100644 --- a/examples/demo/views/mixins/buttons.pug +++ b/examples/demo/views/mixins/buttons.pug @@ -1,5 +1,15 @@ -mixin btn(text, type="primary") - button(class="btn btn-" + type)= text +//- Button mixins with various styles -mixin btn-link(href, text) - a.btn.btn-link(href=href)= text +mixin btn(text, type) + - var btnClass = type ? "btn btn-" + type : "btn btn-primary" + button(class=btnClass)= text + +mixin btn-link(href, text, type) + - var btnClass = type ? "btn btn-" + type : "btn btn-primary" + a(href=href class=btnClass)= text + +mixin btn-icon(icon, text, type) + - var btnClass = type ? "btn btn-" + type : "btn btn-primary" + button(class=btnClass) + span.icon= icon + span= text diff --git a/examples/demo/views/mixins/cards.pug b/examples/demo/views/mixins/cards.pug deleted file mode 100644 index 9845b64..0000000 --- a/examples/demo/views/mixins/cards.pug +++ /dev/null @@ -1,11 +0,0 @@ -mixin card(title) - .card - .card-header - h3= title - .card-body - block - -mixin card-simple(title, body) - .card - h3= title - p= body diff --git a/examples/demo/views/mixins/cart-item.pug b/examples/demo/views/mixins/cart-item.pug new file mode 100644 index 0000000..9ad530a --- /dev/null +++ b/examples/demo/views/mixins/cart-item.pug @@ -0,0 +1,17 @@ +//- Cart item display + +mixin cart-item(item) + .cart-item + .cart-item-image + img(src=item.image alt=item.name) + .cart-item-details + h4.cart-item-name #{item.name} + p.cart-item-variant #{item.variant} + span.cart-item-price $#{item.price} + .cart-item-quantity + button.qty-btn.qty-minus - + input.qty-input(type="number" value=item.quantity min="1") + button.qty-btn.qty-plus + + .cart-item-total + span $#{item.total} + button.cart-item-remove(aria-label="Remove item") x diff --git a/examples/demo/views/mixins/forms.pug b/examples/demo/views/mixins/forms.pug new file mode 100644 index 0000000..0b7418a --- /dev/null +++ b/examples/demo/views/mixins/forms.pug @@ -0,0 +1,25 @@ +//- Form input mixins + +mixin input(name, label, type, placeholder) + .form-group + label(for=name)= label + input.form-control(type=type id=name name=name placeholder=placeholder) + +mixin input-required(name, label, type, placeholder) + .form-group + label(for=name) + = label + span.required * + input.form-control(type=type id=name name=name placeholder=placeholder required) + +mixin select(name, label, options) + .form-group + label(for=name)= label + select.form-control(id=name name=name) + each opt in options + option(value=opt.value)= opt.label + +mixin textarea(name, label, placeholder, rows) + .form-group + label(for=name)= label + textarea.form-control(id=name name=name placeholder=placeholder rows=rows) diff --git a/examples/demo/views/mixins/input_text.pug b/examples/demo/views/mixins/input_text.pug deleted file mode 100644 index a38ab3a..0000000 --- a/examples/demo/views/mixins/input_text.pug +++ /dev/null @@ -1,4 +0,0 @@ -mixin input_text(name, label, placeholder) - fieldset.fieldset - legend.fieldset-legend= label - input(type="text" name=name class="input" placeholder=placeholder) diff --git a/examples/demo/views/mixins/product-card.pug b/examples/demo/views/mixins/product-card.pug new file mode 100644 index 0000000..c92d349 --- /dev/null +++ b/examples/demo/views/mixins/product-card.pug @@ -0,0 +1,38 @@ +//- Product card mixin - displays a product in grid/list view +//- Parameters: +//- product: { id, name, price, image, rating, category } + +mixin product-card(product) + article.product-card + a.product-image(href="/products/" + product.id) + img(src=product.image alt=product.name) + if product.sale + span.badge.badge-sale Sale + .product-info + span.product-category #{product.category} + h3.product-name + a(href="/products/" + product.id) #{product.name} + .product-rating + +rating(product.rating) + .product-footer + span.product-price $#{product.price} + button.btn.btn-primary.btn-sm(data-product=product.id) Add to Cart + +//- Featured product card with larger display +mixin product-featured(product) + article.product-card.product-featured + .product-image-large + img(src=product.image alt=product.name) + if product.sale + span.badge.badge-sale Sale + .product-details + span.product-category #{product.category} + h2.product-name #{product.name} + p.product-description #{product.description} + .product-rating + +rating(product.rating) + span.review-count (#{product.reviewCount} reviews) + .product-price-large $#{product.price} + .product-actions + button.btn.btn-primary.btn-lg Add to Cart + button.btn.btn-outline Wishlist diff --git a/examples/demo/views/mixins/rating.pug b/examples/demo/views/mixins/rating.pug new file mode 100644 index 0000000..2b221cf --- /dev/null +++ b/examples/demo/views/mixins/rating.pug @@ -0,0 +1,13 @@ +//- Star rating display +//- Parameters: +//- stars: number of stars (1-5) + +mixin rating(stars) + .stars + - var i = 1 + while i <= 5 + if i <= stars + span.star.star-filled + else + span.star.star-empty + - i = i + 1 diff --git a/examples/demo/views/page-a.pug b/examples/demo/views/page-a.pug deleted file mode 100644 index 4d37b66..0000000 --- a/examples/demo/views/page-a.pug +++ /dev/null @@ -1,36 +0,0 @@ -extends layout.pug - -block scripts - script(src='/jquery.js') - script(src='/pets.js') - -block content - h1= title - p Welcome to the pets page! - ul - li Cat - li Dog - ul - each val in items - li= val - input(data-json=` - { - "very-long": "piece of ", - "data": true - } - `) - - br - div(class='div-class', (click)='play()') one - div(class='div-class' '(click)'='play()') two - a(style={color: 'red', background: 'green'}) sdfsdfs - a.button btn - br - - form(method="post") - +input_text("firstName", "First Name", "first name") - br - +input_text("lastName", "Last Name", "last name") - submit sumit - if error - +alert_error(error) diff --git a/examples/demo/views/page-appen-optional-blk.pug b/examples/demo/views/page-appen-optional-blk.pug deleted file mode 100644 index 02efb8f..0000000 --- a/examples/demo/views/page-appen-optional-blk.pug +++ /dev/null @@ -1,5 +0,0 @@ -extends layout - -append head - script(src='/vendor/three.js') - script(src='/game.js') diff --git a/examples/demo/views/page-append.pug b/examples/demo/views/page-append.pug deleted file mode 100644 index 1922415..0000000 --- a/examples/demo/views/page-append.pug +++ /dev/null @@ -1,11 +0,0 @@ -extends layout-2.pug - -block append head - script(src='/vendor/three.js') - script(src='/game.js') - -block content - p - | cheks manually the head section - br - | hello there diff --git a/examples/demo/views/page-b.pug b/examples/demo/views/page-b.pug deleted file mode 100644 index e54b05f..0000000 --- a/examples/demo/views/page-b.pug +++ /dev/null @@ -1,9 +0,0 @@ -extends sub-layout.pug - -block content - .sidebar - block sidebar - p nothing - .primary - block primary - p nothing diff --git a/examples/demo/views/pages/404.pug b/examples/demo/views/pages/404.pug new file mode 100644 index 0000000..c23a91f --- /dev/null +++ b/examples/demo/views/pages/404.pug @@ -0,0 +1,15 @@ +extends layouts/base.pug + +block title + title #{title} | Pugz Store + +block content + section.error-page + .container + .error-content + h1.error-code 404 + h2 Page Not Found + p The page you are looking for does not exist or has been moved. + .error-actions + a.btn.btn-primary(href="/") Go Home + a.btn.btn-outline(href="/products") View Products diff --git a/examples/demo/views/pages/about.pug b/examples/demo/views/pages/about.pug new file mode 100644 index 0000000..f02e18d --- /dev/null +++ b/examples/demo/views/pages/about.pug @@ -0,0 +1,51 @@ +extends layouts/base.pug + +block title + title #{title} | Pugz Store + +block content + section.page-header + .container + h1 About Pugz + p A Pug template engine written in Zig + + section.section + .container + .about-grid + .about-main + h2 What is Pugz? + p Pugz is a high-performance Pug template engine implemented in Zig. It provides both runtime interpretation and build-time compilation for maximum flexibility. + + h3 Key Features + ul.feature-list + li Template inheritance with extends and blocks + li Partial includes for modular templates + li Mixins for reusable components + li Conditionals (if/else/unless) + li Iteration with each loops + li Variable interpolation + li Pretty-printed output + li LRU caching with TTL + + h3 Performance + p Compiled templates run approximately 3x faster than Pug.js, with zero runtime parsing overhead. + + .about-sidebar + .info-card + h3 This Demo Shows + ul + li Template inheritance (extends) + li Named blocks + li Conditional rendering + li Variable interpolation + li Simple iteration + + .info-card + h3 Links + ul + li + a(href="https://github.com/ankitpatial/pugz") GitHub Repository + li + a(href="/products") View Products + li + a(href="/") Back to Home diff --git a/examples/demo/views/pages/cart.pug b/examples/demo/views/pages/cart.pug new file mode 100644 index 0000000..df015e3 --- /dev/null +++ b/examples/demo/views/pages/cart.pug @@ -0,0 +1,47 @@ +extends layouts/base.pug + +block title + title #{title} | Pugz Store + +block content + section.page-header + .container + h1 Shopping Cart + p Review your items before checkout + + section.section + .container + .cart-layout + .cart-main + .cart-items + each item in cartItems + .cart-item + .cart-item-info + h3 #{name} + p.text-muted #{variant} + span.cart-item-price $#{price} + .cart-item-qty + button.qty-btn - + input.qty-input(type="text" value=quantity) + button.qty-btn + + .cart-item-total $#{total} + button.cart-item-remove x + + .cart-actions + a.btn.btn-outline(href="/products") Continue Shopping + + .cart-summary + h3 Order Summary + .summary-row + span Subtotal + span $#{subtotal} + .summary-row + span Shipping + span.text-success Free + .summary-row + span Tax + span $#{tax} + .summary-row.summary-total + span Total + span $#{total} + a.btn.btn-primary.btn-block(href="/checkout") Proceed to Checkout diff --git a/examples/demo/views/pages/checkout.pug b/examples/demo/views/pages/checkout.pug new file mode 100644 index 0000000..bd05e94 --- /dev/null +++ b/examples/demo/views/pages/checkout.pug @@ -0,0 +1,144 @@ +extends layouts/base.pug +include mixins/forms.pug +include mixins/alerts.pug +include mixins/buttons.pug + +block content + h1 Checkout + + if errors + +alert("Please correct the errors below", "error") + + .checkout-layout + form.checkout-form(action="/checkout" method="POST") + //- Shipping Information + section.checkout-section + h2 Shipping Information + + .form-row + +input-required("firstName", "First Name", "text", "John") + +input-required("lastName", "Last Name", "text", "Doe") + + +input-required("email", "Email Address", "email", "john@example.com") + +input-required("phone", "Phone Number", "tel", "+1 (555) 123-4567") + + +input-required("address", "Street Address", "text", "123 Main St") + +input("address2", "Apartment, suite, etc.", "text", "Apt 4B") + + .form-row + +input-required("city", "City", "text", "New York") + .form-group + label(for="state") + | State + span.required * + select.form-control#state(name="state" required) + option(value="") Select State + each state in states + option(value=state.code)= state.name + +input-required("zip", "ZIP Code", "text", "10001") + + .form-group + label(for="country") + | Country + span.required * + select.form-control#country(name="country" required) + option(value="US" selected) United States + option(value="CA") Canada + + //- Shipping Method + section.checkout-section + h2 Shipping Method + + .shipping-options + each method in shippingMethods + label.shipping-option + input(type="radio" name="shipping" value=method.id checked=method.id == "standard") + .shipping-info + span.shipping-name #{method.name} + span.shipping-time #{method.time} + span.shipping-price + if method.price > 0 + | $#{method.price} + else + | Free + + //- Payment Information + section.checkout-section + h2 Payment Information + + .payment-methods-select + label.payment-method + input(type="radio" name="paymentMethod" value="card" checked) + span Credit/Debit Card + label.payment-method + input(type="radio" name="paymentMethod" value="paypal") + span PayPal + + .card-details(id="card-details") + +input-required("cardNumber", "Card Number", "text", "1234 5678 9012 3456") + + .form-row + +input-required("expiry", "Expiration Date", "text", "MM/YY") + +input-required("cvv", "CVV", "text", "123") + + +input-required("cardName", "Name on Card", "text", "John Doe") + + .form-group + label.checkbox-label + input(type="checkbox" name="saveCard") + span Save card for future purchases + + //- Billing Address + section.checkout-section + .form-group + label.checkbox-label + input(type="checkbox" name="sameAsShipping" checked) + span Billing address same as shipping + + .billing-address(id="billing-address" style="display: none") + +input-required("billingAddress", "Street Address", "text", "") + .form-row + +input-required("billingCity", "City", "text", "") + +input-required("billingState", "State", "text", "") + +input-required("billingZip", "ZIP Code", "text", "") + + button.btn.btn-primary.btn-lg(type="submit") Place Order + + //- Order Summary Sidebar + aside.order-summary + h3 Order Summary + + .summary-items + each item in cart.items + .summary-item + img(src=item.image alt=item.name) + .item-info + span.item-name #{item.name} + span.item-qty x#{item.quantity} + span.item-price $#{item.total} + + .summary-details + .summary-row + span Subtotal + span $#{cart.subtotal} + + if cart.discount + .summary-row.discount + span Discount + span -$#{cart.discount} + + .summary-row + span Shipping + span#shipping-cost $#{selectedShipping.price} + + .summary-row + span Tax + span $#{cart.tax} + + .summary-row.total + span Total + span $#{cart.total} + + .secure-checkout + span Secure Checkout + p Your information is protected with 256-bit SSL encryption diff --git a/examples/demo/views/pages/home.pug b/examples/demo/views/pages/home.pug new file mode 100644 index 0000000..fd2f506 --- /dev/null +++ b/examples/demo/views/pages/home.pug @@ -0,0 +1,56 @@ +extends layouts/base.pug + +block title + title #{title} | Pugz Store + +block content + section.hero + .container + h1 Welcome to Pugz Store + p Discover amazing products powered by Zig + .hero-actions + a.btn.btn-primary(href="/products") Shop Now + a.btn.btn-outline(href="/about") Learn More + + section.section + .container + h2 Template Features + .feature-grid + .feature-card + h3 Conditionals + if authenticated + p.text-success You are logged in! + else + p.text-muted Please log in to continue. + + .feature-card + h3 Variables + p Title: #{title} + p Cart Items: #{cartCount} + + .feature-card + h3 Iteration + ul + each item in items + li= item + + .feature-card + h3 Clean Syntax + p Pug templates compile to HTML with minimal overhead. + + section.section.section-alt + .container + h2 Shop by Category + .category-grid + a.category-card(href="/products?cat=electronics") + .category-icon E + h3 Electronics + span 24 products + a.category-card(href="/products?cat=accessories") + .category-icon A + h3 Accessories + span 18 products + a.category-card(href="/products?cat=home") + .category-icon H + h3 Home Office + span 12 products diff --git a/examples/demo/views/pages/product-detail.pug b/examples/demo/views/pages/product-detail.pug new file mode 100644 index 0000000..f8f28ca --- /dev/null +++ b/examples/demo/views/pages/product-detail.pug @@ -0,0 +1,65 @@ +extends layouts/base.pug + +block title + title #{productName} | Pugz Store + +block content + section.page-header + .container + .breadcrumb + a(href="/") Home + span / + a(href="/products") Products + span / + span #{productName} + + section.section + .container + .product-detail + .product-detail-image + .product-image-placeholder + .product-detail-info + span.product-category #{category} + h1 #{productName} + .product-price-large $#{price} + p.product-description #{description} + + .product-actions + .quantity-selector + label Quantity: + button.qty-btn - + input.qty-input(type="text" value="1") + button.qty-btn + + a.btn.btn-primary.btn-lg(href="/cart") Add to Cart + + .product-meta + p SKU: #{sku} + p Category: #{category} + + section.section.section-alt + .container + h2 You May Also Like + .product-grid + .product-card + .product-image + .product-info + span.product-category Electronics + h3.product-name Smart Watch Pro + .product-price $199.99 + a.btn.btn-sm(href="/products/2") View Details + + .product-card + .product-image + .product-info + span.product-category Accessories + h3.product-name Laptop Stand + .product-price $49.99 + a.btn.btn-sm(href="/products/3") View Details + + .product-card + .product-image + .product-info + span.product-category Accessories + h3.product-name USB-C Hub + .product-price $39.99 + a.btn.btn-sm(href="/products/4") View Details diff --git a/examples/demo/views/pages/products.pug b/examples/demo/views/pages/products.pug new file mode 100644 index 0000000..5c5d414 --- /dev/null +++ b/examples/demo/views/pages/products.pug @@ -0,0 +1,79 @@ +extends layouts/base.pug + +block title + title #{title} | Pugz Store + +block content + section.page-header + .container + h1 All Products + p Browse our selection of quality products + + section.section + .container + .products-toolbar + span.results-count #{productCount} products + .sort-options + label Sort by: + select + option(value="featured") Featured + option(value="price-low") Price: Low to High + option(value="price-high") Price: High to Low + + .product-grid + .product-card + .product-image + .product-badge Sale + .product-info + span.product-category Electronics + h3.product-name Wireless Headphones + .product-price $79.99 + a.btn.btn-sm(href="/products/1") View Details + + .product-card + .product-image + .product-info + span.product-category Electronics + h3.product-name Smart Watch Pro + .product-price $199.99 + a.btn.btn-sm(href="/products/2") View Details + + .product-card + .product-image + .product-info + span.product-category Accessories + h3.product-name Laptop Stand + .product-price $49.99 + a.btn.btn-sm(href="/products/3") View Details + + .product-card + .product-image + .product-badge Sale + .product-info + span.product-category Accessories + h3.product-name USB-C Hub + .product-price $39.99 + a.btn.btn-sm(href="/products/4") View Details + + .product-card + .product-image + .product-info + span.product-category Electronics + h3.product-name Mechanical Keyboard + .product-price $129.99 + a.btn.btn-sm(href="/products/5") View Details + + .product-card + .product-image + .product-info + span.product-category Home Office + h3.product-name Desk Lamp + .product-price $34.99 + a.btn.btn-sm(href="/products/6") View Details + + .pagination + a.page-link(href="#") Prev + a.page-link.active(href="#") 1 + a.page-link(href="#") 2 + a.page-link(href="#") 3 + a.page-link(href="#") Next diff --git a/examples/demo/views/partials/footer.pug b/examples/demo/views/partials/footer.pug new file mode 100644 index 0000000..0436aa2 --- /dev/null +++ b/examples/demo/views/partials/footer.pug @@ -0,0 +1,4 @@ +footer.footer + .container + .footer-content + p Built with Pugz - A Pug template engine for Zig diff --git a/examples/demo/views/partials/head.pug b/examples/demo/views/partials/head.pug new file mode 100644 index 0000000..1849047 --- /dev/null +++ b/examples/demo/views/partials/head.pug @@ -0,0 +1,3 @@ +meta(charset="UTF-8") +meta(name="viewport" content="width=device-width, initial-scale=1.0") +link(rel="stylesheet" href="/css/style.css") diff --git a/examples/demo/views/partials/header.pug b/examples/demo/views/partials/header.pug new file mode 100644 index 0000000..5bd1f48 --- /dev/null +++ b/examples/demo/views/partials/header.pug @@ -0,0 +1,11 @@ +header.header + .container + .header-content + a.logo(href="/") Pugz Store + nav.nav + a.nav-link(href="/") Home + a.nav-link(href="/products") Products + a.nav-link(href="/about") About + .header-actions + a.cart-link(href="/cart") + | Cart (#{cartCount}) diff --git a/examples/demo/views/pet.pug b/examples/demo/views/pet.pug deleted file mode 100644 index 3025d57..0000000 --- a/examples/demo/views/pet.pug +++ /dev/null @@ -1 +0,0 @@ -p= petName diff --git a/examples/demo/views/sub-layout.pug b/examples/demo/views/sub-layout.pug deleted file mode 100644 index ecdfcd7..0000000 --- a/examples/demo/views/sub-layout.pug +++ /dev/null @@ -1,9 +0,0 @@ -extends layout.pug - -block content - .sidebar - block sidebar - p nothing - .primary - block primary - p nothing diff --git a/examples/demo/views/users.pug b/examples/demo/views/users.pug deleted file mode 100644 index 0c51337..0000000 --- a/examples/demo/views/users.pug +++ /dev/null @@ -1,11 +0,0 @@ -doctype html -html - head - title Users - body - h1 User List - ul.user-list - each user in users - li.user - strong= user.name - span.email= user.email diff --git a/src/benchmarks/bench.zig b/src/benchmarks/bench.zig index b6fd688..8dd1320 100644 --- a/src/benchmarks/bench.zig +++ b/src/benchmarks/bench.zig @@ -1,8 +1,9 @@ //! Pugz Benchmark - Template Rendering //! -//! This benchmark uses template.zig renderWithData function. +//! This benchmark parses templates ONCE, then renders 2000 times. +//! This matches how Pug.js benchmark works (compile once, render many). //! -//! Run: zig build bench-v1 +//! Run: zig build bench const std = @import("std"); const pugz = @import("pugz"); @@ -59,12 +60,12 @@ pub fn main() !void { std.debug.print("\n", .{}); std.debug.print("╔═══════════════════════════════════════════════════════════════╗\n", .{}); - std.debug.print("║ V1 Template Benchmark ({d} iterations) ║\n", .{iterations}); + std.debug.print("║ Pugz Benchmark ({d} iterations, parse once) ║\n", .{iterations}); std.debug.print("║ Templates: {s}/*.pug ║\n", .{templates_dir}); std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{}); // Load JSON data - std.debug.print("\nLoading JSON data...\n", .{}); + std.debug.print("\nLoading JSON data and parsing templates...\n", .{}); var data_arena = std.heap.ArenaAllocator.init(allocator); defer data_arena.deinit(); @@ -95,7 +96,7 @@ pub fn main() !void { const search = try loadJson(struct { searchRecords: []const SearchRecord }, data_alloc, "search-results.json"); const friends_data = try loadJson(struct { friends: []const Friend }, data_alloc, "friends.json"); - // Load template sources + // Load and PARSE templates ONCE (like Pug.js compiles once) const simple0_tpl = try loadTemplate(data_alloc, "simple-0.pug"); const simple1_tpl = try loadTemplate(data_alloc, "simple-1.pug"); const simple2_tpl = try loadTemplate(data_alloc, "simple-2.pug"); @@ -104,17 +105,26 @@ pub fn main() !void { const search_tpl = try loadTemplate(data_alloc, "search-results.pug"); const friends_tpl = try loadTemplate(data_alloc, "friends.pug"); - std.debug.print("Loaded. Starting benchmark...\n\n", .{}); + // Parse templates once + const simple0_ast = try pugz.template.parse(data_alloc, simple0_tpl); + const simple1_ast = try pugz.template.parse(data_alloc, simple1_tpl); + const simple2_ast = try pugz.template.parse(data_alloc, simple2_tpl); + const if_expr_ast = try pugz.template.parse(data_alloc, if_expr_tpl); + const projects_ast = try pugz.template.parse(data_alloc, projects_tpl); + const search_ast = try pugz.template.parse(data_alloc, search_tpl); + const friends_ast = try pugz.template.parse(data_alloc, friends_tpl); + + std.debug.print("Loaded. Starting benchmark (render only)...\n\n", .{}); var total: f64 = 0; - total += try bench("simple-0", allocator, simple0_tpl, simple0); - total += try bench("simple-1", allocator, simple1_tpl, simple1); - total += try bench("simple-2", allocator, simple2_tpl, simple2); - total += try bench("if-expression", allocator, if_expr_tpl, if_expr); - total += try bench("projects-escaped", allocator, projects_tpl, projects); - total += try bench("search-results", allocator, search_tpl, search); - total += try bench("friends", allocator, friends_tpl, friends_data); + total += try bench("simple-0", allocator, simple0_ast, simple0); + total += try bench("simple-1", allocator, simple1_ast, simple1); + total += try bench("simple-2", allocator, simple2_ast, simple2); + total += try bench("if-expression", allocator, if_expr_ast, if_expr); + total += try bench("projects-escaped", allocator, projects_ast, projects); + total += try bench("search-results", allocator, search_ast, search); + total += try bench("friends", allocator, friends_ast, friends_data); std.debug.print("\n", .{}); std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ "TOTAL", total }); @@ -136,7 +146,7 @@ fn loadTemplate(alloc: std.mem.Allocator, comptime filename: []const u8) ![]cons fn bench( name: []const u8, allocator: std.mem.Allocator, - template: []const u8, + ast: *pugz.parser.Node, data: anytype, ) !f64 { var arena = std.heap.ArenaAllocator.init(allocator); @@ -145,7 +155,7 @@ fn bench( var timer = try std.time.Timer.start(); for (0..iterations) |_| { _ = arena.reset(.retain_capacity); - _ = pugz.template.renderWithData(arena.allocator(), template, data) catch |err| { + _ = pugz.template.renderAst(arena.allocator(), ast, data) catch |err| { std.debug.print(" {s:<20} => ERROR: {}\n", .{ name, err }); return 0; }; diff --git a/src/lexer.zig b/src/lexer.zig index 68d8f07..31a4523 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -335,6 +335,16 @@ pub const Lexer = struct { const IndentType = enum { tabs, spaces }; + /// Get current indent level (top of stack) - O(1) + inline fn currentIndent(self: *const Lexer) usize { + return self.indent_stack.items[self.indent_stack.items.len - 1]; + } + + /// Get previous indent level (second from top) - O(1) + inline fn previousIndent(self: *const Lexer) usize { + return self.indent_stack.items[self.indent_stack.items.len - 2]; + } + pub fn init(allocator: Allocator, str: []const u8, options: LexerOptions) !Lexer { // Strip UTF-8 BOM if present var input = str; @@ -391,6 +401,16 @@ pub const Lexer = struct { } } + /// Deinit without freeing input_allocated - caller takes ownership of it + /// Returns the input_allocated slice that caller must free + pub fn deinitKeepInput(self: *Lexer) []const u8 { + self.indent_stack.deinit(self.allocator); + self.tokens.deinit(self.allocator); + const input = self.input_allocated; + self.input_allocated = &.{}; // Clear so regular deinit won't double-free + return input; + } + // ======================================================================== // Error handling // ======================================================================== @@ -634,9 +654,9 @@ pub const Lexer = struct { return false; } - // Add outdent tokens for remaining indentation - var i: usize = 0; - while (i < self.indent_stack.items.len and self.indent_stack.items[i] > 0) : (i += 1) { + // Add outdent tokens for remaining indentation (pop from stack end) + while (self.indent_stack.items.len > 1 and self.currentIndent() > 0) { + _ = self.indent_stack.pop(); var outdent_tok = self.tok(.outdent, .none); self.tokEnd(&outdent_tok); self.tokens.append(self.allocator, outdent_tok) catch return false; @@ -2211,34 +2231,34 @@ pub const Lexer = struct { } // Outdent - if (indents < self.indent_stack.items[0]) { + if (indents < self.currentIndent()) { var outdent_count: usize = 0; - while (self.indent_stack.items[0] > indents) { - if (self.indent_stack.items.len > 1 and self.indent_stack.items[1] < indents) { + while (self.currentIndent() > indents) { + if (self.indent_stack.items.len > 1 and self.previousIndent() < indents) { self.setError(.INCONSISTENT_INDENTATION, "Inconsistent indentation"); return false; } outdent_count += 1; - _ = self.indent_stack.orderedRemove(0); + _ = self.indent_stack.pop(); // O(1) instead of O(n) } while (outdent_count > 0) : (outdent_count -= 1) { self.colno = 1; var outdent_token = self.tok(.outdent, .none); - self.colno = self.indent_stack.items[0] + 1; + self.colno = self.currentIndent() + 1; self.tokens.append(self.allocator, outdent_token) catch return false; self.tokEnd(&outdent_token); } - } else if (indents > 0 and indents != self.indent_stack.items[0]) { + } else if (indents > 0 and indents != self.currentIndent()) { // Indent var indent_token = self.tok(.indent, .none); self.colno = 1 + indents; self.tokens.append(self.allocator, indent_token) catch return false; self.tokEnd(&indent_token); - self.indent_stack.insert(self.allocator, 0, indents) catch return false; + self.indent_stack.append(self.allocator, indents) catch return false; // O(1) instead of O(n) } else { // Newline var newline_token = self.tok(.newline, .none); - self.colno = 1 + @min(self.indent_stack.items[0], indents); + self.colno = 1 + @min(self.currentIndent(), indents); self.tokens.append(self.allocator, newline_token) catch return false; self.tokEnd(&newline_token); } @@ -2253,7 +2273,7 @@ pub const Lexer = struct { const captures = self.scanIndentation() orelse return false; const indents = forced_indents orelse captures.indent.len; - if (indents <= self.indent_stack.items[0]) return false; + if (indents <= self.currentIndent()) return false; var start_token = self.tok(.start_pipeless_text, .none); self.tokEnd(&start_token); @@ -2307,7 +2327,7 @@ pub const Lexer = struct { else ""; tokens_list.append(self.allocator, text_content) catch return false; - } else if (line_indent > self.indent_stack.items[0]) { + } else if (line_indent > self.currentIndent()) { // line is indented less than the first line but is still indented // need to retry lexing the text block with new indent level _ = self.tokens.pop(); diff --git a/src/load.zig b/src/load.zig index 48cd449..ddd3c9a 100644 --- a/src/load.zig +++ b/src/load.zig @@ -98,6 +98,7 @@ pub const LoadError = error{ ParseError, WalkError, InvalidUtf8, + PathEscapesRoot, }; // ============================================================================ @@ -121,7 +122,33 @@ pub const LoadResult = struct { // Default Implementations // ============================================================================ +/// Check if path is safe (doesn't escape root via .. or other tricks) +/// Returns false if path would escape the root directory. +pub fn isPathSafe(path: []const u8) bool { + // Reject absolute paths + if (path.len > 0 and path[0] == '/') { + return false; + } + + var depth: i32 = 0; + var iter = mem.splitScalar(u8, path, '/'); + + while (iter.next()) |component| { + if (component.len == 0 or mem.eql(u8, component, ".")) { + continue; + } + if (mem.eql(u8, component, "..")) { + depth -= 1; + if (depth < 0) return false; // Escaped root + } else { + depth += 1; + } + } + return true; +} + /// Default path resolution - handles relative and absolute paths +/// Rejects paths that would escape the base directory. pub fn defaultResolve( filename: []const u8, source: ?[]const u8, @@ -133,6 +160,11 @@ pub fn defaultResolve( return error.InvalidPath; } + // Security: reject paths that escape root + if (!isPathSafe(trimmed)) { + return error.PathEscapesRoot; + } + // Absolute path (starts with /) if (trimmed[0] == '/') { if (options.basedir == null) { @@ -369,10 +401,11 @@ test "pathJoin - absolute paths" { try std.testing.expectEqualStrings("/absolute/path.pug", result); } -test "defaultResolve - missing basedir for absolute path" { +test "defaultResolve - rejects absolute paths as path escape" { const options = LoadOptions{}; const result = defaultResolve("/absolute/path.pug", null, &options); - try std.testing.expectError(error.MissingBasedir, result); + // Absolute paths are rejected as path escape (security boundary) + try std.testing.expectError(error.PathEscapesRoot, result); } test "defaultResolve - missing filename for relative path" { diff --git a/src/root.zig b/src/root.zig index ec6062e..0fae615 100644 --- a/src/root.zig +++ b/src/root.zig @@ -8,6 +8,7 @@ pub const pug = @import("pug.zig"); pub const view_engine = @import("view_engine.zig"); pub const template = @import("template.zig"); +pub const parser = @import("parser.zig"); // Re-export main types pub const ViewEngine = view_engine.ViewEngine; diff --git a/src/runtime.zig b/src/runtime.zig index 9f9d931..ac67d68 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -89,66 +89,60 @@ pub const AttrValue = union(enum) { /// Returns empty string for false/null values. /// For true values, returns terse form " key" or full form " key="key"". pub fn attr(allocator: Allocator, key: []const u8, val: AttrValue, escaped: bool, terse: bool) ![]const u8 { + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + try appendAttr(allocator, &result, key, val, escaped, terse); + if (result.items.len == 0) { + return ""; + } + return try result.toOwnedSlice(allocator); +} + +/// Append attribute directly to output buffer - avoids intermediate allocations +/// This is the preferred method for rendering attributes in hot paths +pub fn appendAttr(allocator: Allocator, output: *ArrayListUnmanaged(u8), key: []const u8, val: AttrValue, escaped: bool, terse: bool) !void { switch (val) { - .none => return try allocator.dupe(u8, ""), + .none => return, .boolean => |b| { - if (!b) return try allocator.dupe(u8, ""); + if (!b) return; // true value - if (terse) { - var result: ArrayListUnmanaged(u8) = .{}; - errdefer result.deinit(allocator); - try result.append(allocator, ' '); - try result.appendSlice(allocator, key); - return try result.toOwnedSlice(allocator); - } else { - var result: ArrayListUnmanaged(u8) = .{}; - errdefer result.deinit(allocator); - try result.append(allocator, ' '); - try result.appendSlice(allocator, key); - try result.appendSlice(allocator, "=\""); - try result.appendSlice(allocator, key); - try result.append(allocator, '"'); - return try result.toOwnedSlice(allocator); + try output.append(allocator, ' '); + try output.appendSlice(allocator, key); + if (!terse) { + try output.appendSlice(allocator, "=\""); + try output.appendSlice(allocator, key); + try output.append(allocator, '"'); } }, .number => |n| { - var result: ArrayListUnmanaged(u8) = .{}; - errdefer result.deinit(allocator); - try result.append(allocator, ' '); - try result.appendSlice(allocator, key); - try result.appendSlice(allocator, "=\""); + try output.append(allocator, ' '); + try output.appendSlice(allocator, key); + try output.appendSlice(allocator, "=\""); - // Format number + // Format number directly to buffer var buf: [32]u8 = undefined; - const num_str = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return error.FormatError; - try result.appendSlice(allocator, num_str); + const num_str = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return; + try output.appendSlice(allocator, num_str); - try result.append(allocator, '"'); - return try result.toOwnedSlice(allocator); + try output.append(allocator, '"'); }, .string => |s| { - // Empty class or style returns empty + // Skip empty class or style if (s.len == 0 and (mem.eql(u8, key, "class") or mem.eql(u8, key, "style"))) { - return try allocator.dupe(u8, ""); + return; } - var result: ArrayListUnmanaged(u8) = .{}; - errdefer result.deinit(allocator); - - try result.append(allocator, ' '); - try result.appendSlice(allocator, key); - try result.appendSlice(allocator, "=\""); + try output.append(allocator, ' '); + try output.appendSlice(allocator, key); + try output.appendSlice(allocator, "=\""); if (escaped) { - const escaped_val = try escape(allocator, s); - defer allocator.free(escaped_val); - try result.appendSlice(allocator, escaped_val); + try appendEscaped(allocator, output, s); } else { - try result.appendSlice(allocator, s); + try output.appendSlice(allocator, s); } - try result.append(allocator, '"'); - return try result.toOwnedSlice(allocator); + try output.append(allocator, '"'); }, } } diff --git a/src/template.zig b/src/template.zig index a9d2983..b7a50ed 100644 --- a/src/template.zig +++ b/src/template.zig @@ -10,6 +10,8 @@ const pug = @import("pug.zig"); const parser = @import("parser.zig"); const Node = parser.Node; const runtime = @import("runtime.zig"); +const mixin_mod = @import("mixin.zig"); +pub const MixinRegistry = mixin_mod.MixinRegistry; pub const TemplateError = error{ OutOfMemory, @@ -17,10 +19,40 @@ pub const TemplateError = error{ ParserError, }; -/// Render context tracks state like doctype mode +/// Result of parsing - contains AST and the normalized source that AST slices point to +pub const ParseResult = struct { + ast: *Node, + /// Normalized source - AST strings are slices into this, must stay alive while AST is used + normalized_source: []const u8, + + pub fn deinit(self: *ParseResult, allocator: Allocator) void { + self.ast.deinit(allocator); + allocator.destroy(self.ast); + allocator.free(self.normalized_source); + } +}; + +/// Render context tracks state like doctype mode and mixin registry pub const RenderContext = struct { /// true = HTML5 terse mode (default), false = XHTML mode terse: bool = true, + /// Mixin registry for expanding mixin calls (optional) + mixins: ?*const MixinRegistry = null, + /// Current mixin argument bindings (for substitution during mixin expansion) + arg_bindings: ?*const std.StringHashMapUnmanaged([]const u8) = null, + /// Block content passed to current mixin call (for `block` keyword) + mixin_block: ?*Node = null, + /// Enable pretty-printing with indentation and newlines + pretty: bool = false, + /// Current indentation level (for pretty printing) + indent_level: u32 = 0, + + /// Create a child context with incremented indent level + fn indented(self: RenderContext) RenderContext { + var child = self; + child.indent_level += 1; + return child; + } }; /// Render a template with data @@ -36,10 +68,10 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) ! defer stripped.deinit(allocator); // Parse - var parse = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source); - defer parse.deinit(); + var pug_parser = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source); + defer pug_parser.deinit(); - const ast = parse.parse() catch { + const ast = pug_parser.parse() catch { return error.ParserError; }; defer { @@ -47,7 +79,12 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) ! allocator.destroy(ast); } - // Render with data + return renderAst(allocator, ast, data); +} + +/// Render a pre-parsed AST with data. Use this for better performance when +/// rendering the same template multiple times - parse once, render many. +pub fn renderAst(allocator: Allocator, ast: *Node, data: anytype) ![]const u8 { var output = std.ArrayListUnmanaged(u8){}; errdefer output.deinit(allocator); @@ -60,6 +97,78 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) ! return output.toOwnedSlice(allocator); } +/// Render options for AST rendering +pub const RenderOptions = struct { + pretty: bool = false, +}; + +/// Render a pre-parsed AST with data and mixin registry. +/// Use this when templates include mixin definitions from other files. +pub fn renderAstWithMixins(allocator: Allocator, ast: *Node, data: anytype, registry: *const MixinRegistry) ![]const u8 { + return renderAstWithMixinsAndOptions(allocator, ast, data, registry, .{}); +} + +/// Render a pre-parsed AST with data, mixin registry, and render options. +pub fn renderAstWithMixinsAndOptions(allocator: Allocator, ast: *Node, data: anytype, registry: *const MixinRegistry, options: RenderOptions) ![]const u8 { + var output = std.ArrayListUnmanaged(u8){}; + errdefer output.deinit(allocator); + + // Detect doctype to set terse mode + var ctx = RenderContext{ + .mixins = registry, + .pretty = options.pretty, + }; + detectDoctype(ast, &ctx); + + try renderNode(allocator, &output, ast, data, &ctx); + + return output.toOwnedSlice(allocator); +} + +/// Parse template source into AST. Caller owns the returned AST and must call +/// ast.deinit(allocator) and allocator.destroy(ast) when done. +/// WARNING: The returned AST contains slices into a normalized copy of source. +/// This function frees that copy on return, so AST string values become invalid. +/// Use parseWithSource() instead if you need to access AST string values. +pub fn parse(allocator: Allocator, source: []const u8) !*Node { + const result = try parseWithSource(allocator, source); + // Free the normalized source - AST strings will be invalid after this! + // This maintains backwards compatibility but is unsafe for include paths etc. + allocator.free(result.normalized_source); + return result.ast; +} + +/// Parse template source into AST, returning both AST and the normalized source. +/// AST string values are slices into normalized_source, so it must stay alive. +/// Caller must call result.deinit(allocator) when done. +pub fn parseWithSource(allocator: Allocator, source: []const u8) !ParseResult { + // Lex + var lex = pug.lexer.Lexer.init(allocator, source, .{}) catch return error.OutOfMemory; + errdefer lex.deinit(); + + const tokens = lex.getTokens() catch return error.LexerError; + + // Strip comments + var stripped = pug.strip_comments.stripComments(allocator, tokens, .{}) catch return error.OutOfMemory; + defer stripped.deinit(allocator); + + // Parse + var pug_parser = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source); + defer pug_parser.deinit(); + + const ast = pug_parser.parse() catch { + return error.ParserError; + }; + + // Transfer ownership of normalized input from lexer to caller + const normalized = lex.deinitKeepInput(); + + return ParseResult{ + .ast = ast, + .normalized_source = normalized, + }; +} + /// Scan AST for doctype and set terse mode accordingly fn detectDoctype(node: *Node, ctx: *RenderContext) void { if (node.type == .Doctype) { @@ -86,6 +195,23 @@ fn detectDoctype(node: *Node, ctx: *RenderContext) void { } } +// Tags where whitespace is significant - don't add indentation inside these +const whitespace_sensitive_tags = std.StaticStringMap(void).initComptime(.{ + .{ "pre", {} }, + .{ "textarea", {} }, + .{ "script", {} }, + .{ "style", {} }, + .{ "code", {} }, +}); + +/// Write indentation (two spaces per level) +fn writeIndent(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), level: u32) Allocator.Error!void { + var i: u32 = 0; + while (i < level) : (i += 1) { + try output.appendSlice(allocator, " "); + } +} + fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { switch (node.type) { .Block, .NamedBlock => { @@ -100,11 +226,13 @@ fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: * .BlockComment => try renderBlockComment(allocator, output, node, data, ctx), .Doctype => try renderDoctype(allocator, output, node), .Each => try renderEach(allocator, output, node, data, ctx), - .Mixin => { - // Mixin definitions are skipped (only mixin calls render) - if (!node.call) return; - for (node.nodes.items) |child| { - try renderNode(allocator, output, child, data, ctx); + .Mixin => try renderMixin(allocator, output, node, data, ctx), + .MixinBlock => { + // Render the block content passed to the mixin + if (ctx.mixin_block) |block| { + for (block.nodes.items) |child| { + try renderNode(allocator, output, child, data, ctx); + } } }, else => { @@ -117,19 +245,56 @@ fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: * fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { const name = tag.name orelse "div"; + const is_whitespace_sensitive = whitespace_sensitive_tags.has(name); + + // Check if children are only text/inline content (no block elements) + const has_children = tag.nodes.items.len > 0; + const has_block_children = has_children and hasBlockChildren(tag); + + // Pretty print: add newline and indent before opening tag (except for inline elements) + if (ctx.pretty and !tag.is_inline) { + // Only add newline if we're not at the start of output + if (output.items.len > 0) { + try output.append(allocator, '\n'); + } + try writeIndent(allocator, output, ctx.indent_level); + } try output.appendSlice(allocator, "<"); try output.appendSlice(allocator, name); - // Render attributes using runtime.attr() + // Collect class values separately to merge them into one attribute + var class_parts = std.ArrayListUnmanaged([]const u8){}; + defer class_parts.deinit(allocator); + + // Render attributes directly to output buffer (avoids intermediate allocations) for (tag.attrs.items) |attr| { - const attr_val = try evaluateAttrValue(allocator, attr.val, data); - const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) { - error.FormatError => return error.OutOfMemory, - error.OutOfMemory => return error.OutOfMemory, - }; - defer allocator.free(attr_str); - try output.appendSlice(allocator, attr_str); + // Substitute mixin arguments in attribute value if we're inside a mixin + const final_val = if (ctx.arg_bindings) |bindings| + substituteArgValue(attr.val, bindings) + else + attr.val; + const attr_val = try evaluateAttrValue(allocator, final_val, data); + + // Collect class attributes for merging + if (std.mem.eql(u8, attr.name, "class")) { + switch (attr_val) { + .string => |s| if (s.len > 0) try class_parts.append(allocator, s), + else => {}, + } + } else { + try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse); + } + } + + // Output merged class attribute + if (class_parts.items.len > 0) { + try output.appendSlice(allocator, " class=\""); + for (class_parts.items, 0..) |part, i| { + if (i > 0) try output.append(allocator, ' '); + try output.appendSlice(allocator, part); + } + try output.append(allocator, '"'); } // Self-closing logic differs by mode: @@ -152,24 +317,68 @@ fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *No try output.appendSlice(allocator, ">"); - // Render text content + // Render text content (with mixin argument substitution if applicable) if (tag.val) |val| { - try processInterpolation(allocator, output, val, false, data); + const final_val = if (ctx.arg_bindings) |bindings| + substituteArgValue(val, bindings) orelse val + else + val; + try processInterpolation(allocator, output, final_val, false, data); } - // Render children - for (tag.nodes.items) |child| { - try renderNode(allocator, output, child, data, ctx); + // Render children with increased indent (unless whitespace-sensitive) + if (has_children) { + const child_ctx = if (ctx.pretty and !is_whitespace_sensitive) + ctx.indented() + else + ctx.*; + for (tag.nodes.items) |child| { + try renderNode(allocator, output, child, data, &child_ctx); + } } // Close tag if (!is_self_closing) { + // Pretty print: add newline and indent before closing tag + // Only if we have block children (not just text/inline content) + if (ctx.pretty and has_block_children and !tag.is_inline and !is_whitespace_sensitive) { + try output.append(allocator, '\n'); + try writeIndent(allocator, output, ctx.indent_level); + } try output.appendSlice(allocator, ""); } } +/// Check if a tag has block-level children (not just text/inline content) +fn hasBlockChildren(tag: *Node) bool { + for (tag.nodes.items) |child| { + switch (child.type) { + // Text and Code are inline + .Text, .Code => continue, + // Tags marked as inline are inline + .Tag, .InterpolatedTag => { + if (!child.is_inline) return true; + }, + // Everything else is considered block + else => return true, + } + } + return false; +} + +/// Substitute a single argument reference in a value (simple case - exact match) +fn substituteArgValue(val: ?[]const u8, bindings: *const std.StringHashMapUnmanaged([]const u8)) ?[]const u8 { + const v = val orelse return null; + // Check if the entire value is a parameter name + if (bindings.get(v)) |replacement| { + return replacement; + } + // For now, return as-is (complex substitution would need allocation) + return v; +} + /// Evaluate attribute value from AST to runtime.AttrValue fn evaluateAttrValue(allocator: Allocator, val: ?[]const u8, data: anytype) !runtime.AttrValue { _ = allocator; @@ -211,6 +420,21 @@ fn renderCode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), code: * } else { try output.appendSlice(allocator, inner); } + } else if (ctx.arg_bindings) |bindings| { + // Inside a mixin - check argument bindings first + if (bindings.get(val)) |value| { + if (code.must_escape) { + try runtime.appendEscaped(allocator, output, value); + } else { + try output.appendSlice(allocator, value); + } + } else if (getFieldValue(data, val)) |value| { + if (code.must_escape) { + try runtime.appendEscaped(allocator, output, value); + } else { + try output.appendSlice(allocator, value); + } + } } else if (getFieldValue(data, val)) |value| { if (code.must_escape) { try runtime.appendEscaped(allocator, output, value); @@ -226,6 +450,138 @@ fn renderCode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), code: * } } +/// Render mixin definition or call +fn renderMixin(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { + // Mixin definitions are skipped (only mixin calls render) + if (!node.call) return; + + const mixin_name = node.name orelse return; + + // Look up mixin definition in registry + const mixin_def = if (ctx.mixins) |registry| registry.get(mixin_name) else null; + + if (mixin_def) |def| { + // Build argument bindings + var bindings = std.StringHashMapUnmanaged([]const u8){}; + defer bindings.deinit(allocator); + + if (def.args) |params| { + if (node.args) |args| { + bindMixinArguments(allocator, params, args, &bindings) catch {}; + } + } + + // Create block node from call's children (if any) for `block` keyword + var call_block: ?*Node = null; + if (node.nodes.items.len > 0) { + call_block = node; + } + + // Render the mixin body with argument bindings + var mixin_ctx = RenderContext{ + .terse = ctx.terse, + .mixins = ctx.mixins, + .arg_bindings = &bindings, + .mixin_block = call_block, + }; + + for (def.nodes.items) |child| { + try renderNode(allocator, output, child, data, &mixin_ctx); + } + } else { + // Mixin not found - render children directly (fallback behavior) + for (node.nodes.items) |child| { + try renderNode(allocator, output, child, data, ctx); + } + } +} + +/// Bind mixin call arguments to parameter names +fn bindMixinArguments( + allocator: Allocator, + params: []const u8, + args: []const u8, + bindings: *std.StringHashMapUnmanaged([]const u8), +) !void { + // Parse parameter names from definition: "text, type" or "text, type='primary'" + var param_names = std.ArrayListUnmanaged([]const u8){}; + defer param_names.deinit(allocator); + + var param_iter = std.mem.splitSequence(u8, params, ","); + while (param_iter.next()) |param_part| { + const trimmed = std.mem.trim(u8, param_part, " \t"); + if (trimmed.len == 0) continue; + + // Handle default values: "type='primary'" -> just get "type" + var param_name = trimmed; + if (std.mem.indexOf(u8, trimmed, "=")) |eq_pos| { + param_name = std.mem.trim(u8, trimmed[0..eq_pos], " \t"); + } + + // Handle rest args: "...items" -> "items" + if (std.mem.startsWith(u8, param_name, "...")) { + param_name = param_name[3..]; + } + + try param_names.append(allocator, param_name); + } + + // Parse argument values from call: "'Click', 'primary'" or "text='Click'" + var arg_values = std.ArrayListUnmanaged([]const u8){}; + defer arg_values.deinit(allocator); + + // Simple argument parsing - split by comma but respect quotes + var in_string = false; + var string_char: u8 = 0; + var paren_depth: usize = 0; + var start: usize = 0; + + for (args, 0..) |c, idx| { + if (!in_string) { + if (c == '"' or c == '\'') { + in_string = true; + string_char = c; + } else if (c == '(') { + paren_depth += 1; + } else if (c == ')') { + if (paren_depth > 0) paren_depth -= 1; + } else if (c == ',' and paren_depth == 0) { + const arg_val = std.mem.trim(u8, args[start..idx], " \t"); + try arg_values.append(allocator, stripQuotes(arg_val)); + start = idx + 1; + } + } else { + if (c == string_char) { + in_string = false; + } + } + } + + // Add last argument + if (start < args.len) { + const arg_val = std.mem.trim(u8, args[start..], " \t"); + if (arg_val.len > 0) { + try arg_values.append(allocator, stripQuotes(arg_val)); + } + } + + // Bind positional arguments + const min_len = @min(param_names.items.len, arg_values.items.len); + for (0..min_len) |i| { + try bindings.put(allocator, param_names.items[i], arg_values.items[i]); + } +} + +fn stripQuotes(val: []const u8) []const u8 { + if (val.len < 2) return val; + const first = val[0]; + const last = val[val.len - 1]; + if ((first == '"' and last == '"') or (first == '\'' and last == '\'')) { + return val[1 .. val.len - 1]; + } + return val; +} + fn renderEach(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), each: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { const collection_name = each.obj orelse return; const item_name = each.val orelse "item"; @@ -242,14 +598,26 @@ fn renderEach(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), each: * const CollType = @TypeOf(collection); const coll_info = @typeInfo(CollType); - if (coll_info == .pointer and coll_info.pointer.size == .slice) { + // Handle both slices ([]T) and pointers to arrays (*[N]T) + const is_slice = coll_info == .pointer and coll_info.pointer.size == .slice; + const is_array_ptr = coll_info == .pointer and coll_info.pointer.size == .one and + @typeInfo(coll_info.pointer.child) == .array; + + if (is_slice or is_array_ptr) { for (collection) |item| { const ItemType = @TypeOf(item); if (ItemType == []const u8) { + // Simple string item - use renderNodeWithItem for (each.nodes.items) |child| { try renderNodeWithItem(allocator, output, child, data, item, ctx); } + } else if (@typeInfo(ItemType) == .@"struct") { + // Struct item - render with item as the data context + for (each.nodes.items) |child| { + try renderNode(allocator, output, child, item, ctx); + } } else { + // Other types - skip for (each.nodes.items) |child| { try renderNode(allocator, output, child, data, ctx); } @@ -297,15 +665,33 @@ fn renderTagWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), try output.appendSlice(allocator, "<"); try output.appendSlice(allocator, name); - // Render attributes using runtime.attr() + // Collect class values separately to merge them into one attribute + var class_parts = std.ArrayListUnmanaged([]const u8){}; + defer class_parts.deinit(allocator); + + // Render attributes directly to output buffer (avoids intermediate allocations) for (tag.attrs.items) |attr| { const attr_val = try evaluateAttrValue(allocator, attr.val, data); - const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) { - error.FormatError => return error.OutOfMemory, - error.OutOfMemory => return error.OutOfMemory, - }; - defer allocator.free(attr_str); - try output.appendSlice(allocator, attr_str); + + // Collect class attributes for merging + if (std.mem.eql(u8, attr.name, "class")) { + switch (attr_val) { + .string => |s| if (s.len > 0) try class_parts.append(allocator, s), + else => {}, + } + } else { + try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse); + } + } + + // Output merged class attribute + if (class_parts.items.len > 0) { + try output.appendSlice(allocator, " class=\""); + for (class_parts.items, 0..) |part, i| { + if (i > 0) try output.append(allocator, ' '); + try output.appendSlice(allocator, part); + } + try output.append(allocator, '"'); } const is_void = isSelfClosing(name); @@ -681,3 +1067,57 @@ test "nested tags with data" { try std.testing.expectEqualStrings("

Welcome

Hello there!

", html); } + +test "pretty print - nested tags" { + const allocator = std.testing.allocator; + + var result = try parseWithSource(allocator, + \\div + \\ h1 Title + \\ p Content + ); + defer result.deinit(allocator); + + var registry = MixinRegistry.init(allocator); + defer registry.deinit(); + + const html = try renderAstWithMixinsAndOptions(allocator, result.ast, .{}, ®istry, .{ .pretty = true }); + defer allocator.free(html); + + const expected = + \\
+ \\

Title

+ \\

Content

+ \\
+ ; + try std.testing.expectEqualStrings(expected, html); +} + +test "pretty print - deeply nested" { + const allocator = std.testing.allocator; + + var result = try parseWithSource(allocator, + \\html + \\ body + \\ div + \\ p Hello + ); + defer result.deinit(allocator); + + var registry = MixinRegistry.init(allocator); + defer registry.deinit(); + + const html = try renderAstWithMixinsAndOptions(allocator, result.ast, .{}, ®istry, .{ .pretty = true }); + defer allocator.free(html); + + const expected = + \\ + \\ + \\
+ \\

Hello

+ \\
+ \\ + \\ + ; + try std.testing.expectEqualStrings(expected, html); +} diff --git a/src/tests/general_test.zig b/src/tests/general_test.zig index c8e7d12..96507a6 100644 --- a/src/tests/general_test.zig +++ b/src/tests/general_test.zig @@ -52,7 +52,7 @@ test "Link with class and href (space separated)" { try expectOutput( "a(class='button' href='//google.com') Google", .{}, - "Google", + "Google", ); } @@ -60,7 +60,7 @@ test "Link with class and href (comma separated)" { try expectOutput( "a(class='button', href='//google.com') Google", .{}, - "Google", + "Google", ); } diff --git a/src/tests/test_includes.zig b/src/tests/test_includes.zig new file mode 100644 index 0000000..0bd28c9 --- /dev/null +++ b/src/tests/test_includes.zig @@ -0,0 +1,24 @@ +const std = @import("std"); +const pugz = @import("pugz"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var engine = pugz.ViewEngine.init(allocator, .{ + .views_dir = "test_views", + }) catch |err| { + std.debug.print("Init Error: {}\n", .{err}); + return err; + }; + defer engine.deinit(); + + const html = engine.render(allocator, "home", .{}) catch |err| { + std.debug.print("Error: {}\n", .{err}); + return err; + }; + defer allocator.free(html); + + std.debug.print("=== Rendered HTML ===\n{s}\n=== End ===\n", .{html}); +} diff --git a/src/tests/test_views/home.pug b/src/tests/test_views/home.pug new file mode 100644 index 0000000..a9409cf --- /dev/null +++ b/src/tests/test_views/home.pug @@ -0,0 +1,8 @@ +include mixins/_buttons.pug +include mixins/_cards.pug + +doctype html +html + body + +primary-button("Click me") + +card("Title", "content here") diff --git a/src/tests/test_views/mixins/_buttons.pug b/src/tests/test_views/mixins/_buttons.pug new file mode 100644 index 0000000..d0ee89a --- /dev/null +++ b/src/tests/test_views/mixins/_buttons.pug @@ -0,0 +1,2 @@ +mixin btn(text) + button.btn= text diff --git a/src/tests/test_views/mixins/_cards.pug b/src/tests/test_views/mixins/_cards.pug new file mode 100644 index 0000000..272c1b8 --- /dev/null +++ b/src/tests/test_views/mixins/_cards.pug @@ -0,0 +1,4 @@ +mixin card(title, content) + .card + .card-header= title + .card-body= content diff --git a/src/view_engine.zig b/src/view_engine.zig index 020b9bb..3878411 100644 --- a/src/view_engine.zig +++ b/src/view_engine.zig @@ -1,59 +1,451 @@ -// ViewEngine - Simple template engine for web servers +// ViewEngine - Template engine with include/mixin support for web servers // // Provides a high-level API for rendering Pug templates from a views directory. -// Works with any web server that provides an allocator (httpz, zap, etc). +// Templates are parsed once and cached in memory for fast subsequent renders. +// Handles include statements and mixin resolution automatically. // // Usage: -// const engine = ViewEngine.init(.{ .views_dir = "views" }); -// const html = try engine.render(allocator, "pages/home", .{ .title = "Home" }); +// var engine = try ViewEngine.init(allocator, .{ .views_dir = "views" }); +// defer engine.deinit(); +// +// const html = try engine.render(request_allocator, "pages/home", .{ .title = "Home" }); +// +// Include/Mixin pattern: +// // views/pages/home.pug +// include mixins/_buttons.pug +// include mixins/_cards.pug +// +// doctype html +// html +// body +// +primary-button("Click me") +// +card("Title", "content") const std = @import("std"); -const pug = @import("pug.zig"); +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; + +pub const ViewEngineError = error{ + OutOfMemory, + TemplateNotFound, + ReadError, + ParseError, + ViewsDirNotFound, + IncludeNotFound, + PathEscapesRoot, + CacheInitError, +}; pub const Options = struct { - /// Root directory containing view templates + /// Root directory containing view templates (all paths relative to this) views_dir: []const u8 = "views", /// File extension for templates extension: []const u8 = ".pug", - /// Enable pretty-printing with indentation - pretty: bool = true, + /// 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(options: Options) ViewEngine { - return .{ .options = options }; + 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 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(); + } } /// Renders a template file with the given data context. /// Template path is relative to views_dir, extension added automatically. - pub fn render(self: *const ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, data: anytype) ![]const u8 { - _ = data; // TODO: pass data to template + /// Processes includes and resolves mixin calls. + pub fn render(self: *ViewEngine, allocator: std.mem.Allocator, template_path: []const u8, data: anytype) ![]const u8 { + // Build mixin registry from all includes + var registry = MixinRegistry.init(allocator); + defer registry.deinit(); - // Build full path - const full_path = try self.resolvePath(allocator, template_path); - defer allocator.free(full_path); + // Get or parse the main AST and process includes + const ast = try self.getOrParseWithIncludes(template_path, ®istry); - // Compile the template - var result = pug.compileFile(allocator, full_path, .{ + // Render the AST with mixin registry - mixins are expanded inline during rendering + return template.renderAstWithMixinsAndOptions(allocator, ast, data, ®istry, .{ .pretty = self.options.pretty, - .filename = full_path, - }) catch |err| { + }); + } + + /// 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; + } + } + } + + // 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); + + // Read template file + 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); + + // Parse template - returns AST and normalized source that AST strings point to + var parse_result = template.parseWithSource(self.cache_allocator, source) catch { + return ViewEngineError.ParseError; + }; + errdefer parse_result.deinit(self.cache_allocator); + + // Process extends (template inheritance) - must be done before includes + const final_ast = try self.processExtends(parse_result.ast, registry); + + // Process includes in the AST + try self.processIncludes(final_ast, registry); + + // Collect mixins from this template + mixin.collectMixins(self.cache_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; + } + + /// Process all include statements in the AST + fn processIncludes(self: *ViewEngine, 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| { + // For includes, convert TemplateNotFound to IncludeNotFound + if (err == ViewEngineError.TemplateNotFound) { + return ViewEngineError.IncludeNotFound; + } + return err; + }; + + // 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 { + return ViewEngineError.OutOfMemory; + }; + } + } + } + } + } + + // Recurse into children + for (node.nodes.items) |child| { + try self.processIncludes(child, registry); + } + } + + /// Process extends statement - loads parent template and merges blocks + fn processExtends(self: *ViewEngine, ast: *Node, registry: *MixinRegistry) ViewEngineError!*Node { + if (ast.nodes.items.len == 0) return ast; + + // Check if first node is Extends + const first_node = ast.nodes.items[0]; + if (first_node.type != .Extends) return ast; + + // Get parent template path + const parent_path = if (first_node.file) |file| file.path else null; + 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); + 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| { + if (err == ViewEngineError.TemplateNotFound) { + return ViewEngineError.IncludeNotFound; + } return err; }; - if (result.err) |*e| { - e.deinit(); - return error.ParseError; - } + // Replace blocks in parent with child blocks + self.replaceBlocks(parent_ast, &child_blocks); - return result.html; + return parent_ast; } - /// Resolves a template path relative to views directory + /// 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 { + 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) { + if (node.name) |name| { + blocks.put(name, node) catch {}; + } + } + for (node.nodes.items) |child| { + self.collectNamedBlocks(child, blocks); + } + } + + /// Replace named blocks in parent with child block content + fn replaceBlocks(self: *ViewEngine, node: *Node, child_blocks: *std.StringHashMap(*Node)) void { + if (node.type == .NamedBlock) { + if (node.name) |name| { + if (child_blocks.get(name)) |child_block| { + // Get the block mode from child + const mode = child_block.mode orelse "replace"; + + 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 {}; + } + } 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 {}; + 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 {}; + } + } + } + } + } + + // Recurse into children + for (node.nodes.items) |child| { + self.replaceBlocks(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 { - // Add extension if not present + // Security: reject paths that escape root + if (!load.isPathSafe(template_path)) { + return ViewEngineError.PathEscapesRoot; + } + const with_ext = if (std.mem.endsWith(u8, template_path, self.options.extension)) try allocator.dupe(u8, template_path) else @@ -63,3 +455,54 @@ pub const ViewEngine = struct { return std.fs.path.join(allocator, &.{ self.options.views_dir, with_ext }); } }; + +// ============================================================================ +// Tests +// ============================================================================ + +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, + }); + defer engine.deinit(); +} + +test "isPathSafe - safe paths" { + try std.testing.expect(load.isPathSafe("home")); + try std.testing.expect(load.isPathSafe("pages/home")); + try std.testing.expect(load.isPathSafe("mixins/_buttons")); + try std.testing.expect(load.isPathSafe("a/b/c/d")); + try std.testing.expect(load.isPathSafe("a/b/../b/c")); // Goes up then back down, still safe +} + +test "isPathSafe - unsafe paths" { + try std.testing.expect(!load.isPathSafe("../etc/passwd")); + try std.testing.expect(!load.isPathSafe("..")); + try std.testing.expect(!load.isPathSafe("a/../../b")); + try std.testing.expect(!load.isPathSafe("a/b/c/../../../..")); + try std.testing.expect(!load.isPathSafe("/etc/passwd")); // Absolute paths +} + +test "ViewEngine - path escape protection" { + const allocator = std.testing.allocator; + + var engine = try ViewEngine.init(allocator, .{ + .views_dir = "src/tests/test_views", + }); + defer engine.deinit(); + + // Should reject paths that escape the views root + const result = engine.render(allocator, "../etc/passwd", .{}); + try std.testing.expectError(ViewEngineError.PathEscapesRoot, result); + + // Absolute paths should also be rejected + const result2 = engine.render(allocator, "/etc/passwd", .{}); + try std.testing.expectError(ViewEngineError.PathEscapesRoot, result2); +}