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:
313
src/ast.zig
Normal file
313
src/ast.zig
Normal 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
780
src/codegen.zig
Normal 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("&"),
|
||||
'<' => try self.write("<"),
|
||||
'>' => try self.write(">"),
|
||||
'"' => try self.write("""),
|
||||
'\'' => try self.write("'"),
|
||||
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><script>alert('xss')</script></p>\n", html);
|
||||
}
|
||||
186
src/examples/app_01/main.zig
Normal file
186
src/examples/app_01/main.zig
Normal 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();
|
||||
}
|
||||
7
src/examples/app_01/views/layout-2.pug
Normal file
7
src/examples/app_01/views/layout-2.pug
Normal file
@@ -0,0 +1,7 @@
|
||||
html
|
||||
head
|
||||
block head
|
||||
script(src='/vendor/jquery.js')
|
||||
script(src='/vendor/caustic.js')
|
||||
body
|
||||
block content
|
||||
10
src/examples/app_01/views/layout.pug
Normal file
10
src/examples/app_01/views/layout.pug
Normal 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
|
||||
15
src/examples/app_01/views/page-a.pug
Normal file
15
src/examples/app_01/views/page-a.pug
Normal 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
|
||||
5
src/examples/app_01/views/page-appen-optional-blk.pug
Normal file
5
src/examples/app_01/views/page-appen-optional-blk.pug
Normal file
@@ -0,0 +1,5 @@
|
||||
extends layout
|
||||
|
||||
append head
|
||||
script(src='/vendor/three.js')
|
||||
script(src='/game.js')
|
||||
11
src/examples/app_01/views/page-append.pug
Normal file
11
src/examples/app_01/views/page-append.pug
Normal 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
|
||||
9
src/examples/app_01/views/page-b.pug
Normal file
9
src/examples/app_01/views/page-b.pug
Normal file
@@ -0,0 +1,9 @@
|
||||
extends sub-layout.pug
|
||||
|
||||
block content
|
||||
.sidebar
|
||||
block sidebar
|
||||
p nothing
|
||||
.primary
|
||||
block primary
|
||||
p nothing
|
||||
1
src/examples/app_01/views/pet.pug
Normal file
1
src/examples/app_01/views/pet.pug
Normal file
@@ -0,0 +1 @@
|
||||
p= petName
|
||||
9
src/examples/app_01/views/sub-layout.pug
Normal file
9
src/examples/app_01/views/sub-layout.pug
Normal 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
1436
src/lexer.zig
Normal file
File diff suppressed because it is too large
Load Diff
62
src/main.zig
Normal file
62
src/main.zig
Normal 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
1243
src/parser.zig
Normal file
File diff suppressed because it is too large
Load Diff
37
src/root.zig
Normal file
37
src/root.zig
Normal 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
1483
src/runtime.zig
Normal file
File diff suppressed because it is too large
Load Diff
286
src/test_templates.zig
Normal file
286
src/test_templates.zig
Normal 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'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><script>alert('xss')</script></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><b>bold</b></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
104
src/tests/doctype_test.zig
Normal 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
717
src/tests/general_test.zig
Normal 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'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="
|
||||
\\ {
|
||||
\\ "very-long": "piece of ",
|
||||
\\ "data": true
|
||||
\\ }
|
||||
\\" />
|
||||
,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test Case 5: Escaped vs unescaped attribute values
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
test "Escaped attribute value" {
|
||||
try expectOutput(
|
||||
"div(escaped=\"<code>\")",
|
||||
.{},
|
||||
"<div escaped=\"<code>\"></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
24
src/tests/helper.zig
Normal 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);
|
||||
}
|
||||
378
src/tests/inheritance_test.zig
Normal file
378
src/tests/inheritance_test.zig
Normal 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);
|
||||
}
|
||||
18
src/tests/mixin_debug_test.zig
Normal file
18
src/tests/mixin_debug_test.zig
Normal 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});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user