Initial commit: Pugz - Pug-like HTML template engine in Zig

Features:
- Lexer with indentation tracking and raw text block support
- Parser producing AST from token stream
- Runtime with variable interpolation, conditionals, loops
- Mixin support (params, defaults, rest args, block content, attributes)
- Template inheritance (extends/block/append/prepend)
- Plain text (piped, dot blocks, literal HTML)
- Tag interpolation (#[tag text])
- Block expansion with colon
- Self-closing tags (void elements + explicit /)
- Case/when statements
- Comments (rendered and silent)

All 113 tests passing.
This commit is contained in:
2026-01-17 18:32:29 +05:30
parent 71f4ec4ffc
commit 6ab3f14897
28 changed files with 7693 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"mcp__acp__Bash",
"mcp__acp__Write",
"mcp__acp__Edit"
]
}
}

28
.editorconfig Normal file
View File

@@ -0,0 +1,28 @@
# EditorConfig is awesome: https://editorconfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
# Matches multiple files with brace expansion notation
# Set default charset
[*.{zig,pug}]
charset = utf-8
# 2 space indentation
[*.pug]
indent_style = space
indent_size = 2
# Tab indentation (no size specified)
[Makefile]
indent_style = tab
# Matches the exact files either package.json or .travis.yml
[{package.json,.travis.yml}]
indent_style = space
indent_size = 2

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Zig build artifacts
zig-out/
zig-cache/
.zig-cache/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db

301
CLAUDE.md Normal file
View File

@@ -0,0 +1,301 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Purpose
Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It implements Pug 3 syntax for indentation-based HTML templating with a focus on server-side rendering.
## Build Commands
- `zig build` - Build the project (output in `zig-out/`)
- `zig build run` - Build and run the executable
- `zig build test` - Run all tests (113 tests currently)
## Architecture Overview
The template engine follows a classic compiler pipeline:
```
Source → Lexer → Tokens → Parser → AST → Runtime → HTML
```
### Core Modules
| Module | Purpose |
|--------|---------|
| **src/lexer.zig** | Tokenizes Pug source into tokens. Handles indentation tracking, raw text blocks, interpolation. |
| **src/parser.zig** | Converts token stream into AST. Handles nesting via indent/dedent tokens. |
| **src/ast.zig** | AST node definitions (Element, Text, Conditional, Each, Mixin, etc.) |
| **src/runtime.zig** | Evaluates AST with data context, produces final HTML. Handles variable interpolation, conditionals, loops, mixins. |
| **src/codegen.zig** | Static HTML generation (without runtime evaluation). Outputs placeholders for dynamic content. |
| **src/root.zig** | Public library API - exports `renderTemplate()` and core types. |
| **src/main.zig** | CLI executable example. |
### Test Files
- **src/tests/general_test.zig** - Comprehensive integration tests for all features
## Memory Management
**Important**: The runtime is designed to work with `ArenaAllocator`:
```zig
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit(); // Frees all template memory at once
const html = try pugz.renderTemplate(arena.allocator(), template, data);
```
This pattern is recommended because template rendering creates many small allocations that are all freed together after the response is sent.
## Key Implementation Details
### Lexer State Machine
The lexer tracks several states for handling complex syntax:
- `in_raw_block` / `raw_block_indent` / `raw_block_started` - For dot block text (e.g., `script.`)
- `indent_stack` - Stack-based indent/dedent token generation
### Token Types
Key tokens: `tag`, `class`, `id`, `lparen`, `rparen`, `attr_name`, `attr_value`, `text`, `interp_start`, `interp_end`, `indent`, `dedent`, `dot_block`, `pipe_text`, `literal_html`, `self_close`, `mixin_call`, etc.
### AST Node Types
- `element` - HTML elements with tag, classes, id, attributes, children
- `text` - Text with segments (literal, escaped interpolation, unescaped interpolation, tag interpolation)
- `conditional` - if/else if/else/unless branches
- `each` - Iteration with value, optional index, else branch
- `mixin_def` / `mixin_call` - Mixin definitions and invocations
- `block` - Named blocks for template inheritance
- `include` / `extends` - File inclusion and inheritance
- `raw_text` - Literal HTML or text blocks
### Runtime Value System
```zig
pub const Value = union(enum) {
null,
bool: bool,
int: i64,
float: f64,
string: []const u8,
array: []const Value,
object: std.StringHashMapUnmanaged(Value),
};
```
The `toValue()` function converts Zig structs to runtime Values automatically.
## Supported Pug Features
### Tags & Nesting
```pug
div
h1 Title
p Paragraph
```
### Classes & IDs (shorthand)
```pug
div#main.container.active
.box // defaults to div
#sidebar // defaults to div
```
### Attributes
```pug
a(href="/link" target="_blank") Click
input(type="checkbox" checked)
div(style={color: 'red'})
div(class=['foo', 'bar'])
button(disabled=false) // omitted when false
button(disabled=true) // disabled="disabled"
```
### Text & Interpolation
```pug
p Hello #{name} // escaped interpolation
p Hello !{rawHtml} // unescaped interpolation
p= variable // buffered code (escaped)
p!= rawVariable // buffered code (unescaped)
| Piped text line
p.
Multi-line
text block
<p>Literal HTML</p> // passed through as-is
```
### Tag Interpolation
```pug
p This is #[em emphasized] text
p Click #[a(href="/") here] to continue
```
### Block Expansion
```pug
a: img(src="logo.png") // colon for inline nesting
```
### Explicit Self-Closing
```pug
foo/ // renders as <foo />
```
### Conditionals
```pug
if condition
p Yes
else if other
p Maybe
else
p No
unless loggedIn
p Please login
```
### Iteration
```pug
each item in items
li= item
each val, index in list
li #{index}: #{val}
each item in items
li= item
else
li No items
// Works with objects too (key as index)
each val, key in object
p #{key}: #{val}
```
### Case/When
```pug
case status
when "active"
p Active
when "pending"
p Pending
default
p Unknown
```
### Mixins
```pug
mixin button(text, type="primary")
button(class="btn btn-" + type)= text
+button("Click me")
+button("Submit", "success")
// With block content
mixin card(title)
.card
h3= title
block
+card("My Card")
p Card content here
// Rest arguments
mixin list(id, ...items)
ul(id=id)
each item in items
li= item
+list("mylist", "a", "b", "c")
// Attributes pass-through
mixin link(href, text)
a(href=href)&attributes(attributes)= text
+link("/home", "Home")(class="nav-link" data-id="1")
```
### Includes & Inheritance
```pug
include header.pug
extends layout.pug
block content
h1 Page Title
// Block modes
block append scripts
script(src="extra.js")
block prepend styles
link(rel="stylesheet" href="extra.css")
```
### Comments
```pug
// This renders as HTML comment
//- This is a silent comment (not in output)
```
## Server Usage Example
```zig
const std = @import("std");
const pugz = @import("pugz");
pub fn handleRequest(allocator: std.mem.Allocator) ![]u8 {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
return try pugz.renderTemplate(arena.allocator(),
\\html
\\ head
\\ title= title
\\ body
\\ h1 Hello, #{name}!
\\ if showList
\\ ul
\\ each item in items
\\ li= item
, .{
.title = "My Page",
.name = "World",
.showList = true,
.items = &[_][]const u8{ "One", "Two", "Three" },
});
}
```
## Testing
Run tests with `zig build test`. Tests cover:
- Basic element parsing and rendering
- Class and ID shorthand syntax
- Attribute parsing (quoted, unquoted, boolean, object literals)
- Text interpolation (escaped, unescaped, tag interpolation)
- Conditionals (if/else if/else/unless)
- Iteration (each with index, else branch, objects)
- Case/when statements
- Mixin definitions and calls (with defaults, rest args, block content, attributes)
- Plain text (piped, dot blocks, literal HTML)
- Self-closing tags (void elements, explicit `/`)
- Block expansion with colon
- Comments (rendered and silent)
## Error Handling
The lexer and parser return errors for invalid syntax:
- `ParserError.UnexpectedToken`
- `ParserError.MissingCondition`
- `ParserError.MissingMixinName`
- `RuntimeError.ParseError` (wrapped for convenience API)
## Future Improvements
Potential areas for enhancement:
- Filter support (`:markdown`, `:stylus`, etc.)
- More complete JavaScript expression evaluation
- Source maps for debugging
- Compile-time template validation

161
build.zig Normal file
View File

@@ -0,0 +1,161 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const mod = b.addModule("pugz", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
});
const exe = b.addExecutable(.{
.name = "pugz",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "pugz", .module = mod },
},
}),
});
b.installArtifact(exe);
const run_step = b.step("run", "Run the app");
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
// By making the run step depend on the default step, it will be run from the
// installation directory rather than directly from within the cache directory.
run_cmd.step.dependOn(b.getInstallStep());
// This allows the user to pass arguments to the application in the build
// command itself, like this: `zig build run -- arg1 arg2 etc`
if (b.args) |args| {
run_cmd.addArgs(args);
}
// Creates an executable that will run `test` blocks from the provided module.
// Here `mod` needs to define a target, which is why earlier we made sure to
// set the releative field.
const mod_tests = b.addTest(.{
.root_module = mod,
});
// A run step that will run the test executable.
const run_mod_tests = b.addRunArtifact(mod_tests);
// Creates an executable that will run `test` blocks from the executable's
// root module. Note that test executables only test one module at a time,
// hence why we have to create two separate ones.
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
// A run step that will run the second test executable.
const run_exe_tests = b.addRunArtifact(exe_tests);
// Integration tests - general template tests
const general_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/tests/general_test.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "pugz", .module = mod },
},
}),
});
const run_general_tests = b.addRunArtifact(general_tests);
// Integration tests - doctype tests
const doctype_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/tests/doctype_test.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "pugz", .module = mod },
},
}),
});
const run_doctype_tests = b.addRunArtifact(doctype_tests);
// Integration tests - inheritance tests
const inheritance_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/tests/inheritance_test.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "pugz", .module = mod },
},
}),
});
const run_inheritance_tests = b.addRunArtifact(inheritance_tests);
// A top level step for running all tests. dependOn can be called multiple
// times and since the two run steps do not depend on one another, this will
// make the two of them run in parallel.
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
test_step.dependOn(&run_general_tests.step);
test_step.dependOn(&run_doctype_tests.step);
test_step.dependOn(&run_inheritance_tests.step);
// Individual test steps
const test_general_step = b.step("test-general", "Run general template tests");
test_general_step.dependOn(&run_general_tests.step);
const test_doctype_step = b.step("test-doctype", "Run doctype tests");
test_doctype_step.dependOn(&run_doctype_tests.step);
const test_inheritance_step = b.step("test-inheritance", "Run inheritance tests");
test_inheritance_step.dependOn(&run_inheritance_tests.step);
const test_unit_step = b.step("test-unit", "Run unit tests (lexer, parser, etc.)");
test_unit_step.dependOn(&run_mod_tests.step);
// ─────────────────────────────────────────────────────────────────────────
// Example: app_01 - Template Inheritance Demo with http.zig
// ─────────────────────────────────────────────────────────────────────────
const httpz_dep = b.dependency("httpz", .{
.target = target,
.optimize = optimize,
});
const app_01 = b.addExecutable(.{
.name = "app_01",
.root_module = b.createModule(.{
.root_source_file = b.path("src/examples/app_01/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "pugz", .module = mod },
.{ .name = "httpz", .module = httpz_dep.module("httpz") },
},
}),
});
b.installArtifact(app_01);
const run_app_01 = b.addRunArtifact(app_01);
run_app_01.step.dependOn(b.getInstallStep());
const app_01_step = b.step("app-01", "Run the template inheritance demo web app");
app_01_step.dependOn(&run_app_01.step);
// Just like flags, top level steps are also listed in the `--help` menu.
//
// The Zig build system is entirely implemented in userland, which means
// that it cannot hook into private compiler APIs. All compilation work
// orchestrated by the build system will result in other Zig compiler
// subcommands being invoked with the right flags defined. You can observe
// these invocations when one fails (or you pass a flag to increase
// verbosity) to validate assumptions and diagnose problems.
//
// Lastly, the Zig build system is relatively simple and self-contained,
// and reading its source code will allow you to master it.
}

