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

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

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

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

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

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

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

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

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

View File

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

View File

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