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:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"mcp__acp__Bash",
|
||||||
|
"mcp__acp__Write",
|
||||||
|
"mcp__acp__Edit"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
28
.editorconfig
Normal file
28
.editorconfig
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# EditorConfig is awesome: https://editorconfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Unix-style newlines with a newline ending every file
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
# Matches multiple files with brace expansion notation
|
||||||
|
# Set default charset
|
||||||
|
[*.{zig,pug}]
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
# 2 space indentation
|
||||||
|
[*.pug]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
# Tab indentation (no size specified)
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
# Matches the exact files either package.json or .travis.yml
|
||||||
|
[{package.json,.travis.yml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Zig build artifacts
|
||||||
|
zig-out/
|
||||||
|
zig-cache/
|
||||||
|
.zig-cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
301
CLAUDE.md
Normal file
301
CLAUDE.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Purpose
|
||||||
|
|
||||||
|
Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It implements Pug 3 syntax for indentation-based HTML templating with a focus on server-side rendering.
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
- `zig build` - Build the project (output in `zig-out/`)
|
||||||
|
- `zig build run` - Build and run the executable
|
||||||
|
- `zig build test` - Run all tests (113 tests currently)
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The template engine follows a classic compiler pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
Source → Lexer → Tokens → Parser → AST → Runtime → HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Modules
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| **src/lexer.zig** | Tokenizes Pug source into tokens. Handles indentation tracking, raw text blocks, interpolation. |
|
||||||
|
| **src/parser.zig** | Converts token stream into AST. Handles nesting via indent/dedent tokens. |
|
||||||
|
| **src/ast.zig** | AST node definitions (Element, Text, Conditional, Each, Mixin, etc.) |
|
||||||
|
| **src/runtime.zig** | Evaluates AST with data context, produces final HTML. Handles variable interpolation, conditionals, loops, mixins. |
|
||||||
|
| **src/codegen.zig** | Static HTML generation (without runtime evaluation). Outputs placeholders for dynamic content. |
|
||||||
|
| **src/root.zig** | Public library API - exports `renderTemplate()` and core types. |
|
||||||
|
| **src/main.zig** | CLI executable example. |
|
||||||
|
|
||||||
|
### Test Files
|
||||||
|
|
||||||
|
- **src/tests/general_test.zig** - Comprehensive integration tests for all features
|
||||||
|
|
||||||
|
## Memory Management
|
||||||
|
|
||||||
|
**Important**: The runtime is designed to work with `ArenaAllocator`:
|
||||||
|
|
||||||
|
```zig
|
||||||
|
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||||
|
defer arena.deinit(); // Frees all template memory at once
|
||||||
|
|
||||||
|
const html = try pugz.renderTemplate(arena.allocator(), template, data);
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern is recommended because template rendering creates many small allocations that are all freed together after the response is sent.
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
|
||||||
|
### Lexer State Machine
|
||||||
|
|
||||||
|
The lexer tracks several states for handling complex syntax:
|
||||||
|
- `in_raw_block` / `raw_block_indent` / `raw_block_started` - For dot block text (e.g., `script.`)
|
||||||
|
- `indent_stack` - Stack-based indent/dedent token generation
|
||||||
|
|
||||||
|
### Token Types
|
||||||
|
|
||||||
|
Key tokens: `tag`, `class`, `id`, `lparen`, `rparen`, `attr_name`, `attr_value`, `text`, `interp_start`, `interp_end`, `indent`, `dedent`, `dot_block`, `pipe_text`, `literal_html`, `self_close`, `mixin_call`, etc.
|
||||||
|
|
||||||
|
### AST Node Types
|
||||||
|
|
||||||
|
- `element` - HTML elements with tag, classes, id, attributes, children
|
||||||
|
- `text` - Text with segments (literal, escaped interpolation, unescaped interpolation, tag interpolation)
|
||||||
|
- `conditional` - if/else if/else/unless branches
|
||||||
|
- `each` - Iteration with value, optional index, else branch
|
||||||
|
- `mixin_def` / `mixin_call` - Mixin definitions and invocations
|
||||||
|
- `block` - Named blocks for template inheritance
|
||||||
|
- `include` / `extends` - File inclusion and inheritance
|
||||||
|
- `raw_text` - Literal HTML or text blocks
|
||||||
|
|
||||||
|
### Runtime Value System
|
||||||
|
|
||||||
|
```zig
|
||||||
|
pub const Value = union(enum) {
|
||||||
|
null,
|
||||||
|
bool: bool,
|
||||||
|
int: i64,
|
||||||
|
float: f64,
|
||||||
|
string: []const u8,
|
||||||
|
array: []const Value,
|
||||||
|
object: std.StringHashMapUnmanaged(Value),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `toValue()` function converts Zig structs to runtime Values automatically.
|
||||||
|
|
||||||
|
## Supported Pug Features
|
||||||
|
|
||||||
|
### Tags & Nesting
|
||||||
|
```pug
|
||||||
|
div
|
||||||
|
h1 Title
|
||||||
|
p Paragraph
|
||||||
|
```
|
||||||
|
|
||||||
|
### Classes & IDs (shorthand)
|
||||||
|
```pug
|
||||||
|
div#main.container.active
|
||||||
|
.box // defaults to div
|
||||||
|
#sidebar // defaults to div
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attributes
|
||||||
|
```pug
|
||||||
|
a(href="/link" target="_blank") Click
|
||||||
|
input(type="checkbox" checked)
|
||||||
|
div(style={color: 'red'})
|
||||||
|
div(class=['foo', 'bar'])
|
||||||
|
button(disabled=false) // omitted when false
|
||||||
|
button(disabled=true) // disabled="disabled"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text & Interpolation
|
||||||
|
```pug
|
||||||
|
p Hello #{name} // escaped interpolation
|
||||||
|
p Hello !{rawHtml} // unescaped interpolation
|
||||||
|
p= variable // buffered code (escaped)
|
||||||
|
p!= rawVariable // buffered code (unescaped)
|
||||||
|
| Piped text line
|
||||||
|
p.
|
||||||
|
Multi-line
|
||||||
|
text block
|
||||||
|
<p>Literal HTML</p> // passed through as-is
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag Interpolation
|
||||||
|
```pug
|
||||||
|
p This is #[em emphasized] text
|
||||||
|
p Click #[a(href="/") here] to continue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block Expansion
|
||||||
|
```pug
|
||||||
|
a: img(src="logo.png") // colon for inline nesting
|
||||||
|
```
|
||||||
|
|
||||||
|
### Explicit Self-Closing
|
||||||
|
```pug
|
||||||
|
foo/ // renders as <foo />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditionals
|
||||||
|
```pug
|
||||||
|
if condition
|
||||||
|
p Yes
|
||||||
|
else if other
|
||||||
|
p Maybe
|
||||||
|
else
|
||||||
|
p No
|
||||||
|
|
||||||
|
unless loggedIn
|
||||||
|
p Please login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Iteration
|
||||||
|
```pug
|
||||||
|
each item in items
|
||||||
|
li= item
|
||||||
|
|
||||||
|
each val, index in list
|
||||||
|
li #{index}: #{val}
|
||||||
|
|
||||||
|
each item in items
|
||||||
|
li= item
|
||||||
|
else
|
||||||
|
li No items
|
||||||
|
|
||||||
|
// Works with objects too (key as index)
|
||||||
|
each val, key in object
|
||||||
|
p #{key}: #{val}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Case/When
|
||||||
|
```pug
|
||||||
|
case status
|
||||||
|
when "active"
|
||||||
|
p Active
|
||||||
|
when "pending"
|
||||||
|
p Pending
|
||||||
|
default
|
||||||
|
p Unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mixins
|
||||||
|
```pug
|
||||||
|
mixin button(text, type="primary")
|
||||||
|
button(class="btn btn-" + type)= text
|
||||||
|
|
||||||
|
+button("Click me")
|
||||||
|
+button("Submit", "success")
|
||||||
|
|
||||||
|
// With block content
|
||||||
|
mixin card(title)
|
||||||
|
.card
|
||||||
|
h3= title
|
||||||
|
block
|
||||||
|
|
||||||
|
+card("My Card")
|
||||||
|
p Card content here
|
||||||
|
|
||||||
|
// Rest arguments
|
||||||
|
mixin list(id, ...items)
|
||||||
|
ul(id=id)
|
||||||
|
each item in items
|
||||||
|
li= item
|
||||||
|
|
||||||
|
+list("mylist", "a", "b", "c")
|
||||||
|
|
||||||
|
// Attributes pass-through
|
||||||
|
mixin link(href, text)
|
||||||
|
a(href=href)&attributes(attributes)= text
|
||||||
|
|
||||||
|
+link("/home", "Home")(class="nav-link" data-id="1")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Includes & Inheritance
|
||||||
|
```pug
|
||||||
|
include header.pug
|
||||||
|
|
||||||
|
extends layout.pug
|
||||||
|
block content
|
||||||
|
h1 Page Title
|
||||||
|
|
||||||
|
// Block modes
|
||||||
|
block append scripts
|
||||||
|
script(src="extra.js")
|
||||||
|
|
||||||
|
block prepend styles
|
||||||
|
link(rel="stylesheet" href="extra.css")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
```pug
|
||||||
|
// This renders as HTML comment
|
||||||
|
//- This is a silent comment (not in output)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Usage Example
|
||||||
|
|
||||||
|
```zig
|
||||||
|
const std = @import("std");
|
||||||
|
const pugz = @import("pugz");
|
||||||
|
|
||||||
|
pub fn handleRequest(allocator: std.mem.Allocator) ![]u8 {
|
||||||
|
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
|
||||||
|
return try pugz.renderTemplate(arena.allocator(),
|
||||||
|
\\html
|
||||||
|
\\ head
|
||||||
|
\\ title= title
|
||||||
|
\\ body
|
||||||
|
\\ h1 Hello, #{name}!
|
||||||
|
\\ if showList
|
||||||
|
\\ ul
|
||||||
|
\\ each item in items
|
||||||
|
\\ li= item
|
||||||
|
, .{
|
||||||
|
.title = "My Page",
|
||||||
|
.name = "World",
|
||||||
|
.showList = true,
|
||||||
|
.items = &[_][]const u8{ "One", "Two", "Three" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests with `zig build test`. Tests cover:
|
||||||
|
- Basic element parsing and rendering
|
||||||
|
- Class and ID shorthand syntax
|
||||||
|
- Attribute parsing (quoted, unquoted, boolean, object literals)
|
||||||
|
- Text interpolation (escaped, unescaped, tag interpolation)
|
||||||
|
- Conditionals (if/else if/else/unless)
|
||||||
|
- Iteration (each with index, else branch, objects)
|
||||||
|
- Case/when statements
|
||||||
|
- Mixin definitions and calls (with defaults, rest args, block content, attributes)
|
||||||
|
- Plain text (piped, dot blocks, literal HTML)
|
||||||
|
- Self-closing tags (void elements, explicit `/`)
|
||||||
|
- Block expansion with colon
|
||||||
|
- Comments (rendered and silent)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The lexer and parser return errors for invalid syntax:
|
||||||
|
- `ParserError.UnexpectedToken`
|
||||||
|
- `ParserError.MissingCondition`
|
||||||
|
- `ParserError.MissingMixinName`
|
||||||
|
- `RuntimeError.ParseError` (wrapped for convenience API)
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
Potential areas for enhancement:
|
||||||
|
- Filter support (`:markdown`, `:stylus`, etc.)
|
||||||
|
- More complete JavaScript expression evaluation
|
||||||
|
- Source maps for debugging
|
||||||
|
- Compile-time template validation
|
||||||
161
build.zig
Normal file
161
build.zig
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub fn build(b: *std.Build) void {
|
||||||
|
const target = b.standardTargetOptions(.{});
|
||||||
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
const mod = b.addModule("pugz", .{
|
||||||
|
.root_source_file = b.path("src/root.zig"),
|
||||||
|
.target = target,
|
||||||
|
});
|
||||||
|
|
||||||
|
const exe = b.addExecutable(.{
|
||||||
|
.name = "pugz",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/main.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "pugz", .module = mod },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
b.installArtifact(exe);
|
||||||
|
const run_step = b.step("run", "Run the app");
|
||||||
|
|
||||||
|
const run_cmd = b.addRunArtifact(exe);
|
||||||
|
run_step.dependOn(&run_cmd.step);
|
||||||
|
|
||||||
|
// By making the run step depend on the default step, it will be run from the
|
||||||
|
// installation directory rather than directly from within the cache directory.
|
||||||
|
run_cmd.step.dependOn(b.getInstallStep());
|
||||||
|
|
||||||
|
// This allows the user to pass arguments to the application in the build
|
||||||
|
// command itself, like this: `zig build run -- arg1 arg2 etc`
|
||||||
|
if (b.args) |args| {
|
||||||
|
run_cmd.addArgs(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates an executable that will run `test` blocks from the provided module.
|
||||||
|
// Here `mod` needs to define a target, which is why earlier we made sure to
|
||||||
|
// set the releative field.
|
||||||
|
const mod_tests = b.addTest(.{
|
||||||
|
.root_module = mod,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A run step that will run the test executable.
|
||||||
|
const run_mod_tests = b.addRunArtifact(mod_tests);
|
||||||
|
|
||||||
|
// Creates an executable that will run `test` blocks from the executable's
|
||||||
|
// root module. Note that test executables only test one module at a time,
|
||||||
|
// hence why we have to create two separate ones.
|
||||||
|
const exe_tests = b.addTest(.{
|
||||||
|
.root_module = exe.root_module,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A run step that will run the second test executable.
|
||||||
|
const run_exe_tests = b.addRunArtifact(exe_tests);
|
||||||
|
|
||||||
|
// Integration tests - general template tests
|
||||||
|
const general_tests = b.addTest(.{
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/tests/general_test.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "pugz", .module = mod },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const run_general_tests = b.addRunArtifact(general_tests);
|
||||||
|
|
||||||
|
// Integration tests - doctype tests
|
||||||
|
const doctype_tests = b.addTest(.{
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/tests/doctype_test.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "pugz", .module = mod },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const run_doctype_tests = b.addRunArtifact(doctype_tests);
|
||||||
|
|
||||||
|
// Integration tests - inheritance tests
|
||||||
|
const inheritance_tests = b.addTest(.{
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/tests/inheritance_test.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "pugz", .module = mod },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const run_inheritance_tests = b.addRunArtifact(inheritance_tests);
|
||||||
|
|
||||||
|
// A top level step for running all tests. dependOn can be called multiple
|
||||||
|
// times and since the two run steps do not depend on one another, this will
|
||||||
|
// make the two of them run in parallel.
|
||||||
|
const test_step = b.step("test", "Run all tests");
|
||||||
|
test_step.dependOn(&run_mod_tests.step);
|
||||||
|
test_step.dependOn(&run_exe_tests.step);
|
||||||
|
test_step.dependOn(&run_general_tests.step);
|
||||||
|
test_step.dependOn(&run_doctype_tests.step);
|
||||||
|
test_step.dependOn(&run_inheritance_tests.step);
|
||||||
|
|
||||||
|
// Individual test steps
|
||||||
|
const test_general_step = b.step("test-general", "Run general template tests");
|
||||||
|
test_general_step.dependOn(&run_general_tests.step);
|
||||||
|
|
||||||
|
const test_doctype_step = b.step("test-doctype", "Run doctype tests");
|
||||||
|
test_doctype_step.dependOn(&run_doctype_tests.step);
|
||||||
|
|
||||||
|
const test_inheritance_step = b.step("test-inheritance", "Run inheritance tests");
|
||||||
|
test_inheritance_step.dependOn(&run_inheritance_tests.step);
|
||||||
|
|
||||||
|
const test_unit_step = b.step("test-unit", "Run unit tests (lexer, parser, etc.)");
|
||||||
|
test_unit_step.dependOn(&run_mod_tests.step);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Example: app_01 - Template Inheritance Demo with http.zig
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
const httpz_dep = b.dependency("httpz", .{
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app_01 = b.addExecutable(.{
|
||||||
|
.name = "app_01",
|
||||||
|
.root_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/examples/app_01/main.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.imports = &.{
|
||||||
|
.{ .name = "pugz", .module = mod },
|
||||||
|
.{ .name = "httpz", .module = httpz_dep.module("httpz") },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
b.installArtifact(app_01);
|
||||||
|
|
||||||
|
const run_app_01 = b.addRunArtifact(app_01);
|
||||||
|
run_app_01.step.dependOn(b.getInstallStep());
|
||||||
|
|
||||||
|
const app_01_step = b.step("app-01", "Run the template inheritance demo web app");
|
||||||
|
app_01_step.dependOn(&run_app_01.step);
|
||||||
|
|
||||||
|
// Just like flags, top level steps are also listed in the `--help` menu.
|
||||||
|
//
|
||||||
|
// The Zig build system is entirely implemented in userland, which means
|
||||||
|
// that it cannot hook into private compiler APIs. All compilation work
|
||||||
|
// orchestrated by the build system will result in other Zig compiler
|
||||||
|
// subcommands being invoked with the right flags defined. You can observe
|
||||||
|
// these invocations when one fails (or you pass a flag to increase
|
||||||
|
// verbosity) to validate assumptions and diagnose problems.
|
||||||
|
//
|
||||||
|
// Lastly, the Zig build system is relatively simple and self-contained,
|
||||||
|
// and reading its source code will allow you to master it.
|
||||||
|
}
|
||||||
48
build.zig.zon
Normal file
48
build.zig.zon
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
.{
|
||||||
|
// This is the default name used by packages depending on this one. For
|
||||||
|
// example, when a user runs `zig fetch --save <url>`, this field is used
|
||||||
|
// as the key in the `dependencies` table. Although the user can choose a
|
||||||
|
// different name, most users will stick with this provided value.
|
||||||
|
//
|
||||||
|
// It is redundant to include "zig" in this name because it is already
|
||||||
|
// within the Zig package namespace.
|
||||||
|
.name = .pugz,
|
||||||
|
// This is a [Semantic Version](https://semver.org/).
|
||||||
|
// In a future version of Zig it will be used for package deduplication.
|
||||||
|
.version = "0.0.0",
|
||||||
|
// Together with name, this represents a globally unique package
|
||||||
|
// identifier. This field is generated by the Zig toolchain when the
|
||||||
|
// package is first created, and then *never changes*. This allows
|
||||||
|
// unambiguous detection of one package being an updated version of
|
||||||
|
// another.
|
||||||
|
//
|
||||||
|
// When forking a Zig project, this id should be regenerated (delete the
|
||||||
|
// field and run `zig build`) if the upstream project is still maintained.
|
||||||
|
// Otherwise, the fork is *hostile*, attempting to take control over the
|
||||||
|
// original project's identity. Thus it is recommended to leave the comment
|
||||||
|
// on the following line intact, so that it shows up in code reviews that
|
||||||
|
// modify the field.
|
||||||
|
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
|
||||||
|
// Tracks the earliest Zig version that the package considers to be a
|
||||||
|
// supported use case.
|
||||||
|
.minimum_zig_version = "0.15.2",
|
||||||
|
// This field is optional.
|
||||||
|
// Each dependency must either provide a `url` and `hash`, or a `path`.
|
||||||
|
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
|
||||||
|
// Once all dependencies are fetched, `zig build` no longer requires
|
||||||
|
// internet connectivity.
|
||||||
|
.dependencies = .{
|
||||||
|
.httpz = .{
|
||||||
|
.url = "git+https://github.com/karlseguin/http.zig?ref=master#9ef2ffe8d611ff2e1081e5cf39cb4632c145c5b9",
|
||||||
|
.hash = "httpz-0.0.0-PNVzrIowBwAFr_kqBN1W4KBMC2Ofutasj2ZfNAIcfTzF",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.paths = .{
|
||||||
|
"build.zig",
|
||||||
|
"build.zig.zon",
|
||||||
|
"src",
|
||||||
|
// For example...
|
||||||
|
//"LICENSE",
|
||||||
|
//"README.md",
|
||||||
|
},
|
||||||
|
}
|
||||||
313
src/ast.zig
Normal file
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