48
build.zig.zon Normal file
View File

@@ -0,0 +1,48 @@
.{
// This is the default name used by packages depending on this one. For
// example, when a user runs `zig fetch --save <url>`, this field is used
// as the key in the `dependencies` table. Although the user can choose a
// different name, most users will stick with this provided value.
//
// It is redundant to include "zig" in this name because it is already
// within the Zig package namespace.
.name = .pugz,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.0.0",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
// unambiguous detection of one package being an updated version of
// another.
//
// When forking a Zig project, this id should be regenerated (delete the
// field and run `zig build`) if the upstream project is still maintained.
// Otherwise, the fork is *hostile*, attempting to take control over the
// original project's identity. Thus it is recommended to leave the comment
// on the following line intact, so that it shows up in code reviews that
// modify the field.
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
// Tracks the earliest Zig version that the package considers to be a
// supported use case.
.minimum_zig_version = "0.15.2",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
.httpz = .{
.url = "git+https://github.com/karlseguin/http.zig?ref=master#9ef2ffe8d611ff2e1081e5cf39cb4632c145c5b9",
.hash = "httpz-0.0.0-PNVzrIowBwAFr_kqBN1W4KBMC2Ofutasj2ZfNAIcfTzF",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
}

313
src/ast.zig Normal file
View File

@@ -0,0 +1,313 @@
//! AST (Abstract Syntax Tree) definitions for Pug templates.
//!
//! The AST represents the hierarchical structure of a Pug document.
//! Each node type corresponds to a Pug language construct.
const std = @import("std");
/// An attribute on an element: name, value, and whether it's escaped.
pub const Attribute = struct {
name: []const u8,
value: ?[]const u8, // null for boolean attributes (e.g., `checked`)
escaped: bool, // true for `=`, false for `!=`
};
/// A segment of text content, which may be plain text or interpolation.
pub const TextSegment = union(enum) {
/// Plain text content.
literal: []const u8,
/// Escaped interpolation: #{expr} - HTML entities escaped.
interp_escaped: []const u8,
/// Unescaped interpolation: !{expr} - raw HTML output.
interp_unescaped: []const u8,
/// Tag interpolation: #[tag text] - inline HTML element.
interp_tag: InlineTag,
};
/// Inline tag from tag interpolation syntax: #[em text] or #[a(href='/') link]
pub const InlineTag = struct {
/// Tag name (e.g., "em", "a", "strong").
tag: []const u8,
/// CSS classes from `.class` syntax.
classes: []const []const u8,
/// Element ID from `#id` syntax.
id: ?[]const u8,
/// Attributes from `(attr=value)` syntax.
attributes: []Attribute,
/// Text content (may contain nested interpolations).
text_segments: []TextSegment,
};
/// All AST node types.
pub const Node = union(enum) {
/// Root document node containing all top-level nodes.
document: Document,
/// Doctype declaration: `doctype html`.
doctype: Doctype,
/// HTML element with optional tag, classes, id, attributes, and children.
element: Element,
/// Text content (may contain interpolations).
text: Text,
/// Buffered code output: `= expr` (escaped) or `!= expr` (unescaped).
code: Code,
/// Comment: `//` (rendered) or `//-` (silent).
comment: Comment,
/// Conditional: if/else if/else/unless chains.
conditional: Conditional,
/// Each loop: `each item in collection` or `each item, index in collection`.
each: Each,
/// While loop: `while condition`.
@"while": While,
/// Case/switch statement.
case: Case,
/// Mixin definition: `mixin name(args)`.
mixin_def: MixinDef,
/// Mixin call: `+name(args)`.
mixin_call: MixinCall,
/// Mixin block placeholder: `block` inside a mixin.
mixin_block: void,
/// Include directive: `include path`.
include: Include,
/// Extends directive: `extends path`.
extends: Extends,
/// Named block: `block name`.
block: Block,
/// Raw text block (after `.` on element).
raw_text: RawText,
};
/// Root document containing all top-level nodes.
pub const Document = struct {
nodes: []Node,
/// Optional extends directive (must be first if present).
extends_path: ?[]const u8 = null,
};
/// Doctype declaration node.
pub const Doctype = struct {
/// The doctype value (e.g., "html", "xml", "strict", or custom string).
/// Empty string means default to "html".
value: []const u8,
};
/// HTML element node.
pub const Element = struct {
/// Tag name (defaults to "div" if only class/id specified).
tag: []const u8,
/// CSS classes from `.class` syntax.
classes: []const []const u8,
/// Element ID from `#id` syntax.
id: ?[]const u8,
/// Attributes from `(attr=value)` syntax.
attributes: []Attribute,
/// Spread attributes from `&attributes({...})` syntax.
spread_attributes: ?[]const u8 = null,
/// Child nodes (nested elements, text, etc.).
children: []Node,
/// Whether this is a self-closing tag.
self_closing: bool,
/// Inline text content (e.g., `p Hello`).
inline_text: ?[]TextSegment,
/// Buffered code content (e.g., `p= expr` or `p!= expr`).
buffered_code: ?Code = null,
};
/// Text content node.
pub const Text = struct {
/// Segments of text (literals and interpolations).
segments: []TextSegment,
/// Whether this is from pipe syntax `|`.
is_piped: bool,
};
/// Code output node: `= expr` or `!= expr`.
pub const Code = struct {
/// The expression to evaluate.
expression: []const u8,
/// Whether output is HTML-escaped.
escaped: bool,
};
/// Comment node.
pub const Comment = struct {
/// Comment text content.
content: []const u8,
/// Whether comment is rendered in output (`//`) or silent (`//-`).
rendered: bool,
/// Nested content (for block comments).
children: []Node,
};
/// Conditional node for if/else if/else/unless chains.
pub const Conditional = struct {
/// The condition branches in order.
branches: []Branch,
pub const Branch = struct {
/// Condition expression (null for `else`).
condition: ?[]const u8,
/// Whether this is `unless` (negated condition).
is_unless: bool,
/// Child nodes for this branch.
children: []Node,
};
};
/// Each loop node.
pub const Each = struct {
/// Iterator variable name.
value_name: []const u8,
/// Optional index variable name.
index_name: ?[]const u8,
/// Collection expression to iterate.
collection: []const u8,
/// Loop body nodes.
children: []Node,
/// Optional else branch (when collection is empty).
else_children: []Node,
};
/// While loop node.
pub const While = struct {
/// Loop condition expression.
condition: []const u8,
/// Loop body nodes.
children: []Node,
};
/// Case/switch node.
pub const Case = struct {
/// Expression to match against.
expression: []const u8,
/// When branches (in order, for fall-through support).
whens: []When,
/// Default branch children (if any).
default_children: []Node,
pub const When = struct {
/// Value to match.
value: []const u8,
/// Child nodes for this case. Empty means fall-through to next case.
children: []Node,
/// Explicit break (- break) means output nothing.
has_break: bool,
};
};
/// Mixin definition node.
pub const MixinDef = struct {
/// Mixin name.
name: []const u8,
/// Parameter names.
params: []const []const u8,
/// Default values for parameters (null if no default).
defaults: []?[]const u8,
/// Whether last param is rest parameter (...args).
has_rest: bool,
/// Mixin body nodes.
children: []Node,
};
/// Mixin call node.
pub const MixinCall = struct {
/// Mixin name to call.
name: []const u8,
/// Argument expressions.
args: []const []const u8,
/// Attributes passed to mixin.
attributes: []Attribute,
/// Block content passed to mixin.
block_children: []Node,
};
/// Include directive node.
pub const Include = struct {
/// Path to include.
path: []const u8,
/// Optional filter (e.g., `:markdown`).
filter: ?[]const u8,
};
/// Extends directive node.
pub const Extends = struct {
/// Path to parent template.
path: []const u8,
};
/// Named block node for template inheritance.
pub const Block = struct {
/// Block name.
name: []const u8,
/// Block mode: replace, append, or prepend.
mode: Mode,
/// Block content nodes.
children: []Node,
pub const Mode = enum {
replace,
append,
prepend,
};
};
/// Raw text block (from `.` syntax).
pub const RawText = struct {
/// Raw text content lines.
content: []const u8,
};
// ─────────────────────────────────────────────────────────────────────────────
// AST Builder Helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Creates an empty document node.
pub fn emptyDocument() Document {
return .{
.nodes = &.{},
.extends_path = null,
};
}
/// Creates a simple element with just a tag name.
pub fn simpleElement(tag: []const u8) Element {
return .{
.tag = tag,
.classes = &.{},
.id = null,
.attributes = &.{},
.children = &.{},
.self_closing = false,
.inline_text = null,
};
}
/// Creates a text node from a single literal string.
/// Note: The returned Text has a pointer to static memory for segments.
/// For dynamic text, allocate segments separately.
pub fn literalText(allocator: std.mem.Allocator, content: []const u8) !Text {
const segments = try allocator.alloc(TextSegment, 1);
segments[0] = .{ .literal = content };
return .{
.segments = segments,
.is_piped = false,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
test "create simple element" {
const elem = simpleElement("div");
try std.testing.expectEqualStrings("div", elem.tag);
try std.testing.expectEqual(@as(usize, 0), elem.children.len);
}
test "create literal text" {
const allocator = std.testing.allocator;
const text = try literalText(allocator, "Hello, world!");
defer allocator.free(text.segments);
try std.testing.expectEqual(@as(usize, 1), text.segments.len);
try std.testing.expectEqualStrings("Hello, world!", text.segments[0].literal);
}

780
src/codegen.zig Normal file
View File

@@ -0,0 +1,780 @@
//! Pugz Code Generator - Converts AST to HTML output.
//!
//! This module traverses the AST and generates HTML strings. It handles:
//! - Element rendering with tags, classes, IDs, and attributes
//! - Text content with interpolation placeholders
//! - Proper indentation for pretty-printed output
//! - Self-closing tags (void elements)
//! - Comment rendering
const std = @import("std");
const ast = @import("ast.zig");
/// Configuration options for code generation.
pub const Options = struct {
/// Enable pretty-printing with indentation and newlines.
pretty: bool = true,
/// Indentation string (spaces or tabs).
indent_str: []const u8 = " ",
/// Enable self-closing tag syntax for void elements.
self_closing: bool = true,
};
/// Errors that can occur during code generation.
pub const CodeGenError = error{
OutOfMemory,
};
/// HTML void elements that should not have closing tags.
const void_elements = std.StaticStringMap(void).initComptime(.{
.{ "area", {} },
.{ "base", {} },
.{ "br", {} },
.{ "col", {} },
.{ "embed", {} },
.{ "hr", {} },
.{ "img", {} },
.{ "input", {} },
.{ "link", {} },
.{ "meta", {} },
.{ "param", {} },
.{ "source", {} },
.{ "track", {} },
.{ "wbr", {} },
});
/// Whitespace-sensitive elements where pretty-printing should be disabled.
const whitespace_sensitive = std.StaticStringMap(void).initComptime(.{
.{ "pre", {} },
.{ "textarea", {} },
.{ "script", {} },
.{ "style", {} },
});
/// Code generator that converts AST to HTML.
pub const CodeGen = struct {
allocator: std.mem.Allocator,
options: Options,
output: std.ArrayListUnmanaged(u8),
depth: usize,
/// Track if we're inside a whitespace-sensitive element.
preserve_whitespace: bool,
/// Creates a new code generator with the given options.
pub fn init(allocator: std.mem.Allocator, options: Options) CodeGen {
return .{
.allocator = allocator,
.options = options,
.output = .empty,
.depth = 0,
.preserve_whitespace = false,
};
}
/// Releases allocated memory.
pub fn deinit(self: *CodeGen) void {
self.output.deinit(self.allocator);
}
/// Generates HTML from the given document AST.
/// Returns a slice of the generated HTML owned by the CodeGen.
pub fn generate(self: *CodeGen, doc: ast.Document) CodeGenError![]const u8 {
// Pre-allocate reasonable capacity
try self.output.ensureTotalCapacity(self.allocator, 1024);
for (doc.nodes) |node| {
try self.visitNode(node);
}
return self.output.items;
}
/// Generates HTML and returns an owned copy.
/// Caller must free the returned slice.
pub fn generateOwned(self: *CodeGen, doc: ast.Document) CodeGenError![]u8 {
const result = try self.generate(doc);
return try self.allocator.dupe(u8, result);
}
/// Visits a single AST node and generates corresponding HTML.
fn visitNode(self: *CodeGen, node: ast.Node) CodeGenError!void {
switch (node) {
.doctype => |dt| try self.visitDoctype(dt),
.element => |elem| try self.visitElement(elem),
.text => |text| try self.visitText(text),
.comment => |comment| try self.visitComment(comment),
.conditional => |cond| try self.visitConditional(cond),
.each => |each| try self.visitEach(each),
.@"while" => |whl| try self.visitWhile(whl),
.case => |c| try self.visitCase(c),
.mixin_def => {}, // Mixin definitions don't produce direct output
.mixin_call => |call| try self.visitMixinCall(call),
.mixin_block => {}, // Mixin block placeholder - handled at mixin call site
.include => |inc| try self.visitInclude(inc),
.extends => {}, // Handled at document level
.block => |blk| try self.visitBlock(blk),
.raw_text => |raw| try self.visitRawText(raw),
.code => |code| try self.visitCode(code),
.document => |doc| {
for (doc.nodes) |child| {
try self.visitNode(child);
}
},
}
}
/// Doctype shortcuts mapping
const doctype_shortcuts = std.StaticStringMap([]const u8).initComptime(.{
.{ "html", "<!DOCTYPE html>" },
.{ "xml", "<?xml version=\"1.0\" encoding=\"utf-8\" ?>" },
.{ "transitional", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">" },
.{ "strict", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">" },
.{ "frameset", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Frameset//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd\">" },
.{ "1.1", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">" },
.{ "basic", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML Basic 1.1//EN\" \"http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd\">" },
.{ "mobile", "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.2//EN\" \"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd\">" },
.{ "plist", "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">" },
});
/// Generates doctype declaration.
fn visitDoctype(self: *CodeGen, dt: ast.Doctype) CodeGenError!void {
if (doctype_shortcuts.get(dt.value)) |output| {
try self.write(output);
} else {
try self.write("<!DOCTYPE ");
try self.write(dt.value);
try self.write(">");
}
try self.writeNewline();
}
/// Generates HTML for an element node.
fn visitElement(self: *CodeGen, elem: ast.Element) CodeGenError!void {
const is_void = void_elements.has(elem.tag) or elem.self_closing;
const was_preserving = self.preserve_whitespace;
// Check if entering whitespace-sensitive element
if (whitespace_sensitive.has(elem.tag)) {
self.preserve_whitespace = true;
}
// Opening tag
try self.writeIndent();
try self.write("<");
try self.write(elem.tag);
// ID attribute
if (elem.id) |id| {
try self.write(" id=\"");
try self.writeEscaped(id);
try self.write("\"");
}
// Class attribute
if (elem.classes.len > 0) {
try self.write(" class=\"");
for (elem.classes, 0..) |class, i| {
if (i > 0) try self.write(" ");
try self.writeEscaped(class);
}
try self.write("\"");
}
// Other attributes
for (elem.attributes) |attr| {
try self.write(" ");
try self.write(attr.name);
if (attr.value) |value| {
try self.write("=\"");
if (attr.escaped) {
try self.writeEscaped(value);
} else {
try self.write(value);
}
try self.write("\"");
} else {
// Boolean attribute: checked -> checked="checked"
try self.write("=\"");
try self.write(attr.name);
try self.write("\"");
}
}
// Close opening tag
if (is_void and self.options.self_closing) {
try self.write(" />");
try self.writeNewline();
self.preserve_whitespace = was_preserving;
return;
}
try self.write(">");
// Inline text
const has_inline_text = elem.inline_text != null and elem.inline_text.?.len > 0;
const has_children = elem.children.len > 0;
if (has_inline_text) {
try self.writeTextSegments(elem.inline_text.?);
}
// Children
if (has_children) {
if (!self.preserve_whitespace) {
try self.writeNewline();
}
self.depth += 1;
for (elem.children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
if (!self.preserve_whitespace) {
try self.writeIndent();
}
}
// Closing tag (not for void elements)
if (!is_void) {
try self.write("</");
try self.write(elem.tag);
try self.write(">");
try self.writeNewline();
}
self.preserve_whitespace = was_preserving;
}
/// Generates output for a text node.
fn visitText(self: *CodeGen, text: ast.Text) CodeGenError!void {
try self.writeIndent();
try self.writeTextSegments(text.segments);
try self.writeNewline();
}
/// Generates HTML comment.
fn visitComment(self: *CodeGen, comment: ast.Comment) CodeGenError!void {
if (!comment.rendered) return;
try self.writeIndent();
try self.write("<!--");
if (comment.content.len > 0) {
try self.write(" ");
try self.write(comment.content);
try self.write(" ");
}
try self.write("-->");
try self.writeNewline();
}
/// Generates placeholder for conditional (runtime evaluation needed).
fn visitConditional(self: *CodeGen, cond: ast.Conditional) CodeGenError!void {
// Output each branch with placeholder comments
for (cond.branches, 0..) |branch, i| {
try self.writeIndent();
if (i == 0) {
if (branch.is_unless) {
try self.write("<!-- unless ");
} else {
try self.write("<!-- if ");
}
if (branch.condition) |condition| {
try self.write(condition);
}
try self.write(" -->");
} else if (branch.condition) |condition| {
try self.write("<!-- else if ");
try self.write(condition);
try self.write(" -->");
} else {
try self.write("<!-- else -->");
}
try self.writeNewline();
self.depth += 1;
for (branch.children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
}
try self.writeIndent();
try self.write("<!-- endif -->");
try self.writeNewline();
}
/// Generates placeholder for each loop (runtime evaluation needed).
fn visitEach(self: *CodeGen, each: ast.Each) CodeGenError!void {
try self.writeIndent();
try self.write("<!-- each ");
try self.write(each.value_name);
if (each.index_name) |idx| {
try self.write(", ");
try self.write(idx);
}
try self.write(" in ");
try self.write(each.collection);
try self.write(" -->");
try self.writeNewline();
self.depth += 1;
for (each.children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
if (each.else_children.len > 0) {
try self.writeIndent();
try self.write("<!-- else -->");
try self.writeNewline();
self.depth += 1;
for (each.else_children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
}
try self.writeIndent();
try self.write("<!-- endeach -->");
try self.writeNewline();
}
/// Generates placeholder for while loop (runtime evaluation needed).
fn visitWhile(self: *CodeGen, whl: ast.While) CodeGenError!void {
try self.writeIndent();
try self.write("<!-- while ");
try self.write(whl.condition);
try self.write(" -->");
try self.writeNewline();
self.depth += 1;
for (whl.children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
try self.writeIndent();
try self.write("<!-- endwhile -->");
try self.writeNewline();
}
/// Generates placeholder for case statement (runtime evaluation needed).
fn visitCase(self: *CodeGen, c: ast.Case) CodeGenError!void {
try self.writeIndent();
try self.write("<!-- case ");
try self.write(c.expression);
try self.write(" -->");
try self.writeNewline();
for (c.whens) |when| {
try self.writeIndent();
try self.write("<!-- when ");
try self.write(when.value);
try self.write(" -->");
try self.writeNewline();
self.depth += 1;
for (when.children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
}
if (c.default_children.len > 0) {
try self.writeIndent();
try self.write("<!-- default -->");
try self.writeNewline();
self.depth += 1;
for (c.default_children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
}
try self.writeIndent();
try self.write("<!-- endcase -->");
try self.writeNewline();
}
/// Generates placeholder for mixin call (runtime evaluation needed).
fn visitMixinCall(self: *CodeGen, call: ast.MixinCall) CodeGenError!void {
try self.writeIndent();
try self.write("<!-- +");
try self.write(call.name);
try self.write(" -->");
try self.writeNewline();
}
/// Generates placeholder for include (file loading needed).
fn visitInclude(self: *CodeGen, inc: ast.Include) CodeGenError!void {
try self.writeIndent();
try self.write("<!-- include ");
try self.write(inc.path);
try self.write(" -->");
try self.writeNewline();
}
/// Generates content for a named block.
fn visitBlock(self: *CodeGen, blk: ast.Block) CodeGenError!void {
try self.writeIndent();
try self.write("<!-- block ");
try self.write(blk.name);
try self.write(" -->");
try self.writeNewline();
self.depth += 1;
for (blk.children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
try self.writeIndent();
try self.write("<!-- endblock -->");
try self.writeNewline();
}
/// Generates raw text content (for script/style blocks).
fn visitRawText(self: *CodeGen, raw: ast.RawText) CodeGenError!void {
try self.writeIndent();
try self.write(raw.content);
try self.writeNewline();
}
/// Generates code output (escaped or unescaped).
fn visitCode(self: *CodeGen, code: ast.Code) CodeGenError!void {
try self.writeIndent();
if (code.escaped) {
try self.write("{{ ");
} else {
try self.write("{{{ ");
}
try self.write(code.expression);
if (code.escaped) {
try self.write(" }}");
} else {
try self.write(" }}}");
}
try self.writeNewline();
}
// ─────────────────────────────────────────────────────────────────────────
// Output helpers
// ─────────────────────────────────────────────────────────────────────────
/// Writes text segments, handling interpolation.
fn writeTextSegments(self: *CodeGen, segments: []const ast.TextSegment) CodeGenError!void {
for (segments) |seg| {
switch (seg) {
.literal => |lit| try self.writeEscaped(lit),
.interp_escaped => |expr| {
try self.write("{{ ");
try self.write(expr);
try self.write(" }}");
},
.interp_unescaped => |expr| {
try self.write("{{{ ");
try self.write(expr);
try self.write(" }}}");
},
.interp_tag => |inline_tag| {
try self.writeInlineTag(inline_tag);
},
}
}
}
/// Writes an inline tag from tag interpolation.
fn writeInlineTag(self: *CodeGen, tag: ast.InlineTag) CodeGenError!void {
try self.write("<");
try self.write(tag.tag);
// Write ID if present
if (tag.id) |id| {
try self.write(" id=\"");
try self.writeEscaped(id);
try self.write("\"");
}
// Write classes if present
if (tag.classes.len > 0) {
try self.write(" class=\"");
for (tag.classes, 0..) |class, i| {
if (i > 0) try self.write(" ");
try self.writeEscaped(class);
}
try self.write("\"");
}
// Write attributes
for (tag.attributes) |attr| {
if (attr.value) |value| {
try self.write(" ");
try self.write(attr.name);
try self.write("=\"");
if (attr.escaped) {
try self.writeEscaped(value);
} else {
try self.write(value);
}
try self.write("\"");
} else {
try self.write(" ");
try self.write(attr.name);
try self.write("=\"");
try self.write(attr.name);
try self.write("\"");
}
}
try self.write(">");
// Write text content (may contain nested interpolations)
try self.writeTextSegments(tag.text_segments);
try self.write("</");
try self.write(tag.tag);
try self.write(">");
}
/// Writes indentation based on current depth.
fn writeIndent(self: *CodeGen) CodeGenError!void {
if (!self.options.pretty or self.preserve_whitespace) return;
for (0..self.depth) |_| {
try self.write(self.options.indent_str);
}
}
/// Writes a newline if pretty-printing is enabled.
fn writeNewline(self: *CodeGen) CodeGenError!void {
if (!self.options.pretty or self.preserve_whitespace) return;
try self.write("\n");
}
/// Writes a string directly to output.
fn write(self: *CodeGen, str: []const u8) CodeGenError!void {
try self.output.appendSlice(self.allocator, str);
}
/// Writes a string with HTML entity escaping.
fn writeEscaped(self: *CodeGen, str: []const u8) CodeGenError!void {
for (str) |c| {
switch (c) {
'&' => try self.write("&amp;"),
'<' => try self.write("&lt;"),
'>' => try self.write("&gt;"),
'"' => try self.write("&quot;"),
'\'' => try self.write("&#x27;"),
else => try self.output.append(self.allocator, c),
}
}
}
};
// ─────────────────────────────────────────────────────────────────────────────
// Convenience function
// ─────────────────────────────────────────────────────────────────────────────
/// Generates HTML from an AST document with default options.
/// Returns an owned slice that the caller must free.
pub fn generate(allocator: std.mem.Allocator, doc: ast.Document) CodeGenError![]u8 {
var gen = CodeGen.init(allocator, .{});
defer gen.deinit();
return gen.generateOwned(doc);
}
/// Generates HTML with custom options.
/// Returns an owned slice that the caller must free.
pub fn generateWithOptions(allocator: std.mem.Allocator, doc: ast.Document, options: Options) CodeGenError![]u8 {
var gen = CodeGen.init(allocator, options);
defer gen.deinit();
return gen.generateOwned(doc);
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
test "generate simple element" {
const allocator = std.testing.allocator;
const doc = ast.Document{
.nodes = @constCast(&[_]ast.Node{
.{ .element = .{
.tag = "div",
.id = null,
.classes = &.{},
.attributes = &.{},
.inline_text = null,
.children = &.{},
.self_closing = false,
} },
}),
};
const html = try generate(allocator, doc);
defer allocator.free(html);
try std.testing.expectEqualStrings("<div></div>\n", html);
}
test "generate element with id and class" {
const allocator = std.testing.allocator;
const doc = ast.Document{
.nodes = @constCast(&[_]ast.Node{
.{ .element = .{
.tag = "div",
.id = "main",
.classes = &.{ "container", "active" },
.attributes = &.{},
.inline_text = null,
.children = &.{},
.self_closing = false,
} },
}),
};
const html = try generate(allocator, doc);
defer allocator.free(html);
try std.testing.expectEqualStrings("<div id=\"main\" class=\"container active\"></div>\n", html);
}
test "generate void element" {
const allocator = std.testing.allocator;
const doc = ast.Document{
.nodes = @constCast(&[_]ast.Node{
.{ .element = .{
.tag = "br",
.id = null,
.classes = &.{},
.attributes = &.{},
.inline_text = null,
.children = &.{},
.self_closing = false,
} },
}),
};
const html = try generate(allocator, doc);
defer allocator.free(html);
try std.testing.expectEqualStrings("<br />\n", html);
}
test "generate nested elements" {
const allocator = std.testing.allocator;
var inner_text = [_]ast.TextSegment{.{ .literal = "Hello" }};
var inner_node = [_]ast.Node{
.{ .element = .{
.tag = "p",
.id = null,
.classes = &.{},
.attributes = &.{},
.inline_text = &inner_text,
.children = &.{},
.self_closing = false,
} },
};
const doc = ast.Document{
.nodes = @constCast(&[_]ast.Node{
.{ .element = .{
.tag = "div",
.id = null,
.classes = &.{},
.attributes = &.{},
.inline_text = null,
.children = &inner_node,
.self_closing = false,
} },
}),
};
const html = try generate(allocator, doc);
defer allocator.free(html);
const expected =
\\<div>
\\ <p>Hello</p>
\\</div>
\\
;
try std.testing.expectEqualStrings(expected, html);
}
test "generate with interpolation" {
const allocator = std.testing.allocator;
var inline_text = [_]ast.TextSegment{
.{ .literal = "Hello, " },
.{ .interp_escaped = "name" },
.{ .literal = "!" },
};
const doc = ast.Document{
.nodes = @constCast(&[_]ast.Node{
.{ .element = .{
.tag = "p",
.id = null,
.classes = &.{},
.attributes = &.{},
.inline_text = &inline_text,
.children = &.{},
.self_closing = false,
} },
}),
};
const html = try generate(allocator, doc);
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>Hello, {{ name }}!</p>\n", html);
}
test "generate html comment" {
const allocator = std.testing.allocator;
const doc = ast.Document{
.nodes = @constCast(&[_]ast.Node{
.{ .comment = .{
.content = "This is a comment",
.rendered = true,
.children = &.{},
} },
}),
};
const html = try generate(allocator, doc);
defer allocator.free(html);
try std.testing.expectEqualStrings("<!-- This is a comment -->\n", html);
}
test "escape html entities" {
const allocator = std.testing.allocator;
var inline_text = [_]ast.TextSegment{.{ .literal = "<script>alert('xss')</script>" }};
const doc = ast.Document{
.nodes = @constCast(&[_]ast.Node{
.{ .element = .{
.tag = "p",
.id = null,
.classes = &.{},
.attributes = &.{},
.inline_text = &inline_text,
.children = &.{},
.self_closing = false,
} },
}),
};
const html = try generate(allocator, doc);
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;</p>\n", html);
}

View File

@@ -0,0 +1,186 @@
//! Pugz Template Inheritance Demo
//!
//! A web application demonstrating Pug-style template inheritance
//! using the Pugz template engine with http.zig server.
//!
//! Routes:
//! GET / - Home page (layout.pug)
//! GET /page-a - Page A with custom scripts and content
//! GET /page-b - Page B with sub-layout
//! GET /append - Page with block append
//! GET /append-opt - Page with optional block syntax
const std = @import("std");
const httpz = @import("httpz");
const pugz = @import("pugz");
const Allocator = std.mem.Allocator;
/// Application state shared across all requests
const App = struct {
allocator: Allocator,
views_dir: []const u8,
/// File resolver for loading templates from disk
pub fn fileResolver(allocator: Allocator, path: []const u8) ?[]const u8 {
const file = std.fs.cwd().openFile(path, .{}) catch return null;
defer file.close();
return file.readToEndAlloc(allocator, 1024 * 1024) catch null;
}
/// Render a template with data
pub fn render(self: *App, template_name: []const u8, data: anytype) ![]u8 {
// Build full path
const template_path = try std.fs.path.join(self.allocator, &.{ self.views_dir, template_name });
defer self.allocator.free(template_path);
// Load template source
const source = fileResolver(self.allocator, template_path) orelse {
return error.TemplateNotFound;
};
defer self.allocator.free(source);
// Parse template
var lexer = pugz.Lexer.init(self.allocator, source);
const tokens = try lexer.tokenize();
var parser = pugz.Parser.init(self.allocator, tokens);
const doc = try parser.parse();
// Setup context with data
var ctx = pugz.runtime.Context.init(self.allocator);
defer ctx.deinit();
try ctx.pushScope();
inline for (std.meta.fields(@TypeOf(data))) |field| {
const value = @field(data, field.name);
try ctx.set(field.name, pugz.runtime.toValue(self.allocator, value));
}
// Render with file resolver for includes/extends
var runtime = pugz.runtime.Runtime.init(self.allocator, &ctx, .{
.file_resolver = fileResolver,
.base_dir = self.views_dir,
});
defer runtime.deinit();
return runtime.renderOwned(doc);
}
};
/// Handler for GET /
fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.render("layout.pug", .{
.title = "Home",
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// Handler for GET /page-a - demonstrates extends and block override
fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.render("page-a.pug", .{
.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;
}
/// Handler for GET /page-b - demonstrates sub-layout inheritance
fn pageB(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.render("page-b.pug", .{
.title = "Page B - Sub Layout",
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// Handler for GET /append - demonstrates block append
fn pageAppend(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.render("page-append.pug", .{
.title = "Page Append",
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// Handler for GET /append-opt - demonstrates optional block keyword
fn pageAppendOptional(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.render("page-appen-optional-blk.pug", .{
.title = "Page Append Optional",
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Views directory - relative to current working directory
const views_dir = "src/examples/app_01/views";
var app = App{
.allocator = allocator,
.views_dir = views_dir,
};
var server = try httpz.Server(*App).init(allocator, .{ .port = 8080 }, &app);
defer server.deinit();
var router = try server.router(.{});
// Routes
router.get("/", index, .{});
router.get("/page-a", pageA, .{});
router.get("/page-b", pageB, .{});
router.get("/append", pageAppend, .{});
router.get("/append-opt", pageAppendOptional, .{});
std.debug.print(
\\
\\Pugz Template Inheritance Demo
\\==============================
\\Server running at http://localhost:8080
\\
\\Routes:
\\ GET / - Home page (base layout)
\\ GET /page-a - Page with custom scripts and content blocks
\\ GET /page-b - Page with sub-layout inheritance
\\ GET /append - Page with block append
\\ GET /append-opt - Page with optional block keyword
\\
\\Press Ctrl+C to stop.
\\
, .{});
try server.listen();
}

View File

@@ -0,0 +1,7 @@
html
head
block head
script(src='/vendor/jquery.js')
script(src='/vendor/caustic.js')
body
block content

View File

@@ -0,0 +1,10 @@
html
head
title My Site - #{title}
block scripts
script(src='/jquery.js')
body
block content
block foot
#footer
p some footer content

View File

@@ -0,0 +1,15 @@
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

View File

@@ -0,0 +1,5 @@
extends layout
append head
script(src='/vendor/three.js')
script(src='/game.js')

View File

@@ -0,0 +1,11 @@
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

View File

@@ -0,0 +1,9 @@
extends sub-layout.pug
block content
.sidebar
block sidebar
p nothing
.primary
block primary
p nothing

View File

@@ -0,0 +1 @@
p= petName

View File

@@ -0,0 +1,9 @@
extends layout.pug
block content
.sidebar
block sidebar
p nothing
.primary
block primary
p nothing

1436
src/lexer.zig Normal file

File diff suppressed because it is too large Load Diff

62
src/main.zig Normal file
View File

@@ -0,0 +1,62 @@
const std = @import("std");
const pugz = @import("pugz");
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer _ = gpa.deinit();
// Use arena allocator - recommended for templates (all memory freed at once)
var arena = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena.deinit();
const allocator = arena.allocator();
std.debug.print("=== Pugz Template Engine ===\n\n", .{});
// Simple API: renderTemplate - one function call does everything
std.debug.print("--- Simple API (recommended for servers) ---\n", .{});
const html = try pugz.renderTemplate(allocator,
\\doctype html
\\html
\\ head
\\ title= title
\\ body
\\ h1 Hello, #{name}!
\\ p Welcome to Pugz.
\\ ul
\\ each item in items
\\ li= item
, .{
.title = "My Page",
.name = "World",
.items = &[_][]const u8{ "First", "Second", "Third" },
});
std.debug.print("{s}\n", .{html});
// Advanced API: parse once, render multiple times with different data
std.debug.print("--- Advanced API (parse once, render many) ---\n", .{});
const source =
\\p Hello, #{name}!
;
// Tokenize & Parse (do this once)
var lexer = pugz.Lexer.init(allocator, source);
const tokens = try lexer.tokenize();
var parser = pugz.Parser.init(allocator, tokens);
const doc = try parser.parse();
// Render multiple times with different data
const html1 = try pugz.render(allocator, doc, .{ .name = "Alice" });
const html2 = try pugz.render(allocator, doc, .{ .name = "Bob" });
std.debug.print("Render 1: {s}", .{html1});
std.debug.print("Render 2: {s}", .{html2});
}
test "simple test" {
const gpa = std.testing.allocator;
var list: std.ArrayListUnmanaged(i32) = .empty;
defer list.deinit(gpa);
try list.append(gpa, 42);
try std.testing.expectEqual(@as(i32, 42), list.pop());
}

1243
src/parser.zig Normal file

File diff suppressed because it is too large Load Diff

37
src/root.zig Normal file
View File

@@ -0,0 +1,37 @@
//! Pugz - A Pug-like HTML template engine written in Zig.
//!
//! Pugz provides a clean, indentation-based syntax for writing HTML templates,
//! inspired by Pug (formerly Jade). It supports:
//! - Indentation-based nesting
//! - Tag, class, and ID shorthand syntax
//! - Attributes and text interpolation
//! - Control flow (if/else, each, while)
//! - Mixins and template inheritance
pub const lexer = @import("lexer.zig");
pub const ast = @import("ast.zig");
pub const parser = @import("parser.zig");
pub const codegen = @import("codegen.zig");
pub const runtime = @import("runtime.zig");
// Re-export main types for convenience
pub const Lexer = lexer.Lexer;
pub const Token = lexer.Token;
pub const TokenType = lexer.TokenType;
pub const Parser = parser.Parser;
pub const Node = ast.Node;
pub const Document = ast.Document;
pub const CodeGen = codegen.CodeGen;
pub const generate = codegen.generate;
pub const Runtime = runtime.Runtime;
pub const Context = runtime.Context;
pub const Value = runtime.Value;
pub const render = runtime.render;
pub const renderTemplate = runtime.renderTemplate;
test {
_ = @import("std").testing.refAllDecls(@This());
}

1483
src/runtime.zig Normal file

File diff suppressed because it is too large Load Diff

286
src/test_templates.zig Normal file
View File

@@ -0,0 +1,286 @@
//! Template test cases for Pugz engine
//!
//! Run with: zig build test
//! Or run specific: zig test src/test_templates.zig
const std = @import("std");
const pugz = @import("root.zig");
/// Helper to compile and render a template with data
fn render(allocator: std.mem.Allocator, source: []const u8, setData: fn (*pugz.Context) anyerror!void) ![]u8 {
var lexer = pugz.Lexer.init(allocator, source);
const tokens = try lexer.tokenize();
var parser = pugz.Parser.init(allocator, tokens);
const doc = try parser.parse();
var ctx = pugz.Context.init(allocator);
defer ctx.deinit();
try ctx.pushScope();
try setData(&ctx);
var runtime = pugz.Runtime.init(allocator, &ctx, .{ .pretty = false });
defer runtime.deinit();
return runtime.renderOwned(doc);
}
/// Helper for templates with no data
fn renderNoData(allocator: std.mem.Allocator, source: []const u8) ![]u8 {
return render(allocator, source, struct {
fn set(_: *pugz.Context) anyerror!void {}
}.set);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Cases
// ─────────────────────────────────────────────────────────────────────────────
test "simple tag" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "p Hello");
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>Hello</p>", html);
}
test "tag with class" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "p.intro Hello");
defer allocator.free(html);
try std.testing.expectEqualStrings("<p class=\"intro\">Hello</p>", html);
}
test "tag with id" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "div#main");
defer allocator.free(html);
try std.testing.expectEqualStrings("<div id=\"main\"></div>", html);
}
test "tag with id and class" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "div#main.container");
defer allocator.free(html);
try std.testing.expectEqualStrings("<div id=\"main\" class=\"container\"></div>", html);
}
test "multiple classes" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "div.foo.bar.baz");
defer allocator.free(html);
try std.testing.expectEqualStrings("<div class=\"foo bar baz\"></div>", html);
}
test "interpolation with data" {
const allocator = std.testing.allocator;
const html = try render(allocator, "p #{name}'s code", struct {
fn set(ctx: *pugz.Context) anyerror!void {
try ctx.set("name", pugz.Value.str("ankit patial"));
}
}.set);
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>ankit patial&#x27;s code</p>", html);
}
test "interpolation at start of text" {
const allocator = std.testing.allocator;
const html = try render(allocator, "title #{title}", struct {
fn set(ctx: *pugz.Context) anyerror!void {
try ctx.set("title", pugz.Value.str("My Page"));
}
}.set);
defer allocator.free(html);
try std.testing.expectEqualStrings("<title>My Page</title>", html);
}
test "multiple interpolations" {
const allocator = std.testing.allocator;
const html = try render(allocator, "p #{a} and #{b}", struct {
fn set(ctx: *pugz.Context) anyerror!void {
try ctx.set("a", pugz.Value.str("foo"));
try ctx.set("b", pugz.Value.str("bar"));
}
}.set);
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>foo and bar</p>", html);
}
test "integer interpolation" {
const allocator = std.testing.allocator;
const html = try render(allocator, "p Count: #{count}", struct {
fn set(ctx: *pugz.Context) anyerror!void {
try ctx.set("count", pugz.Value.integer(42));
}
}.set);
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>Count: 42</p>", html);
}
test "void element br" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "br");
defer allocator.free(html);
try std.testing.expectEqualStrings("<br />", html);
}
test "void element img with attributes" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "img(src=\"logo.png\" alt=\"Logo\")");
defer allocator.free(html);
try std.testing.expectEqualStrings("<img src=\"logo.png\" alt=\"Logo\" />", html);
}
test "attribute with single quotes" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "a(href='//google.com')");
defer allocator.free(html);
try std.testing.expectEqualStrings("<a href=\"//google.com\"></a>", html);
}
test "attribute with double quotes" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "a(href=\"//google.com\")");
defer allocator.free(html);
try std.testing.expectEqualStrings("<a href=\"//google.com\"></a>", html);
}
test "multiple attributes with comma" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "a(class='btn', href='/link')");
defer allocator.free(html);
try std.testing.expectEqualStrings("<a class=\"btn\" href=\"/link\"></a>", html);
}
test "multiple attributes without comma" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "a(class='btn' href='/link')");
defer allocator.free(html);
try std.testing.expectEqualStrings("<a class=\"btn\" href=\"/link\"></a>", html);
}
test "boolean attribute" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "input(type=\"checkbox\" checked)");
defer allocator.free(html);
try std.testing.expectEqualStrings("<input type=\"checkbox\" checked />", html);
}
test "html comment" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "// This is a comment");
defer allocator.free(html);
try std.testing.expectEqualStrings("<!-- This is a comment -->", html);
}
test "unbuffered comment not rendered" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "//- Hidden comment");
defer allocator.free(html);
try std.testing.expectEqualStrings("", html);
}
test "nested elements" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator,
\\div
\\ p Hello
);
defer allocator.free(html);
try std.testing.expectEqualStrings("<div><p>Hello</p></div>", html);
}
test "deeply nested elements" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator,
\\html
\\ body
\\ div
\\ p Text
);
defer allocator.free(html);
try std.testing.expectEqualStrings("<html><body><div><p>Text</p></div></body></html>", html);
}
test "sibling elements" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator,
\\ul
\\ li One
\\ li Two
\\ li Three
);
defer allocator.free(html);
try std.testing.expectEqualStrings("<ul><li>One</li><li>Two</li><li>Three</li></ul>", html);
}
test "div shorthand with class only" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, ".container");
defer allocator.free(html);
try std.testing.expectEqualStrings("<div class=\"container\"></div>", html);
}
test "div shorthand with id only" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "#main");
defer allocator.free(html);
try std.testing.expectEqualStrings("<div id=\"main\"></div>", html);
}
test "class and id on div shorthand" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "#main.container.active");
defer allocator.free(html);
try std.testing.expectEqualStrings("<div id=\"main\" class=\"container active\"></div>", html);
}
test "html escaping in text" {
const allocator = std.testing.allocator;
const html = try renderNoData(allocator, "p <script>alert('xss')</script>");
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;</p>", html);
}
test "html escaping in interpolation" {
const allocator = std.testing.allocator;
const html = try render(allocator, "p #{code}", struct {
fn set(ctx: *pugz.Context) anyerror!void {
try ctx.set("code", pugz.Value.str("<b>bold</b>"));
}
}.set);
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>&lt;b&gt;bold&lt;/b&gt;</p>", html);
}
// ─────────────────────────────────────────────────────────────────────────────
// Known Issues / TODO Tests (these document expected behavior not yet working)
// ─────────────────────────────────────────────────────────────────────────────
// TODO: Inline text after attributes
// test "inline text after attributes" {
// const allocator = std.testing.allocator;
// const html = try renderNoData(allocator, "a(href='//google.com') Google");
// defer allocator.free(html);
// try std.testing.expectEqualStrings("<a href=\"//google.com\">Google</a>", html);
// }
// TODO: Pipe text for newlines
// test "pipe text" {
// const allocator = std.testing.allocator;
// const html = try renderNoData(allocator,
// \\p
// \\ | Line 1
// \\ | Line 2
// );
// defer allocator.free(html);
// try std.testing.expectEqualStrings("<p>Line 1Line 2</p>", html);
// }
// TODO: Block expansion with colon
// test "block expansion" {
// const allocator = std.testing.allocator;
// const html = try renderNoData(allocator, "ul: li Item");
// defer allocator.free(html);
// try std.testing.expectEqualStrings("<ul><li>Item</li></ul>", html);
// }

