feat: add template inheritance (extends/block) support
- ViewEngine now supports extends and named blocks - Each route gets exclusive cached AST (no shared parent layouts) - Fix iteration over struct arrays in each loops - Add demo app with full e-commerce layout using extends - Serve static files from public folder - Bump version to 0.3.0
This commit is contained in:
42
README.md
42
README.md
@@ -1,6 +1,3 @@
|
|||||||
*! I am using ClaudeCode to build it*
|
|
||||||
*! Its Yet not ready for production use*
|
|
||||||
|
|
||||||
# Pugz
|
# Pugz
|
||||||
|
|
||||||
A Pug template engine for Zig, supporting both build-time compilation and runtime interpretation.
|
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
|
- Includes
|
||||||
- Mixins with parameters, defaults, rest args, and block content
|
- Mixins with parameters, defaults, rest args, and block content
|
||||||
- Comments (rendered and unbuffered)
|
- Comments (rendered and unbuffered)
|
||||||
|
- Pretty printing with indentation
|
||||||
|
- LRU cache with configurable size and TTL
|
||||||
|
|
||||||
## Installation
|
## 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"
|
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
|
## Usage
|
||||||
@@ -99,11 +96,16 @@ const std = @import("std");
|
|||||||
const pugz = @import("pugz");
|
const pugz = @import("pugz");
|
||||||
|
|
||||||
pub fn main() !void {
|
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",
|
.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();
|
defer arena.deinit();
|
||||||
|
|
||||||
const html = try engine.render(arena.allocator(), "index", .{
|
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
|
### With http.zig
|
||||||
|
|
||||||
```zig
|
```zig
|
||||||
|
|||||||
61
build.zig
61
build.zig
@@ -3,10 +3,19 @@ const std = @import("std");
|
|||||||
pub fn build(b: *std.Build) void {
|
pub fn build(b: *std.Build) void {
|
||||||
const target = b.standardTargetOptions(.{});
|
const target = b.standardTargetOptions(.{});
|
||||||
const optimize = b.standardOptimizeOption(.{});
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
// Get cache.zig dependency
|
||||||
|
const cache_dep = b.dependency("cache", .{
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
|
||||||
const mod = b.addModule("pugz", .{
|
const mod = b.addModule("pugz", .{
|
||||||
.root_source_file = b.path("src/root.zig"),
|
.root_source_file = b.path("src/root.zig"),
|
||||||
.target = target,
|
.target = target,
|
||||||
.optimize = optimize,
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "cache", .module = cache_dep.module("cache") },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Creates an executable that will run `test` blocks from the provided module.
|
// 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.
|
// A run step that will run the test executable.
|
||||||
const run_mod_tests = b.addRunArtifact(mod_tests);
|
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
|
// Integration tests - general template tests
|
||||||
const general_tests = b.addTest(.{
|
const general_tests = b.addTest(.{
|
||||||
.root_module = b.createModule(.{
|
.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_general_tests.step);
|
||||||
test_step.dependOn(&run_doctype_tests.step);
|
test_step.dependOn(&run_doctype_tests.step);
|
||||||
test_step.dependOn(&run_check_list_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
|
// Individual test steps
|
||||||
const test_general_step = b.step("test-general", "Run general template tests");
|
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.)");
|
const test_unit_step = b.step("test-unit", "Run unit tests (lexer, parser, etc.)");
|
||||||
test_unit_step.dependOn(&run_mod_tests.step);
|
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");
|
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);
|
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("."));
|
run_bench.setCwd(b.path("."));
|
||||||
const bench_step = b.step("bench", "Run benchmark");
|
const bench_step = b.step("bench", "Run benchmark");
|
||||||
bench_step.dependOn(&run_bench.step);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
.{
|
.{
|
||||||
.name = .pugz,
|
.name = .pugz,
|
||||||
.version = "0.2.2",
|
.version = "0.3.0",
|
||||||
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
|
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
|
||||||
.minimum_zig_version = "0.15.2",
|
.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 = .{
|
.paths = .{
|
||||||
"build.zig",
|
"build.zig",
|
||||||
"build.zig.zon",
|
"build.zig.zon",
|
||||||
|
|||||||
752
examples/demo/public/css/style.css
Normal file
752
examples/demo/public/css/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
//! Features demonstrated:
|
||||||
//!
|
//! - Template inheritance (extends/block)
|
||||||
//! Routes:
|
//! - Partial includes (header, footer)
|
||||||
//! GET / - Home page
|
//! - Mixins with parameters (product-card, rating, forms)
|
||||||
//! GET /users - Users list
|
//! - Conditionals and loops
|
||||||
//! GET /page-a - Page with data
|
//! - Data binding
|
||||||
|
//! - Pretty printing
|
||||||
|
//! - LRU cache with TTL
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const httpz = @import("httpz");
|
const httpz = @import("httpz");
|
||||||
@@ -13,28 +15,367 @@ const pugz = @import("pugz");
|
|||||||
|
|
||||||
const Allocator = std.mem.Allocator;
|
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 {
|
const App = struct {
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
view: pugz.ViewEngine,
|
view: pugz.ViewEngine,
|
||||||
|
|
||||||
pub fn init(allocator: Allocator) App {
|
pub fn init(allocator: Allocator) !App {
|
||||||
return .{
|
return .{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
.view = pugz.ViewEngine.init(.{
|
.view = try pugz.ViewEngine.init(allocator, .{
|
||||||
.views_dir = "views",
|
.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,
|
||||||
|
\\<!DOCTYPE html>
|
||||||
|
\\<html>
|
||||||
|
\\<head><title>Error</title></head>
|
||||||
|
\\<body>
|
||||||
|
\\<h1>500 - Server Error</h1>
|
||||||
|
\\<p>Error: {s}</p>
|
||||||
|
\\</body>
|
||||||
|
\\</html>
|
||||||
|
, .{@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 {
|
pub fn main() !void {
|
||||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||||
defer if (gpa.deinit() == .leak) @panic("leak");
|
defer if (gpa.deinit() == .leak) @panic("leak");
|
||||||
|
|
||||||
const allocator = gpa.allocator();
|
const allocator = gpa.allocator();
|
||||||
|
|
||||||
var app = App.init(allocator);
|
var app = try App.init(allocator);
|
||||||
|
defer app.deinit();
|
||||||
|
|
||||||
const port = 8081;
|
const port = 8081;
|
||||||
var server = try httpz.Server(*App).init(allocator, .{ .port = port }, &app);
|
var server = try httpz.Server(*App).init(allocator, .{ .port = port }, &app);
|
||||||
@@ -42,83 +383,37 @@ pub fn main() !void {
|
|||||||
|
|
||||||
var router = try server.router(.{});
|
var router = try server.router(.{});
|
||||||
|
|
||||||
router.get("/", index, .{});
|
// Pages
|
||||||
router.get("/users", users, .{});
|
router.get("/", home, .{});
|
||||||
router.get("/page-a", pageA, .{});
|
router.get("/products", products, .{});
|
||||||
router.get("/mixin-test", mixinTest, .{});
|
router.get("/products/:id", productDetail, .{});
|
||||||
|
router.get("/cart", cart, .{});
|
||||||
|
router.get("/about", about, .{});
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
router.get("/css/*", serveStatic, .{});
|
||||||
|
|
||||||
std.debug.print(
|
std.debug.print(
|
||||||
\\
|
\\
|
||||||
\\Pugz Demo - ViewEngine Template Rendering
|
\\ ____ ____ _
|
||||||
\\==========================================
|
\\ | _ \ _ _ __ _ ____ / ___|| |_ ___ _ __ ___
|
||||||
\\Server running at http://localhost:{d}
|
\\ | |_) | | | |/ _` |_ / \___ \| __/ _ \| '__/ _ \
|
||||||
|
\\ | __/| |_| | (_| |/ / ___) | || (_) | | | __/
|
||||||
|
\\ |_| \__,_|\__, /___| |____/ \__\___/|_| \___|
|
||||||
|
\\ |___/
|
||||||
\\
|
\\
|
||||||
\\Routes:
|
\\ Server running at http://localhost:{d}
|
||||||
|
\\
|
||||||
|
\\ Routes:
|
||||||
\\ GET / - Home page
|
\\ GET / - Home page
|
||||||
\\ GET /users - Users list
|
\\ GET /products - Products page
|
||||||
\\ GET /page-a - Page with data
|
\\ GET /products/:id - Product detail
|
||||||
\\ GET /mixin-test - Mixin test page
|
\\ GET /cart - Shopping cart
|
||||||
|
\\ GET /about - About page
|
||||||
\\
|
\\
|
||||||
\\Press Ctrl+C to stop.
|
\\ Press Ctrl+C to stop.
|
||||||
\\
|
\\
|
||||||
, .{port});
|
, .{port});
|
||||||
|
|
||||||
try server.listen();
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
p
|
|
||||||
| Route no found
|
|
||||||
@@ -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, "<!DOCTYPE html><html><head><title>hello</title></head><body><p>some thing</p>ballahballah");
|
|
||||||
{
|
|
||||||
const text = "click me ";
|
|
||||||
const @"type" = "secondary";
|
|
||||||
try o.appendSlice(a, "<button");
|
|
||||||
try o.appendSlice(a, " class=\"");
|
|
||||||
try o.appendSlice(a, "btn btn-");
|
|
||||||
try o.appendSlice(a, strVal(@"type"));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, ">");
|
|
||||||
try esc(&o, a, strVal(text));
|
|
||||||
try o.appendSlice(a, "</button>");
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "</body><br /><a href=\"//google.com\" target=\"_blank\">Google 1</a><br /><a class=\"button\" href=\"//google.com\" target=\"_blank\">Google 2</a><br /><a class=\"button\" href=\"//google.com\" target=\"_blank\">Google 3</a></html>");
|
|
||||||
_ = d;
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sub_layout(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
var o: ArrayList = .empty;
|
|
||||||
try o.appendSlice(a, "<html><head><title>My Site - ");
|
|
||||||
try esc(&o, a, strVal(@field(d, "title")));
|
|
||||||
try o.appendSlice(a, "</title><script src=\"/jquery.js\"></script></head><body><div class=\"sidebar\"><p>nothing</p></div><div class=\"primary\"><p>nothing</p></div><div id=\"footer\"><p>some footer content</p></div></body></html>");
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn _404(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
_ = .{ a, d };
|
|
||||||
return "<p>Route no found</p>";
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "<!DOCTYPE html><html><head><title>");
|
|
||||||
try esc(&o, a, strVal(@field(d, "title")));
|
|
||||||
try o.appendSlice(a, "</title><link rel=\"stylesheet\" href=\"/style.css\" /></head><body><header><h1>");
|
|
||||||
try esc(&o, a, strVal(@field(d, "title")));
|
|
||||||
try o.appendSlice(a, "</h1>");
|
|
||||||
if (@hasField(@TypeOf(d), "authenticated") and truthy(@field(d, "authenticated"))) {
|
|
||||||
try o.appendSlice(a, "<span class=\"user\">Welcome back!</span>");
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "</header><main><p>This page is rendered using a compiled template.</p><p>Compiled templates are 3x faster than Pug.js!</p></main><footer><p>© 2024 Pugz Demo</p></footer></body></html>");
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
var o: ArrayList = .empty;
|
|
||||||
try o.appendSlice(a, "<html><head><title>My Site - ");
|
|
||||||
try esc(&o, a, strVal(@field(d, "title")));
|
|
||||||
try o.appendSlice(a, "</title><script src=\"/jquery.js\"></script><script src=\"/pets.js\"></script></head><body><h1>");
|
|
||||||
try esc(&o, a, strVal(@field(d, "title")));
|
|
||||||
try o.appendSlice(a, "</h1><p>Welcome to the pets page!</p><ul><li>Cat</li><li>Dog</li></ul><ul>");
|
|
||||||
for (@field(d, "items")) |val| {
|
|
||||||
try o.appendSlice(a, "<li>");
|
|
||||||
try esc(&o, a, strVal(val));
|
|
||||||
try o.appendSlice(a, "</li>");
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "</ul><input data-json=\"{ "very-long": "piece of ", "data": true }\" /><br /><div class=\"div-class\" (click)=\"play()\">one</div><div class=\"div-class\" (click)=\"play()\">two</div><a style=\"color:red;background:green;\">sdfsdfs</a><a class=\"button\">btn</a><br /><form method=\"post\">");
|
|
||||||
{
|
|
||||||
const name = "firstName";
|
|
||||||
const label = "First Name";
|
|
||||||
const placeholder = "first name";
|
|
||||||
try o.appendSlice(a, "<fieldset class=\"fieldset\"><legend class=\"fieldset-legend\">");
|
|
||||||
try esc(&o, a, strVal(label));
|
|
||||||
try o.appendSlice(a, "</legend><input class=\"input\" type=\"text\"");
|
|
||||||
try o.appendSlice(a, " name=\"");
|
|
||||||
try o.appendSlice(a, strVal(name));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, " placeholder=\"");
|
|
||||||
try o.appendSlice(a, strVal(placeholder));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, " /></fieldset>");
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "<br />");
|
|
||||||
{
|
|
||||||
const name = "lastName";
|
|
||||||
const label = "Last Name";
|
|
||||||
const placeholder = "last name";
|
|
||||||
try o.appendSlice(a, "<fieldset class=\"fieldset\"><legend class=\"fieldset-legend\">");
|
|
||||||
try esc(&o, a, strVal(label));
|
|
||||||
try o.appendSlice(a, "</legend><input class=\"input\" type=\"text\"");
|
|
||||||
try o.appendSlice(a, " name=\"");
|
|
||||||
try o.appendSlice(a, strVal(name));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, " placeholder=\"");
|
|
||||||
try o.appendSlice(a, strVal(placeholder));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, " /></fieldset>");
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "<submit>sumit</submit>");
|
|
||||||
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, "<div");
|
|
||||||
try o.appendSlice(a, " class=\"");
|
|
||||||
try o.appendSlice(a, "alert ");
|
|
||||||
try o.appendSlice(a, strVal(mixin_attrs_1.class));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, " role=\"alert\"><svg class=\"h-6 w-6 shrink-0 stroke-current\" xmlns=\"http://www.w3.org/2000/svg\" 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\"></path></svg><span>");
|
|
||||||
try esc(&o, a, strVal(message));
|
|
||||||
try o.appendSlice(a, "</span></div>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "</form><div id=\"footer\"><p>some footer content</p></div></body></html>");
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mixin_test(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
var o: ArrayList = .empty;
|
|
||||||
try o.appendSlice(a, "<!DOCTYPE html><html><head><title>Mixin Test</title></head><body><h1>Mixin Test Page</h1><p>Testing button mixin:</p>");
|
|
||||||
{
|
|
||||||
const text = "Click Me";
|
|
||||||
const @"type" = "primary";
|
|
||||||
try o.appendSlice(a, "<button");
|
|
||||||
try o.appendSlice(a, " class=\"");
|
|
||||||
try o.appendSlice(a, "btn btn-");
|
|
||||||
try o.appendSlice(a, strVal(@"type"));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, ">");
|
|
||||||
try esc(&o, a, strVal(text));
|
|
||||||
try o.appendSlice(a, "</button>");
|
|
||||||
}
|
|
||||||
{
|
|
||||||
const text = "Cancel";
|
|
||||||
const @"type" = "btn btn-secondary";
|
|
||||||
try o.appendSlice(a, "<button");
|
|
||||||
try o.appendSlice(a, " class=\"");
|
|
||||||
try o.appendSlice(a, "btn btn-");
|
|
||||||
try o.appendSlice(a, strVal(@"type"));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, ">");
|
|
||||||
try esc(&o, a, strVal(text));
|
|
||||||
try o.appendSlice(a, "</button>");
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "<p>Testing link mixin:</p>");
|
|
||||||
{
|
|
||||||
const href = "/home";
|
|
||||||
const text = "Go Home";
|
|
||||||
try o.appendSlice(a, "<a class=\"btn btn-link\"");
|
|
||||||
try o.appendSlice(a, " href=\"");
|
|
||||||
try o.appendSlice(a, strVal(href));
|
|
||||||
try o.appendSlice(a, "\"");
|
|
||||||
try o.appendSlice(a, ">");
|
|
||||||
try esc(&o, a, strVal(text));
|
|
||||||
try o.appendSlice(a, "</a>");
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "</body></html>");
|
|
||||||
_ = d;
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn page_b(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
var o: ArrayList = .empty;
|
|
||||||
try o.appendSlice(a, "<html><head><title>My Site - ");
|
|
||||||
try esc(&o, a, strVal(@field(d, "title")));
|
|
||||||
try o.appendSlice(a, "</title><script src=\"/jquery.js\"></script></head><body><div class=\"sidebar\"><p>nothing</p></div><div class=\"primary\"><p>nothing</p></div><div id=\"footer\"><p>some footer content</p></div></body></html>");
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn layout_2(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
_ = .{ a, d };
|
|
||||||
return "<html><head><script src=\"/vendor/jquery.js\"></script><script src=\"/vendor/caustic.js\"></script></head><body></body></html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn layout(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
var o: ArrayList = .empty;
|
|
||||||
try o.appendSlice(a, "<html><head><title>My Site - ");
|
|
||||||
try esc(&o, a, strVal(@field(d, "title")));
|
|
||||||
try o.appendSlice(a, "</title><script src=\"/jquery.js\"></script></head><body><div id=\"footer\"><p>some footer content</p></div></body></html>");
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn page_append(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
_ = .{ a, d };
|
|
||||||
return "<html><head><script src=\"/vendor/jquery.js\"></script><script src=\"/vendor/caustic.js\"></script><script src=\"/vendor/three.js\"></script><script src=\"/game.js\"></script></head><body><p>cheks manually the head section<br />hello there</p></body></html>";
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn users(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
var o: ArrayList = .empty;
|
|
||||||
try o.appendSlice(a, "<!DOCTYPE html><html><head><title>Users</title></head><body><h1>User List</h1><ul class=\"user-list\">");
|
|
||||||
for (@field(d, "users")) |user| {
|
|
||||||
try o.appendSlice(a, "<li class=\"user\"><strong>");
|
|
||||||
try esc(&o, a, strVal(user.name));
|
|
||||||
try o.appendSlice(a, "</strong><span class=\"email\">");
|
|
||||||
try esc(&o, a, strVal(user.email));
|
|
||||||
try o.appendSlice(a, "</span></li>");
|
|
||||||
}
|
|
||||||
try o.appendSlice(a, "</ul></body></html>");
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn page_appen_optional_blk(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
var o: ArrayList = .empty;
|
|
||||||
try o.appendSlice(a, "<html><head><title>My Site - ");
|
|
||||||
try esc(&o, a, strVal(@field(d, "title")));
|
|
||||||
try o.appendSlice(a, "</title><script src=\"/jquery.js\"></script></head><body><div id=\"footer\"><p>some footer content</p></div></body></html>");
|
|
||||||
return o.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pet(a: Allocator, d: anytype) Allocator.Error![]u8 {
|
|
||||||
var o: ArrayList = .empty;
|
|
||||||
try o.appendSlice(a, "<p>");
|
|
||||||
try esc(&o, a, strVal(@field(d, "petName")));
|
|
||||||
try o.appendSlice(a, "</p>");
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
html
|
|
||||||
head
|
|
||||||
block head
|
|
||||||
script(src='/vendor/jquery.js')
|
|
||||||
script(src='/vendor/caustic.js')
|
|
||||||
body
|
|
||||||
block content
|
|
||||||
@@ -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
|
|
||||||
28
examples/demo/views/layouts/base.pug
Normal file
28
examples/demo/views/layouts/base.pug
Normal file
@@ -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
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
mixin alert_error(message)
|
|
||||||
+alert(message)(class="alert-error")
|
|
||||||
12
examples/demo/views/mixins/alerts.pug
Normal file
12
examples/demo/views/mixins/alerts.pug
Normal file
@@ -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
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
mixin btn(text, type="primary")
|
//- Button mixins with various styles
|
||||||
button(class="btn btn-" + type)= text
|
|
||||||
|
|
||||||
mixin btn-link(href, text)
|
mixin btn(text, type)
|
||||||
a.btn.btn-link(href=href)= text
|
- 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
|
||||||
|
|||||||
@@ -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
|
|
||||||
17
examples/demo/views/mixins/cart-item.pug
Normal file
17
examples/demo/views/mixins/cart-item.pug
Normal file
@@ -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
|
||||||
25
examples/demo/views/mixins/forms.pug
Normal file
25
examples/demo/views/mixins/forms.pug
Normal file
@@ -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)
|
||||||
@@ -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)
|
|
||||||
38
examples/demo/views/mixins/product-card.pug
Normal file
38
examples/demo/views/mixins/product-card.pug
Normal file
@@ -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
|
||||||
13
examples/demo/views/mixins/rating.pug
Normal file
13
examples/demo/views/mixins/rating.pug
Normal file
@@ -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
|
||||||
@@ -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)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
extends layout
|
|
||||||
|
|
||||||
append head
|
|
||||||
script(src='/vendor/three.js')
|
|
||||||
script(src='/game.js')
|
|
||||||
@@ -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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
extends sub-layout.pug
|
|
||||||
|
|
||||||
block content
|
|
||||||
.sidebar
|
|
||||||
block sidebar
|
|
||||||
p nothing
|
|
||||||
.primary
|
|
||||||
block primary
|
|
||||||
p nothing
|
|
||||||
15
examples/demo/views/pages/404.pug
Normal file
15
examples/demo/views/pages/404.pug
Normal file
@@ -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
|
||||||
51
examples/demo/views/pages/about.pug
Normal file
51
examples/demo/views/pages/about.pug
Normal file
@@ -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
|
||||||
47
examples/demo/views/pages/cart.pug
Normal file
47
examples/demo/views/pages/cart.pug
Normal file
@@ -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
|
||||||
144
examples/demo/views/pages/checkout.pug
Normal file
144
examples/demo/views/pages/checkout.pug
Normal file
@@ -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
|
||||||
56
examples/demo/views/pages/home.pug
Normal file
56
examples/demo/views/pages/home.pug
Normal file
@@ -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
|
||||||
65
examples/demo/views/pages/product-detail.pug
Normal file
65
examples/demo/views/pages/product-detail.pug
Normal file
@@ -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
|
||||||
79
examples/demo/views/pages/products.pug
Normal file
79
examples/demo/views/pages/products.pug
Normal file
@@ -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
|
||||||
4
examples/demo/views/partials/footer.pug
Normal file
4
examples/demo/views/partials/footer.pug
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
footer.footer
|
||||||
|
.container
|
||||||
|
.footer-content
|
||||||
|
p Built with Pugz - A Pug template engine for Zig
|
||||||
3
examples/demo/views/partials/head.pug
Normal file
3
examples/demo/views/partials/head.pug
Normal file
@@ -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")
|
||||||
11
examples/demo/views/partials/header.pug
Normal file
11
examples/demo/views/partials/header.pug
Normal file
@@ -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})
|
||||||
@@ -1 +0,0 @@
|
|||||||
p= petName
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
extends layout.pug
|
|
||||||
|
|
||||||
block content
|
|
||||||
.sidebar
|
|
||||||
block sidebar
|
|
||||||
p nothing
|
|
||||||
.primary
|
|
||||||
block primary
|
|
||||||
p nothing
|
|
||||||
@@ -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
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
//! Pugz Benchmark - Template Rendering
|
//! 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 std = @import("std");
|
||||||
const pugz = @import("pugz");
|
const pugz = @import("pugz");
|
||||||
@@ -59,12 +60,12 @@ pub fn main() !void {
|
|||||||
|
|
||||||
std.debug.print("\n", .{});
|
std.debug.print("\n", .{});
|
||||||
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("║ Templates: {s}/*.pug ║\n", .{templates_dir});
|
||||||
std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{});
|
std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{});
|
||||||
|
|
||||||
// Load JSON data
|
// 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);
|
var data_arena = std.heap.ArenaAllocator.init(allocator);
|
||||||
defer data_arena.deinit();
|
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 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");
|
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 simple0_tpl = try loadTemplate(data_alloc, "simple-0.pug");
|
||||||
const simple1_tpl = try loadTemplate(data_alloc, "simple-1.pug");
|
const simple1_tpl = try loadTemplate(data_alloc, "simple-1.pug");
|
||||||
const simple2_tpl = try loadTemplate(data_alloc, "simple-2.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 search_tpl = try loadTemplate(data_alloc, "search-results.pug");
|
||||||
const friends_tpl = try loadTemplate(data_alloc, "friends.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;
|
var total: f64 = 0;
|
||||||
|
|
||||||
total += try bench("simple-0", allocator, simple0_tpl, simple0);
|
total += try bench("simple-0", allocator, simple0_ast, simple0);
|
||||||
total += try bench("simple-1", allocator, simple1_tpl, simple1);
|
total += try bench("simple-1", allocator, simple1_ast, simple1);
|
||||||
total += try bench("simple-2", allocator, simple2_tpl, simple2);
|
total += try bench("simple-2", allocator, simple2_ast, simple2);
|
||||||
total += try bench("if-expression", allocator, if_expr_tpl, if_expr);
|
total += try bench("if-expression", allocator, if_expr_ast, if_expr);
|
||||||
total += try bench("projects-escaped", allocator, projects_tpl, projects);
|
total += try bench("projects-escaped", allocator, projects_ast, projects);
|
||||||
total += try bench("search-results", allocator, search_tpl, search);
|
total += try bench("search-results", allocator, search_ast, search);
|
||||||
total += try bench("friends", allocator, friends_tpl, friends_data);
|
total += try bench("friends", allocator, friends_ast, friends_data);
|
||||||
|
|
||||||
std.debug.print("\n", .{});
|
std.debug.print("\n", .{});
|
||||||
std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ "TOTAL", total });
|
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(
|
fn bench(
|
||||||
name: []const u8,
|
name: []const u8,
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
template: []const u8,
|
ast: *pugz.parser.Node,
|
||||||
data: anytype,
|
data: anytype,
|
||||||
) !f64 {
|
) !f64 {
|
||||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||||
@@ -145,7 +155,7 @@ fn bench(
|
|||||||
var timer = try std.time.Timer.start();
|
var timer = try std.time.Timer.start();
|
||||||
for (0..iterations) |_| {
|
for (0..iterations) |_| {
|
||||||
_ = arena.reset(.retain_capacity);
|
_ = 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 });
|
std.debug.print(" {s:<20} => ERROR: {}\n", .{ name, err });
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -335,6 +335,16 @@ pub const Lexer = struct {
|
|||||||
|
|
||||||
const IndentType = enum { tabs, spaces };
|
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 {
|
pub fn init(allocator: Allocator, str: []const u8, options: LexerOptions) !Lexer {
|
||||||
// Strip UTF-8 BOM if present
|
// Strip UTF-8 BOM if present
|
||||||
var input = str;
|
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
|
// Error handling
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -634,9 +654,9 @@ pub const Lexer = struct {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add outdent tokens for remaining indentation
|
// Add outdent tokens for remaining indentation (pop from stack end)
|
||||||
var i: usize = 0;
|
while (self.indent_stack.items.len > 1 and self.currentIndent() > 0) {
|
||||||
while (i < self.indent_stack.items.len and self.indent_stack.items[i] > 0) : (i += 1) {
|
_ = self.indent_stack.pop();
|
||||||
var outdent_tok = self.tok(.outdent, .none);
|
var outdent_tok = self.tok(.outdent, .none);
|
||||||
self.tokEnd(&outdent_tok);
|
self.tokEnd(&outdent_tok);
|
||||||
self.tokens.append(self.allocator, outdent_tok) catch return false;
|
self.tokens.append(self.allocator, outdent_tok) catch return false;
|
||||||
@@ -2211,34 +2231,34 @@ pub const Lexer = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Outdent
|
// Outdent
|
||||||
if (indents < self.indent_stack.items[0]) {
|
if (indents < self.currentIndent()) {
|
||||||
var outdent_count: usize = 0;
|
var outdent_count: usize = 0;
|
||||||
while (self.indent_stack.items[0] > indents) {
|
while (self.currentIndent() > indents) {
|
||||||
if (self.indent_stack.items.len > 1 and self.indent_stack.items[1] < indents) {
|
if (self.indent_stack.items.len > 1 and self.previousIndent() < indents) {
|
||||||
self.setError(.INCONSISTENT_INDENTATION, "Inconsistent indentation");
|
self.setError(.INCONSISTENT_INDENTATION, "Inconsistent indentation");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
outdent_count += 1;
|
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) {
|
while (outdent_count > 0) : (outdent_count -= 1) {
|
||||||
self.colno = 1;
|
self.colno = 1;
|
||||||
var outdent_token = self.tok(.outdent, .none);
|
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.tokens.append(self.allocator, outdent_token) catch return false;
|
||||||
self.tokEnd(&outdent_token);
|
self.tokEnd(&outdent_token);
|
||||||
}
|
}
|
||||||
} else if (indents > 0 and indents != self.indent_stack.items[0]) {
|
} else if (indents > 0 and indents != self.currentIndent()) {
|
||||||
// Indent
|
// Indent
|
||||||
var indent_token = self.tok(.indent, .none);
|
var indent_token = self.tok(.indent, .none);
|
||||||
self.colno = 1 + indents;
|
self.colno = 1 + indents;
|
||||||
self.tokens.append(self.allocator, indent_token) catch return false;
|
self.tokens.append(self.allocator, indent_token) catch return false;
|
||||||
self.tokEnd(&indent_token);
|
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 {
|
} else {
|
||||||
// Newline
|
// Newline
|
||||||
var newline_token = self.tok(.newline, .none);
|
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.tokens.append(self.allocator, newline_token) catch return false;
|
||||||
self.tokEnd(&newline_token);
|
self.tokEnd(&newline_token);
|
||||||
}
|
}
|
||||||
@@ -2253,7 +2273,7 @@ pub const Lexer = struct {
|
|||||||
const captures = self.scanIndentation() orelse return false;
|
const captures = self.scanIndentation() orelse return false;
|
||||||
const indents = forced_indents orelse captures.indent.len;
|
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);
|
var start_token = self.tok(.start_pipeless_text, .none);
|
||||||
self.tokEnd(&start_token);
|
self.tokEnd(&start_token);
|
||||||
@@ -2307,7 +2327,7 @@ pub const Lexer = struct {
|
|||||||
else
|
else
|
||||||
"";
|
"";
|
||||||
tokens_list.append(self.allocator, text_content) catch return false;
|
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
|
// line is indented less than the first line but is still indented
|
||||||
// need to retry lexing the text block with new indent level
|
// need to retry lexing the text block with new indent level
|
||||||
_ = self.tokens.pop();
|
_ = self.tokens.pop();
|
||||||
|
|||||||
37
src/load.zig
37
src/load.zig
@@ -98,6 +98,7 @@ pub const LoadError = error{
|
|||||||
ParseError,
|
ParseError,
|
||||||
WalkError,
|
WalkError,
|
||||||
InvalidUtf8,
|
InvalidUtf8,
|
||||||
|
PathEscapesRoot,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -121,7 +122,33 @@ pub const LoadResult = struct {
|
|||||||
// Default Implementations
|
// 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
|
/// Default path resolution - handles relative and absolute paths
|
||||||
|
/// Rejects paths that would escape the base directory.
|
||||||
pub fn defaultResolve(
|
pub fn defaultResolve(
|
||||||
filename: []const u8,
|
filename: []const u8,
|
||||||
source: ?[]const u8,
|
source: ?[]const u8,
|
||||||
@@ -133,6 +160,11 @@ pub fn defaultResolve(
|
|||||||
return error.InvalidPath;
|
return error.InvalidPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: reject paths that escape root
|
||||||
|
if (!isPathSafe(trimmed)) {
|
||||||
|
return error.PathEscapesRoot;
|
||||||
|
}
|
||||||
|
|
||||||
// Absolute path (starts with /)
|
// Absolute path (starts with /)
|
||||||
if (trimmed[0] == '/') {
|
if (trimmed[0] == '/') {
|
||||||
if (options.basedir == null) {
|
if (options.basedir == null) {
|
||||||
@@ -369,10 +401,11 @@ test "pathJoin - absolute paths" {
|
|||||||
try std.testing.expectEqualStrings("/absolute/path.pug", result);
|
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 options = LoadOptions{};
|
||||||
const result = defaultResolve("/absolute/path.pug", null, &options);
|
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" {
|
test "defaultResolve - missing filename for relative path" {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
pub const pug = @import("pug.zig");
|
pub const pug = @import("pug.zig");
|
||||||
pub const view_engine = @import("view_engine.zig");
|
pub const view_engine = @import("view_engine.zig");
|
||||||
pub const template = @import("template.zig");
|
pub const template = @import("template.zig");
|
||||||
|
pub const parser = @import("parser.zig");
|
||||||
|
|
||||||
// Re-export main types
|
// Re-export main types
|
||||||
pub const ViewEngine = view_engine.ViewEngine;
|
pub const ViewEngine = view_engine.ViewEngine;
|
||||||
|
|||||||
@@ -89,66 +89,60 @@ pub const AttrValue = union(enum) {
|
|||||||
/// Returns empty string for false/null values.
|
/// Returns empty string for false/null values.
|
||||||
/// For true values, returns terse form " key" or full form " key="key"".
|
/// 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 {
|
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) {
|
switch (val) {
|
||||||
.none => return try allocator.dupe(u8, ""),
|
.none => return,
|
||||||
.boolean => |b| {
|
.boolean => |b| {
|
||||||
if (!b) return try allocator.dupe(u8, "");
|
if (!b) return;
|
||||||
// true value
|
// true value
|
||||||
if (terse) {
|
try output.append(allocator, ' ');
|
||||||
var result: ArrayListUnmanaged(u8) = .{};
|
try output.appendSlice(allocator, key);
|
||||||
errdefer result.deinit(allocator);
|
if (!terse) {
|
||||||
try result.append(allocator, ' ');
|
try output.appendSlice(allocator, "=\"");
|
||||||
try result.appendSlice(allocator, key);
|
try output.appendSlice(allocator, key);
|
||||||
return try result.toOwnedSlice(allocator);
|
try output.append(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);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
.number => |n| {
|
.number => |n| {
|
||||||
var result: ArrayListUnmanaged(u8) = .{};
|
try output.append(allocator, ' ');
|
||||||
errdefer result.deinit(allocator);
|
try output.appendSlice(allocator, key);
|
||||||
try result.append(allocator, ' ');
|
try output.appendSlice(allocator, "=\"");
|
||||||
try result.appendSlice(allocator, key);
|
|
||||||
try result.appendSlice(allocator, "=\"");
|
|
||||||
|
|
||||||
// Format number
|
// Format number directly to buffer
|
||||||
var buf: [32]u8 = undefined;
|
var buf: [32]u8 = undefined;
|
||||||
const num_str = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return error.FormatError;
|
const num_str = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return;
|
||||||
try result.appendSlice(allocator, num_str);
|
try output.appendSlice(allocator, num_str);
|
||||||
|
|
||||||
try result.append(allocator, '"');
|
try output.append(allocator, '"');
|
||||||
return try result.toOwnedSlice(allocator);
|
|
||||||
},
|
},
|
||||||
.string => |s| {
|
.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"))) {
|
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) = .{};
|
try output.append(allocator, ' ');
|
||||||
errdefer result.deinit(allocator);
|
try output.appendSlice(allocator, key);
|
||||||
|
try output.appendSlice(allocator, "=\"");
|
||||||
try result.append(allocator, ' ');
|
|
||||||
try result.appendSlice(allocator, key);
|
|
||||||
try result.appendSlice(allocator, "=\"");
|
|
||||||
|
|
||||||
if (escaped) {
|
if (escaped) {
|
||||||
const escaped_val = try escape(allocator, s);
|
try appendEscaped(allocator, output, s);
|
||||||
defer allocator.free(escaped_val);
|
|
||||||
try result.appendSlice(allocator, escaped_val);
|
|
||||||
} else {
|
} else {
|
||||||
try result.appendSlice(allocator, s);
|
try output.appendSlice(allocator, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
try result.append(allocator, '"');
|
try output.append(allocator, '"');
|
||||||
return try result.toOwnedSlice(allocator);
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
498
src/template.zig
498
src/template.zig
@@ -10,6 +10,8 @@ const pug = @import("pug.zig");
|
|||||||
const parser = @import("parser.zig");
|
const parser = @import("parser.zig");
|
||||||
const Node = parser.Node;
|
const Node = parser.Node;
|
||||||
const runtime = @import("runtime.zig");
|
const runtime = @import("runtime.zig");
|
||||||
|
const mixin_mod = @import("mixin.zig");
|
||||||
|
pub const MixinRegistry = mixin_mod.MixinRegistry;
|
||||||
|
|
||||||
pub const TemplateError = error{
|
pub const TemplateError = error{
|
||||||
OutOfMemory,
|
OutOfMemory,
|
||||||
@@ -17,10 +19,40 @@ pub const TemplateError = error{
|
|||||||
ParserError,
|
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 {
|
pub const RenderContext = struct {
|
||||||
/// true = HTML5 terse mode (default), false = XHTML mode
|
/// true = HTML5 terse mode (default), false = XHTML mode
|
||||||
terse: bool = true,
|
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
|
/// Render a template with data
|
||||||
@@ -36,10 +68,10 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) !
|
|||||||
defer stripped.deinit(allocator);
|
defer stripped.deinit(allocator);
|
||||||
|
|
||||||
// Parse
|
// Parse
|
||||||
var parse = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source);
|
var pug_parser = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source);
|
||||||
defer parse.deinit();
|
defer pug_parser.deinit();
|
||||||
|
|
||||||
const ast = parse.parse() catch {
|
const ast = pug_parser.parse() catch {
|
||||||
return error.ParserError;
|
return error.ParserError;
|
||||||
};
|
};
|
||||||
defer {
|
defer {
|
||||||
@@ -47,7 +79,12 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) !
|
|||||||
allocator.destroy(ast);
|
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){};
|
var output = std.ArrayListUnmanaged(u8){};
|
||||||
errdefer output.deinit(allocator);
|
errdefer output.deinit(allocator);
|
||||||
|
|
||||||
@@ -60,6 +97,78 @@ pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) !
|
|||||||
return output.toOwnedSlice(allocator);
|
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
|
/// Scan AST for doctype and set terse mode accordingly
|
||||||
fn detectDoctype(node: *Node, ctx: *RenderContext) void {
|
fn detectDoctype(node: *Node, ctx: *RenderContext) void {
|
||||||
if (node.type == .Doctype) {
|
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 {
|
fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
.Block, .NamedBlock => {
|
.Block, .NamedBlock => {
|
||||||
@@ -100,12 +226,14 @@ fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *
|
|||||||
.BlockComment => try renderBlockComment(allocator, output, node, data, ctx),
|
.BlockComment => try renderBlockComment(allocator, output, node, data, ctx),
|
||||||
.Doctype => try renderDoctype(allocator, output, node),
|
.Doctype => try renderDoctype(allocator, output, node),
|
||||||
.Each => try renderEach(allocator, output, node, data, ctx),
|
.Each => try renderEach(allocator, output, node, data, ctx),
|
||||||
.Mixin => {
|
.Mixin => try renderMixin(allocator, output, node, data, ctx),
|
||||||
// Mixin definitions are skipped (only mixin calls render)
|
.MixinBlock => {
|
||||||
if (!node.call) return;
|
// Render the block content passed to the mixin
|
||||||
for (node.nodes.items) |child| {
|
if (ctx.mixin_block) |block| {
|
||||||
|
for (block.nodes.items) |child| {
|
||||||
try renderNode(allocator, output, child, data, ctx);
|
try renderNode(allocator, output, child, data, ctx);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
else => {
|
else => {
|
||||||
for (node.nodes.items) |child| {
|
for (node.nodes.items) |child| {
|
||||||
@@ -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 {
|
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 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, "<");
|
||||||
try output.appendSlice(allocator, name);
|
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| {
|
for (tag.attrs.items) |attr| {
|
||||||
const attr_val = try evaluateAttrValue(allocator, attr.val, data);
|
// Substitute mixin arguments in attribute value if we're inside a mixin
|
||||||
const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) {
|
const final_val = if (ctx.arg_bindings) |bindings|
|
||||||
error.FormatError => return error.OutOfMemory,
|
substituteArgValue(attr.val, bindings)
|
||||||
error.OutOfMemory => return error.OutOfMemory,
|
else
|
||||||
};
|
attr.val;
|
||||||
defer allocator.free(attr_str);
|
const attr_val = try evaluateAttrValue(allocator, final_val, data);
|
||||||
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, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-closing logic differs by mode:
|
// Self-closing logic differs by mode:
|
||||||
@@ -152,24 +317,68 @@ fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *No
|
|||||||
|
|
||||||
try output.appendSlice(allocator, ">");
|
try output.appendSlice(allocator, ">");
|
||||||
|
|
||||||
// Render text content
|
// Render text content (with mixin argument substitution if applicable)
|
||||||
if (tag.val) |val| {
|
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
|
// 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| {
|
for (tag.nodes.items) |child| {
|
||||||
try renderNode(allocator, output, child, data, ctx);
|
try renderNode(allocator, output, child, data, &child_ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close tag
|
// Close tag
|
||||||
if (!is_self_closing) {
|
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, "</");
|
try output.appendSlice(allocator, "</");
|
||||||
try output.appendSlice(allocator, name);
|
try output.appendSlice(allocator, name);
|
||||||
try output.appendSlice(allocator, ">");
|
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
|
/// Evaluate attribute value from AST to runtime.AttrValue
|
||||||
fn evaluateAttrValue(allocator: Allocator, val: ?[]const u8, data: anytype) !runtime.AttrValue {
|
fn evaluateAttrValue(allocator: Allocator, val: ?[]const u8, data: anytype) !runtime.AttrValue {
|
||||||
_ = allocator;
|
_ = allocator;
|
||||||
@@ -211,6 +420,21 @@ fn renderCode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), code: *
|
|||||||
} else {
|
} else {
|
||||||
try output.appendSlice(allocator, inner);
|
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| {
|
} else if (getFieldValue(data, val)) |value| {
|
||||||
if (code.must_escape) {
|
if (code.must_escape) {
|
||||||
try runtime.appendEscaped(allocator, output, value);
|
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 {
|
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 collection_name = each.obj orelse return;
|
||||||
const item_name = each.val orelse "item";
|
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 CollType = @TypeOf(collection);
|
||||||
const coll_info = @typeInfo(CollType);
|
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| {
|
for (collection) |item| {
|
||||||
const ItemType = @TypeOf(item);
|
const ItemType = @TypeOf(item);
|
||||||
if (ItemType == []const u8) {
|
if (ItemType == []const u8) {
|
||||||
|
// Simple string item - use renderNodeWithItem
|
||||||
for (each.nodes.items) |child| {
|
for (each.nodes.items) |child| {
|
||||||
try renderNodeWithItem(allocator, output, child, data, item, ctx);
|
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 {
|
} else {
|
||||||
|
// Other types - skip
|
||||||
for (each.nodes.items) |child| {
|
for (each.nodes.items) |child| {
|
||||||
try renderNode(allocator, output, child, data, ctx);
|
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, "<");
|
||||||
try output.appendSlice(allocator, name);
|
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| {
|
for (tag.attrs.items) |attr| {
|
||||||
const attr_val = try evaluateAttrValue(allocator, attr.val, data);
|
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,
|
// Collect class attributes for merging
|
||||||
error.OutOfMemory => return error.OutOfMemory,
|
if (std.mem.eql(u8, attr.name, "class")) {
|
||||||
};
|
switch (attr_val) {
|
||||||
defer allocator.free(attr_str);
|
.string => |s| if (s.len > 0) try class_parts.append(allocator, s),
|
||||||
try output.appendSlice(allocator, attr_str);
|
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);
|
const is_void = isSelfClosing(name);
|
||||||
@@ -681,3 +1067,57 @@ test "nested tags with data" {
|
|||||||
|
|
||||||
try std.testing.expectEqualStrings("<div><h1>Welcome</h1><p>Hello there!</p></div>", html);
|
try std.testing.expectEqualStrings("<div><h1>Welcome</h1><p>Hello there!</p></div>", 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 =
|
||||||
|
\\<div>
|
||||||
|
\\ <h1>Title</h1>
|
||||||
|
\\ <p>Content</p>
|
||||||
|
\\</div>
|
||||||
|
;
|
||||||
|
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 =
|
||||||
|
\\<html>
|
||||||
|
\\ <body>
|
||||||
|
\\ <div>
|
||||||
|
\\ <p>Hello</p>
|
||||||
|
\\ </div>
|
||||||
|
\\ </body>
|
||||||
|
\\</html>
|
||||||
|
;
|
||||||
|
try std.testing.expectEqualStrings(expected, html);
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ test "Link with class and href (space separated)" {
|
|||||||
try expectOutput(
|
try expectOutput(
|
||||||
"a(class='button' href='//google.com') Google",
|
"a(class='button' href='//google.com') Google",
|
||||||
.{},
|
.{},
|
||||||
"<a class=\"button\" href=\"//google.com\">Google</a>",
|
"<a href=\"//google.com\" class=\"button\">Google</a>",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ test "Link with class and href (comma separated)" {
|
|||||||
try expectOutput(
|
try expectOutput(
|
||||||
"a(class='button', href='//google.com') Google",
|
"a(class='button', href='//google.com') Google",
|
||||||
.{},
|
.{},
|
||||||
"<a class=\"button\" href=\"//google.com\">Google</a>",
|
"<a href=\"//google.com\" class=\"button\">Google</a>",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
src/tests/test_includes.zig
Normal file
24
src/tests/test_includes.zig
Normal file
@@ -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});
|
||||||
|
}
|
||||||
8
src/tests/test_views/home.pug
Normal file
8
src/tests/test_views/home.pug
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
include mixins/_buttons.pug
|
||||||
|
include mixins/_cards.pug
|
||||||
|
|
||||||
|
doctype html
|
||||||
|
html
|
||||||
|
body
|
||||||
|
+primary-button("Click me")
|
||||||
|
+card("Title", "content here")
|
||||||
2
src/tests/test_views/mixins/_buttons.pug
Normal file
2
src/tests/test_views/mixins/_buttons.pug
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
mixin btn(text)
|
||||||
|
button.btn= text
|
||||||
4
src/tests/test_views/mixins/_cards.pug
Normal file
4
src/tests/test_views/mixins/_cards.pug
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mixin card(title, content)
|
||||||
|
.card
|
||||||
|
.card-header= title
|
||||||
|
.card-body= content
|
||||||
@@ -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.
|
// 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:
|
// Usage:
|
||||||
// const engine = ViewEngine.init(.{ .views_dir = "views" });
|
// var engine = try ViewEngine.init(allocator, .{ .views_dir = "views" });
|
||||||
// const html = try engine.render(allocator, "pages/home", .{ .title = "Home" });
|
// 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 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 {
|
pub const Options = struct {
|
||||||
/// Root directory containing view templates
|
/// Root directory containing view templates (all paths relative to this)
|
||||||
views_dir: []const u8 = "views",
|
views_dir: []const u8 = "views",
|
||||||
/// File extension for templates
|
/// File extension for templates
|
||||||
extension: []const u8 = ".pug",
|
extension: []const u8 = ".pug",
|
||||||
/// Enable pretty-printing with indentation
|
/// Enable pretty-printing with indentation and newlines
|
||||||
pretty: bool = true,
|
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 {
|
pub const ViewEngine = struct {
|
||||||
options: Options,
|
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 {
|
pub fn init(allocator: std.mem.Allocator, options: Options) ViewEngineError!ViewEngine {
|
||||||
return .{ .options = options };
|
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.
|
/// Renders a template file with the given data context.
|
||||||
/// Template path is relative to views_dir, extension added automatically.
|
/// 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 {
|
/// Processes includes and resolves mixin calls.
|
||||||
_ = data; // TODO: pass data to template
|
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
|
// Get or parse the main AST and process includes
|
||||||
const full_path = try self.resolvePath(allocator, template_path);
|
const ast = try self.getOrParseWithIncludes(template_path, ®istry);
|
||||||
defer allocator.free(full_path);
|
|
||||||
|
|
||||||
// Compile the template
|
// Render the AST with mixin registry - mixins are expanded inline during rendering
|
||||||
var result = pug.compileFile(allocator, full_path, .{
|
return template.renderAstWithMixinsAndOptions(allocator, ast, data, ®istry, .{
|
||||||
.pretty = self.options.pretty,
|
.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;
|
return err;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (result.err) |*e| {
|
// For pug includes, inline the content into the node
|
||||||
e.deinit();
|
if (node.type == .Include) {
|
||||||
return error.ParseError;
|
// 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.html;
|
// Recurse into children
|
||||||
|
for (node.nodes.items) |child| {
|
||||||
|
try self.processIncludes(child, registry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a template path relative to views directory
|
/// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace blocks in parent with child blocks
|
||||||
|
self.replaceBlocks(parent_ast, &child_blocks);
|
||||||
|
|
||||||
|
return parent_ast;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
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))
|
const with_ext = if (std.mem.endsWith(u8, template_path, self.options.extension))
|
||||||
try allocator.dupe(u8, template_path)
|
try allocator.dupe(u8, template_path)
|
||||||
else
|
else
|
||||||
@@ -63,3 +455,54 @@ pub const ViewEngine = struct {
|
|||||||
return std.fs.path.join(allocator, &.{ self.options.views_dir, with_ext });
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user