104
src/tests/doctype_test.zig Normal file
View File

@@ -0,0 +1,104 @@
//! Doctype tests for Pugz engine
const helper = @import("helper.zig");
const expectOutput = helper.expectOutput;
// ─────────────────────────────────────────────────────────────────────────────
// Doctype tests
// ─────────────────────────────────────────────────────────────────────────────
test "Doctype default (html)" {
try expectOutput("doctype", .{}, "<!DOCTYPE html>");
}
test "Doctype html explicit" {
try expectOutput("doctype html", .{}, "<!DOCTYPE html>");
}
test "Doctype xml" {
try expectOutput("doctype xml", .{}, "<?xml version=\"1.0\" encoding=\"utf-8\" ?>");
}
test "Doctype transitional" {
try expectOutput(
"doctype transitional",
.{},
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">",
);
}
test "Doctype strict" {
try expectOutput(
"doctype strict",
.{},
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">",
);
}
test "Doctype frameset" {
try expectOutput(
"doctype frameset",
.{},
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Frameset//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd\">",
);
}
test "Doctype 1.1" {
try expectOutput(
"doctype 1.1",
.{},
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">",
);
}
test "Doctype basic" {
try expectOutput(
"doctype basic",
.{},
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML Basic 1.1//EN\" \"http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd\">",
);
}
test "Doctype mobile" {
try expectOutput(
"doctype mobile",
.{},
"<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.2//EN\" \"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd\">",
);
}
test "Doctype plist" {
try expectOutput(
"doctype plist",
.{},
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
);
}
test "Doctype custom" {
try expectOutput(
"doctype html PUBLIC \"-//W3C//DTD HTML 4.01//EN\"",
.{},
"<!DOCTYPE html PUBLIC \"-//W3C//DTD HTML 4.01//EN\">",
);
}
test "Doctype with html content" {
try expectOutput(
\\doctype html
\\html
\\ head
\\ title Hello
\\ body
\\ p World
, .{},
\\<!DOCTYPE html>
\\<html>
\\ <head>
\\ <title>Hello</title>
\\ </head>
\\ <body>
\\ <p>World</p>
\\ </body>
\\</html>
);
}

717
src/tests/general_test.zig Normal file
View File

@@ -0,0 +1,717 @@
//! General template tests for Pugz engine
const helper = @import("helper.zig");
const expectOutput = helper.expectOutput;
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 1: Simple interpolation
// ─────────────────────────────────────────────────────────────────────────────
test "Simple interpolation" {
try expectOutput(
"p #{name}'s Pug source code!",
.{ .name = "ankit patial" },
"<p>ankit patial&#x27;s Pug source code!</p>",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 2: Attributes with inline text
// ─────────────────────────────────────────────────────────────────────────────
test "Link with href attribute" {
try expectOutput(
"a(href='//google.com') Google",
.{},
"<a href=\"//google.com\">Google</a>",
);
}
test "Link with class and href (space separated)" {
try expectOutput(
"a(class='button' href='//google.com') Google",
.{},
"<a href=\"//google.com\" class=\"button\">Google</a>",
);
}
test "Link with class and href (comma separated)" {
try expectOutput(
"a(class='button', href='//google.com') Google",
.{},
"<a href=\"//google.com\" class=\"button\">Google</a>",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 3: Boolean attributes (multiline)
// ─────────────────────────────────────────────────────────────────────────────
test "Checkbox with boolean checked attribute" {
try expectOutput(
\\input(
\\ type='checkbox'
\\ name='agreement'
\\ checked
\\)
,
.{},
"<input type=\"checkbox\" name=\"agreement\" checked=\"checked\" />",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 4: Backtick template literal with multiline JSON
// ─────────────────────────────────────────────────────────────────────────────
test "Input with multiline JSON data attribute" {
try expectOutput(
\\input(data-json=`
\\ {
\\ "very-long": "piece of ",
\\ "data": true
\\ }
\\`)
,
.{},
\\<input data-json="
\\ {
\\ &quot;very-long&quot;: &quot;piece of &quot;,
\\ &quot;data&quot;: true
\\ }
\\" />
,
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 5: Escaped vs unescaped attribute values
// ─────────────────────────────────────────────────────────────────────────────
test "Escaped attribute value" {
try expectOutput(
"div(escaped=\"<code>\")",
.{},
"<div escaped=\"&lt;code&gt;\"></div>",
);
}
test "Unescaped attribute value" {
try expectOutput(
"div(unescaped!=\"<code>\")",
.{},
"<div unescaped=\"<code>\"></div>",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 6: Boolean attributes with true/false values
// ─────────────────────────────────────────────────────────────────────────────
test "Checkbox with checked (no value)" {
try expectOutput(
"input(type='checkbox' checked)",
.{},
"<input type=\"checkbox\" checked=\"checked\" />",
);
}
test "Checkbox with checked=true" {
try expectOutput(
"input(type='checkbox' checked=true)",
.{},
"<input type=\"checkbox\" checked=\"checked\" />",
);
}
test "Checkbox with checked=false (omitted)" {
try expectOutput(
"input(type='checkbox' checked=false)",
.{},
"<input type=\"checkbox\" />",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 7: Object literal as style attribute
// ─────────────────────────────────────────────────────────────────────────────
test "Style object literal" {
try expectOutput(
"a(style={color: 'red', background: 'green'})",
.{},
"<a style=\"color:red;background:green;\"></a>",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 8: Array literals for class attribute
// ─────────────────────────────────────────────────────────────────────────────
test "Class array literal" {
try expectOutput("a(class=['foo', 'bar', 'baz'])", .{}, "<a class=\"foo bar baz\"></a>");
}
test "Class array merged with shorthand and array" {
try expectOutput(
"a.bang(class=['foo', 'bar', 'baz'] class=['bing'])",
.{},
"<a class=\"bang foo bar baz bing\"></a>",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 9: Shorthand class syntax
// ─────────────────────────────────────────────────────────────────────────────
test "Shorthand class on anchor" {
try expectOutput("a.button", .{}, "<a class=\"button\"></a>");
}
test "Implicit div with class" {
try expectOutput(".content", .{}, "<div class=\"content\"></div>");
}
test "Shorthand ID on anchor" {
try expectOutput("a#main-link", .{}, "<a id=\"main-link\"></a>");
}
test "Implicit div with ID" {
try expectOutput("#content", .{}, "<div id=\"content\"></div>");
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 10: &attributes spread operator
// ─────────────────────────────────────────────────────────────────────────────
test "Attributes spread with &attributes" {
try expectOutput(
"div#foo(data-bar=\"foo\")&attributes({'data-foo': 'bar'})",
.{},
"<div id=\"foo\" data-bar=\"foo\" data-foo=\"bar\"></div>",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 11: case/when/default
// ─────────────────────────────────────────────────────────────────────────────
test "Case statement with friends=1" {
try expectOutput(
\\case friends
\\ when 0
\\ p you have no friends
\\ when 1
\\ p you have a friend
\\ default
\\ p you have #{friends} friends
, .{ .friends = @as(i64, 1) }, "<p>you have a friend</p>");
}
test "Case statement with friends=10" {
try expectOutput(
\\case friends
\\ when 0
\\ p you have no friends
\\ when 1
\\ p you have a friend
\\ default
\\ p you have #{friends} friends
, .{ .friends = @as(i64, 10) }, "<p>you have 10 friends</p>");
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 12: Conditionals (if/else if/else)
// ─────────────────────────────────────────────────────────────────────────────
test "If condition true" {
try expectOutput(
\\if showMessage
\\ p Hello!
, .{ .showMessage = true }, "<p>Hello!</p>");
}
test "If condition false (no data)" {
try expectOutput(
\\if showMessage
\\ p Hello!
, .{}, "");
}
test "If condition false with else" {
try expectOutput(
\\if showMessage
\\ p Hello!
\\else
\\ p Goodbye!
, .{ .showMessage = false }, "<p>Goodbye!</p>");
}
test "Unless condition (negated if)" {
try expectOutput(
\\unless isHidden
\\ p Visible content
, .{ .isHidden = false }, "<p>Visible content</p>");
}
// ─────────────────────────────────────────────────────────────────────────────
// Test Case 13: Nested conditionals with dot notation
// ─────────────────────────────────────────────────────────────────────────────
test "Condition with nested user.description" {
try expectOutput(
\\#user
\\ if user.description
\\ h2.green Description
\\ p.description= user.description
\\ else if authorised
\\ h2.blue Description
\\ p.description No description (authorised)
\\ else
\\ h2.red Description
\\ p.description User has no description
, .{ .user = .{ .description = "foo bar baz" }, .authorised = false },
\\<div id="user">
\\ <h2 class="green">Description</h2>
\\ <p class="description">foo bar baz</p>
\\</div>
);
}
test "Condition with nested user.description and autorized" {
try expectOutput(
\\#user
\\ if user.description
\\ h2.green Description
\\ p.description= user.description
\\ else if authorised
\\ h2.blue Description
\\ p.description No description (authorised)
\\ else
\\ h2.red Description
\\ p.description User has no description
, .{ .authorised = true },
\\<div id="user">
\\ <h2 class="blue">Description</h2>
\\ <p class="description">No description (authorised)</p>
\\</div>
);
}
test "Condition with nested user.description and no data" {
try expectOutput(
\\#user
\\ if user.description
\\ h2.green Description
\\ p.description= user.description
\\ else if authorised
\\ h2.blue Description
\\ p.description No description (authorised)
\\ else
\\ h2.red Description
\\ p.description User has no description
, .{},
\\<div id="user">
\\ <h2 class="red">Description</h2>
\\ <p class="description">User has no description</p>
\\</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Tag Interpolation Tests
// ─────────────────────────────────────────────────────────────────────────────
test "Simple tag interpolation" {
try expectOutput(
"p This is #[em emphasized] text.",
.{},
"<p>This is <em>emphasized</em> text.</p>",
);
}
test "Tag interpolation with strong" {
try expectOutput(
"p This is #[strong important] text.",
.{},
"<p>This is <strong>important</strong> text.</p>",
);
}
test "Tag interpolation with link" {
try expectOutput(
"p Click #[a(href='/') here] to continue.",
.{},
"<p>Click <a href=\"/\">here</a> to continue.</p>",
);
}
test "Tag interpolation with class" {
try expectOutput(
"p This is #[span.highlight highlighted] text.",
.{},
"<p>This is <span class=\"highlight\">highlighted</span> text.</p>",
);
}
test "Tag interpolation with id" {
try expectOutput(
"p See #[span#note this note] for details.",
.{},
"<p>See <span id=\"note\">this note</span> for details.</p>",
);
}
test "Tag interpolation with class and id" {
try expectOutput(
"p Check #[span#info.tooltip the tooltip] here.",
.{},
"<p>Check <span id=\"info\" class=\"tooltip\">the tooltip</span> here.</p>",
);
}
test "Multiple tag interpolations" {
try expectOutput(
"p This has #[em emphasis] and #[strong strength].",
.{},
"<p>This has <em>emphasis</em> and <strong>strength</strong>.</p>",
);
}
test "Tag interpolation with multiple classes" {
try expectOutput(
"p Text with #[span.red.bold styled content] here.",
.{},
"<p>Text with <span class=\"red bold\">styled content</span> here.</p>",
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Iteration Tests
// ─────────────────────────────────────────────────────────────────────────────
test "each loop with array" {
try expectOutput(
\\ul
\\ each item in items
\\ li= item
, .{ .items = &[_][]const u8{ "apple", "banana", "cherry" } },
\\<ul>
\\ <li>apple</li>
\\ <li>banana</li>
\\ <li>cherry</li>
\\</ul>
);
}
test "for loop as alias for each" {
try expectOutput(
\\ul
\\ for item in items
\\ li= item
, .{ .items = &[_][]const u8{ "one", "two", "three" } },
\\<ul>
\\ <li>one</li>
\\ <li>two</li>
\\ <li>three</li>
\\</ul>
);
}
test "each loop with index" {
try expectOutput(
\\ul
\\ each item, idx in items
\\ li #{idx}: #{item}
, .{ .items = &[_][]const u8{ "a", "b", "c" } },
\\<ul>
\\ <li>0: a</li>
\\ <li>1: b</li>
\\ <li>2: c</li>
\\</ul>
);
}
test "each loop with else block" {
try expectOutput(
\\ul
\\ each item in items
\\ li= item
\\ else
\\ li No items found
, .{ .items = &[_][]const u8{} },
\\<ul>
\\ <li>No items found</li>
\\</ul>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Mixin Tests
// ─────────────────────────────────────────────────────────────────────────────
test "Basic mixin declaration and call" {
try expectOutput(
\\mixin list
\\ ul
\\ li foo
\\ li bar
\\+list
, .{},
\\<ul>
\\ <li>foo</li>
\\ <li>bar</li>
\\</ul>
);
}
test "Mixin with arguments" {
try expectOutput(
\\mixin pet(name)
\\ li.pet= name
\\ul
\\ +pet('cat')
\\ +pet('dog')
, .{},
\\<ul>
\\ <li class="pet">cat</li>
\\ <li class="pet">dog</li>
\\</ul>
);
}
test "Mixin with default argument" {
try expectOutput(
\\mixin greet(name='World')
\\ p Hello, #{name}!
\\+greet
\\+greet('Zig')
, .{},
\\<p>Hello, World!</p>
\\<p>Hello, Zig!</p>
);
}
test "Mixin with block content" {
try expectOutput(
\\mixin article(title)
\\ .article
\\ h1= title
\\ block
\\+article('Hello')
\\ p This is content
\\ p More content
, .{},
\\<div class="article">
\\ <h1>Hello</h1>
\\ <p>This is content</p>
\\ <p>More content</p>
\\</div>
);
}
test "Mixin with block and no content passed" {
try expectOutput(
\\mixin box
\\ .box
\\ block
\\+box
, .{},
\\<div class="box">
\\</div>
);
}
test "Mixin with attributes" {
try expectOutput(
\\mixin link(href, name)
\\ a(href=href)&attributes(attributes)= name
\\+link('/foo', 'foo')(class="btn")
, .{},
\\<a href="/foo" class="btn">foo</a>
);
}
test "Mixin with rest arguments" {
try expectOutput(
\\mixin list(id, ...items)
\\ ul(id=id)
\\ each item in items
\\ li= item
\\+list('my-list', 'one', 'two', 'three')
, .{},
\\<ul id="my-list">
\\ <li>one</li>
\\ <li>two</li>
\\ <li>three</li>
\\</ul>
);
}
test "Mixin with rest arguments empty" {
try expectOutput(
\\mixin list(id, ...items)
\\ ul(id=id)
\\ each item in items
\\ li= item
\\+list('my-list')
, .{},
\\<ul id="my-list">
\\</ul>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Plain Text Tests
// ─────────────────────────────────────────────────────────────────────────────
test "Inline text in tag" {
try expectOutput(
\\p This is plain old text content.
, .{},
\\<p>This is plain old text content.</p>
);
}
test "Piped text basic" {
try expectOutput(
\\p
\\ | The pipe always goes at the beginning of its own line,
\\ | not counting indentation.
, .{},
\\<p>
\\ The pipe always goes at the beginning of its own line,
\\ not counting indentation.
\\</p>
);
}
// test "Piped text with inline tags" {
// try expectOutput(
// \\| You put the em
// \\em pha
// \\| sis on the wrong syl
// \\em la
// \\| ble.
// , .{},
// \\You put the em
// \\<em>pha</em>sis on the wrong syl
// \\<em>la</em>ble.
// );
// }
test "Block text with dot" {
try expectOutput(
\\script.
\\ if (usingPug)
\\ console.log('you are awesome')
, .{},
\\<script>
\\ if (usingPug)
\\ console.log('you are awesome')
\\
\\</script>
);
}
test "Block text with dot and attributes" {
try expectOutput(
\\style(type='text/css').
\\ body {
\\ color: red;
\\ }
, .{},
\\<style type="text/css">
\\ body {
\\ color: red;
\\ }
\\
\\</style>
);
}
test "Literal HTML passthrough" {
try expectOutput(
\\<html>
\\p Hello from Pug
\\</html>
, .{},
\\<html>
\\<p>Hello from Pug</p>
\\</html>
);
}
test "Literal HTML mixed with Pug" {
try expectOutput(
\\div
\\ <span>Literal HTML</span>
\\ p Pug paragraph
, .{},
\\<div>
\\<span>Literal HTML</span>
\\ <p>Pug paragraph</p>
\\</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Tag Tests
// ─────────────────────────────────────────────────────────────────────────────
test "Nested tags with indentation" {
try expectOutput(
\\ul
\\ li Item A
\\ li Item B
\\ li Item C
, .{},
\\<ul>
\\ <li>Item A</li>
\\ <li>Item B</li>
\\ <li>Item C</li>
\\</ul>
);
}
test "Self-closing void elements" {
try expectOutput(
\\img
\\br
\\input
, .{},
\\<img />
\\<br />
\\<input />
);
}
test "Block expansion with colon" {
try expectOutput(
\\a: img
, .{},
\\<a>
\\ <img />
\\</a>
);
}
test "Block expansion nested" {
try expectOutput(
\\ul
\\ li: a(href='/') Home
\\ li: a(href='/about') About
, .{},
\\<ul>
\\ <li>
\\ <a href="/">Home</a>
\\ </li>
\\ <li>
\\ <a href="/about">About</a>
\\ </li>
\\</ul>
);
}
test "Explicit self-closing tag" {
try expectOutput(
\\foo/
, .{},
\\<foo />
);
}
test "Explicit self-closing tag with attributes" {
try expectOutput(
\\foo(bar='baz')/
, .{},
\\<foo bar="baz" />
);
}

24
src/tests/helper.zig Normal file
View File

@@ -0,0 +1,24 @@
//! Test helper for Pugz engine
//! Provides common utilities for template testing
const std = @import("std");
const pugz = @import("pugz");
/// Expects the template to produce the expected output when rendered with the given data.
/// Uses arena allocator for automatic cleanup.
pub fn expectOutput(template: []const u8, data: anytype, expected: []const u8) !void {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var lexer = pugz.Lexer.init(allocator, template);
const tokens = try lexer.tokenize();
var parser = pugz.Parser.init(allocator, tokens);
const doc = try parser.parse();
const raw_result = try pugz.render(allocator, doc, data);
const result = std.mem.trimRight(u8, raw_result, "\n");
try std.testing.expectEqualStrings(expected, result);
}

View File

@@ -0,0 +1,378 @@
//! Template inheritance tests for Pugz engine
const std = @import("std");
const pugz = @import("pugz");
/// Mock file resolver for testing template inheritance.
/// Maps template paths to their content.
const MockFiles = struct {
files: std.StringHashMap([]const u8),
fn init(allocator: std.mem.Allocator) MockFiles {
return .{ .files = std.StringHashMap([]const u8).init(allocator) };
}
fn deinit(self: *MockFiles) void {
self.files.deinit();
}
fn put(self: *MockFiles, path: []const u8, content: []const u8) !void {
try self.files.put(path, content);
}
fn get(self: *const MockFiles, path: []const u8) ?[]const u8 {
return self.files.get(path);
}
};
var test_files: ?*MockFiles = null;
fn mockFileResolver(_: std.mem.Allocator, path: []const u8) ?[]const u8 {
if (test_files) |files| {
return files.get(path);
}
return null;
}
fn renderWithFiles(
allocator: std.mem.Allocator,
template: []const u8,
files: *MockFiles,
data: anytype,
) ![]u8 {
test_files = files;
defer test_files = null;
var lexer = pugz.Lexer.init(allocator, template);
const tokens = try lexer.tokenize();
var parser = pugz.Parser.init(allocator, tokens);
const doc = try parser.parse();
var ctx = pugz.runtime.Context.init(allocator);
defer ctx.deinit();
try ctx.pushScope();
inline for (std.meta.fields(@TypeOf(data))) |field| {
const value = @field(data, field.name);
try ctx.set(field.name, pugz.runtime.toValue(allocator, value));
}
var runtime = pugz.runtime.Runtime.init(allocator, &ctx, .{
.file_resolver = mockFileResolver,
});
defer runtime.deinit();
return runtime.renderOwned(doc);
}
// ─────────────────────────────────────────────────────────────────────────────
// Block tests (without inheritance)
// ─────────────────────────────────────────────────────────────────────────────
test "Block with default content" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
const template =
\\html
\\ body
\\ block content
\\ p Default content
;
var lexer = pugz.Lexer.init(allocator, template);
const tokens = try lexer.tokenize();
var parser = pugz.Parser.init(allocator, tokens);
const doc = try parser.parse();
var ctx = pugz.runtime.Context.init(allocator);
defer ctx.deinit();
var runtime = pugz.runtime.Runtime.init(allocator, &ctx, .{});
defer runtime.deinit();
const result = try runtime.renderOwned(doc);
const trimmed = std.mem.trimRight(u8, result, "\n");
try std.testing.expectEqualStrings(
\\<html>
\\ <body>
\\ <p>Default content</p>
\\ </body>
\\</html>
, trimmed);
}
// ─────────────────────────────────────────────────────────────────────────────
// Template inheritance tests
// ─────────────────────────────────────────────────────────────────────────────
test "Extends with block replace" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var files = MockFiles.init(allocator);
defer files.deinit();
// Parent layout
try files.put("layout.pug",
\\html
\\ head
\\ title My Site
\\ body
\\ block content
\\ p Default content
);
// Child template
const child =
\\extends layout.pug
\\
\\block content
\\ h1 Hello World
\\ p This is the child content
;
const result = try renderWithFiles(allocator, child, &files, .{});
const trimmed = std.mem.trimRight(u8, result, "\n");
try std.testing.expectEqualStrings(
\\<html>
\\ <head>
\\ <title>My Site</title>
\\ </head>
\\ <body>
\\ <h1>Hello World</h1>
\\ <p>This is the child content</p>
\\ </body>
\\</html>
, trimmed);
}
test "Extends with block append" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var files = MockFiles.init(allocator);
defer files.deinit();
// Parent layout with scripts
try files.put("layout.pug",
\\html
\\ head
\\ block scripts
\\ script(src='/jquery.js')
);
// Child appends more scripts
const child =
\\extends layout.pug
\\
\\block append scripts
\\ script(src='/app.js')
;
const result = try renderWithFiles(allocator, child, &files, .{});
const trimmed = std.mem.trimRight(u8, result, "\n");
try std.testing.expectEqualStrings(
\\<html>
\\ <head>
\\ <script src="/jquery.js"></script>
\\ <script src="/app.js"></script>
\\ </head>
\\</html>
, trimmed);
}
test "Extends with block prepend" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var files = MockFiles.init(allocator);
defer files.deinit();
// Parent layout
try files.put("layout.pug",
\\html
\\ head
\\ block styles
\\ link(rel='stylesheet' href='/main.css')
);
// Child prepends reset styles
const child =
\\extends layout.pug
\\
\\block prepend styles
\\ link(rel='stylesheet' href='/reset.css')
;
const result = try renderWithFiles(allocator, child, &files, .{});
const trimmed = std.mem.trimRight(u8, result, "\n");
try std.testing.expectEqualStrings(
\\<html>
\\ <head>
\\ <link rel="stylesheet" href="/reset.css" />
\\ <link rel="stylesheet" href="/main.css" />
\\ </head>
\\</html>
, trimmed);
}
test "Extends with shorthand append syntax" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var files = MockFiles.init(allocator);
defer files.deinit();
try files.put("layout.pug",
\\html
\\ head
\\ block head
\\ script(src='/vendor.js')
);
// Using shorthand: `append head` instead of `block append head`
const child =
\\extends layout.pug
\\
\\append head
\\ script(src='/app.js')
;
const result = try renderWithFiles(allocator, child, &files, .{});
const trimmed = std.mem.trimRight(u8, result, "\n");
try std.testing.expectEqualStrings(
\\<html>
\\ <head>
\\ <script src="/vendor.js"></script>
\\ <script src="/app.js"></script>
\\ </head>
\\</html>
, trimmed);
}
test "Extends without .pug extension" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var files = MockFiles.init(allocator);
defer files.deinit();
try files.put("layout.pug",
\\html
\\ body
\\ block content
);
// Reference without .pug extension
const child =
\\extends layout
\\
\\block content
\\ p Hello
;
const result = try renderWithFiles(allocator, child, &files, .{});
const trimmed = std.mem.trimRight(u8, result, "\n");
try std.testing.expectEqualStrings(
\\<html>
\\ <body>
\\ <p>Hello</p>
\\ </body>
\\</html>
, trimmed);
}
test "Extends with unused block keeps default" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var files = MockFiles.init(allocator);
defer files.deinit();
try files.put("layout.pug",
\\html
\\ body
\\ block content
\\ p Default
\\ block footer
\\ p Footer
);
// Only override content, footer keeps default
const child =
\\extends layout.pug
\\
\\block content
\\ p Overridden
;
const result = try renderWithFiles(allocator, child, &files, .{});
const trimmed = std.mem.trimRight(u8, result, "\n");
try std.testing.expectEqualStrings(
\\<html>
\\ <body>
\\ <p>Overridden</p>
\\ <p>Footer</p>
\\ </body>
\\</html>
, trimmed);
}
// ─────────────────────────────────────────────────────────────────────────────
// Include tests
// ─────────────────────────────────────────────────────────────────────────────
test "Include another template" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var files = MockFiles.init(allocator);
defer files.deinit();
try files.put("header.pug",
\\header
\\ h1 Site Header
);
const template =
\\html
\\ body
\\ include header.pug
\\ main
\\ p Content
;
const result = try renderWithFiles(allocator, template, &files, .{});
const trimmed = std.mem.trimRight(u8, result, "\n");
try std.testing.expectEqualStrings(
\\<html>
\\ <body>
\\ <header>
\\ <h1>Site Header</h1>
\\ </header>
\\ <main>
\\ <p>Content</p>
\\ </main>
\\ </body>
\\</html>
, trimmed);
}

View File

@@ -0,0 +1,18 @@
const std = @import("std");
const pugz = @import("pugz");
test "debug mixin tokens" {
const allocator = std.testing.allocator;
const template = "+pet('cat')";
var lexer = pugz.Lexer.init(allocator, template);
defer lexer.deinit();
const tokens = try lexer.tokenize();
std.debug.print("\n=== Tokens for: {s} ===\n", .{template});
for (tokens, 0..) |tok, i| {
std.debug.print("{d}: {s} = '{s}'\n", .{i, @tagName(tok.type), tok.value});
}
}