follow PugJs

This commit is contained in:
2026-01-24 23:53:19 +05:30
parent 621f8def47
commit 27c4898706
893 changed files with 44597 additions and 10484 deletions

View File

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

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ zig-out/
zig-cache/
.zig-cache/
.pugz-cache/
.claude
node_modules
# compiled template file

460
CLAUDE.md
View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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.
Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It compiles Pug templates directly to HTML (unlike JS pug which compiles to JavaScript functions). It implements Pug 3 syntax for indentation-based HTML templating with a focus on server-side rendering.
## Rules
- Do not auto commit, user will do it.
@@ -16,119 +16,142 @@ Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It implements Pug
- `zig build` - Build the project (output in `zig-out/`)
- `zig build test` - Run all tests
- `zig build bench-compiled` - Run compiled templates benchmark (compare with Pug.js)
- `zig build bench-interpreted` - Inpterpret trmplates
- `zig build bench-v1` - Run v1 template benchmark
- `zig build bench-interpreted` - Run interpreted templates benchmark
## Architecture Overview
The template engine supports two rendering modes:
### Compilation Pipeline
### 1. Runtime Rendering (Interpreted)
```
Source → Lexer → Tokens → Parser → AST → Runtime → HTML
Source → Lexer → Tokens → StripComments → Parser → AST → Linker → Codegen → HTML
```
### 2. Build-Time Compilation (Compiled)
```
Source → Lexer → Tokens → Parser → AST → build_templates.zig → generated.zig → Native Zig Code
```
### Two Rendering Modes
The compiled mode is **~3x faster** than Pug.js.
1. **Static compilation** (`pug.compile`): Outputs HTML directly
2. **Data binding** (`template.renderWithData`): Supports `#{field}` interpolation with Zig structs
### 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/build_templates.zig** | Build-time template compiler. Generates optimized Zig code from `.pug` templates. |
| **src/view_engine.zig** | High-level ViewEngine for web servers. Manages views directory, auto-loads mixins. |
| **src/root.zig** | Public library API - exports `ViewEngine`, `renderTemplate()`, `build_templates` and core types. |
| Module | File | Purpose |
|--------|------|---------|
| **Lexer** | `src/lexer.zig` | Tokenizes Pug source into tokens |
| **Parser** | `src/parser.zig` | Builds AST from tokens |
| **Runtime** | `src/runtime.zig` | Shared utilities (HTML escaping, etc.) |
| **Error** | `src/error.zig` | Error formatting with source context |
| **Walk** | `src/walk.zig` | AST traversal with visitor pattern |
| **Strip Comments** | `src/strip_comments.zig` | Token filtering for comments |
| **Load** | `src/load.zig` | File loading for includes/extends |
| **Linker** | `src/linker.zig` | Template inheritance (extends/blocks) |
| **Codegen** | `src/codegen.zig` | AST to HTML generation |
| **Template** | `src/template.zig` | Data binding renderer |
| **Pug** | `src/pug.zig` | Main entry point |
| **ViewEngine** | `src/view_engine.zig` | High-level API for web servers |
| **Root** | `src/root.zig` | Public library API exports |
### Test Files
- **src/tests/general_test.zig** - Comprehensive integration tests for all features
- **src/tests/general_test.zig** - Comprehensive integration tests
- **src/tests/doctype_test.zig** - Doctype-specific tests
- **src/tests/inheritance_test.zig** - Template inheritance tests
- **src/tests/check_list_test.zig** - Template output validation tests
- **src/lexer_test.zig** - Lexer unit tests
- **src/parser_test.zig** - Parser unit tests
## Build-Time Template Compilation
## API Usage
For maximum performance, templates can be compiled to native Zig code at build time.
### Setup in build.zig
### Static Compilation (no data)
```zig
const std = @import("std");
const pug = @import("pugz").pug;
pub fn build(b: *std.Build) void {
const pugz_dep = b.dependency("pugz", .{});
pub fn main() !void {
const allocator = std.heap.page_allocator;
// Compile templates at build time
const build_templates = @import("pugz").build_templates;
const compiled_templates = build_templates.compileTemplates(b, .{
.source_dir = "views", // Directory containing .pug files
});
var result = try pug.compile(allocator, "p Hello World", .{});
defer result.deinit(allocator);
const exe = b.addExecutable(.{
.name = "myapp",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.imports = &.{
.{ .name = "pugz", .module = pugz_dep.module("pugz") },
.{ .name = "tpls", .module = compiled_templates },
},
}),
});
std.debug.print("{s}\n", .{result.html}); // <p>Hello World</p>
}
```
### Usage in Code
### Dynamic Rendering with Data
```zig
const tpls = @import("tpls");
const std = @import("std");
const pugz = @import("pugz");
pub fn handleRequest(allocator: std.mem.Allocator) ![]u8 {
// Zero-cost template rendering - just native Zig code
return try tpls.home(allocator, .{
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const html = try pugz.renderTemplate(arena.allocator(),
\\h1 #{title}
\\p #{message}
, .{
.title = "Welcome",
.user = .{ .name = "Alice", .email = "alice@example.com" },
.items = &[_][]const u8{ "One", "Two", "Three" },
.message = "Hello, World!",
});
std.debug.print("{s}\n", .{html});
// Output: <h1>Welcome</h1><p>Hello, World!</p>
}
```
### Data Binding Features
- **Interpolation**: `#{fieldName}` in text content
- **Attribute binding**: `a(href=url)` binds `url` field to href
- **Buffered code**: `p= message` outputs the `message` field
- **Auto-escaping**: HTML is escaped by default (XSS protection)
```zig
const html = try pugz.renderTemplate(allocator,
\\a(href=url, class=style) #{text}
, .{
.url = "https://example.com",
.style = "btn",
.text = "Click me!",
});
// Output: <a href="https://example.com" class="btn">Click me!</a>
```
### ViewEngine (for Web Servers)
```zig
const std = @import("std");
const pugz = @import("pugz");
const engine = pugz.ViewEngine.init(.{
.views_dir = "src/views",
.extension = ".pug",
});
// In request handler
pub fn handleRequest(allocator: std.mem.Allocator) ![]const u8 {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
return try engine.render(arena.allocator(), "pages/home", .{
.title = "Home",
.user = .{ .name = "Alice" },
});
}
```
### Generated Code Features
### Compile Options
The compiler generates optimized Zig code with:
- **Static string merging** - Consecutive static content merged into single `appendSlice` calls
- **Zero allocation for static templates** - Returns string literal directly
- **Type-safe data access** - Uses `@field(d, "name")` for compile-time checked field access
- **Automatic type conversion** - `strVal()` helper converts integers to strings
- **Optional handling** - Nullable slices handled with `orelse &.{}`
- **HTML escaping** - Lookup table for fast character escaping
### Benchmark Results (2000 iterations)
| Template | Pug.js | Pugz | Speedup |
|----------|--------|------|---------|
| simple-0 | 0.8ms | 0.1ms | **8x** |
| simple-1 | 1.4ms | 0.6ms | **2.3x** |
| simple-2 | 1.8ms | 0.6ms | **3x** |
| if-expression | 0.6ms | 0.2ms | **3x** |
| projects-escaped | 4.4ms | 0.6ms | **7.3x** |
| search-results | 15.2ms | 5.6ms | **2.7x** |
| friends | 153.5ms | 54.0ms | **2.8x** |
| **TOTAL** | **177.6ms** | **61.6ms** | **~3x faster** |
Run benchmarks:
```bash
# Pugz (Zig)
zig build bench-compiled
# Pug.js (for comparison)
cd src/benchmarks/pugjs && npm install && npm run bench
```zig
pub const CompileOptions = struct {
filename: ?[]const u8 = null, // For error messages
basedir: ?[]const u8 = null, // For absolute includes
pretty: bool = false, // Pretty print output
strip_unbuffered_comments: bool = true,
strip_buffered_comments: bool = false,
debug: bool = false,
doctype: ?[]const u8 = null,
};
```
## Memory Management
@@ -144,46 +167,30 @@ 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
## Key Implementation Notes
### Lexer State Machine
### Lexer (`lexer.zig`)
- `Lexer.init(allocator, source, options)` - Initialize
- `Lexer.getTokens()` - Returns token slice
- `Lexer.last_error` - Check for errors after failed `getTokens()`
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
### Parser (`parser.zig`)
- `Parser.init(allocator, tokens, filename, source)` - Initialize
- `Parser.parse()` - Returns AST root node
- `Parser.err` - Check for errors after failed `parse()`
**Important**: The lexer distinguishes between `#id` (ID selector), `#{expr}` (interpolation), and `#[tag]` (tag interpolation) by looking ahead at the next character.
### Codegen (`codegen.zig`)
- `Compiler.init(allocator, options)` - Initialize
- `Compiler.compile(ast)` - Returns HTML string
### Token Types
### Walk (`walk.zig`)
- Uses O(1) stack operations (append/pop) not O(n) insert/remove
- `getParent(index)` uses reverse indexing (0 = immediate parent)
- `initWithCapacity()` for pre-allocation optimization
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.
### Runtime (`runtime.zig`)
- `escapeChar(c)` - Shared HTML escape function
- `appendEscaped(list, allocator, str)` - Append with escaping
## Supported Pug Features
@@ -222,12 +229,9 @@ p.
Multi-line
text block
<p>Literal HTML</p> // passed through as-is
// Interpolation-only text works too
h1.header #{title} // renders <h1 class="header">Title Value</h1>
```
**Security Note**: By default, `#{}` and `=` escape HTML entities (`<`, `>`, `&`, `"`, `'`) to prevent XSS attacks. Only use `!{}` or `!=` for content you fully trust (e.g., pre-sanitized HTML from your own code). Never use unescaped output for user-provided data.
**Security Note**: By default, `#{}` and `=` escape HTML entities (`<`, `>`, `&`, `"`, `'`) to prevent XSS attacks. Only use `!{}` or `!=` for content you fully trust.
### Tag Interpolation
```pug
@@ -240,11 +244,6 @@ p Click #[a(href="/") here] to continue
a: img(src="logo.png") // colon for inline nesting
```
### Explicit Self-Closing
```pug
foo/ // renders as <foo />
```
### Conditionals
```pug
if condition
@@ -256,10 +255,6 @@ else
unless loggedIn
p Please login
// String comparison in conditions
if status == "active"
p Active
```
### Iteration
@@ -274,16 +269,6 @@ 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}
// Nested iteration with field access
each friend in friends
li #{friend.name}
each tag in friend.tags
span= tag
```
### Case/When
@@ -304,29 +289,6 @@ mixin button(text, type="primary")
+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
@@ -336,13 +298,6 @@ 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
@@ -351,136 +306,57 @@ block prepend styles
//- This is a silent comment (not in output)
```
## Server Usage
## Benchmark Results (2000 iterations)
### Compiled Templates (Recommended for Production)
| Template | Time |
|----------|------|
| simple-0 | 0.8ms |
| simple-1 | 11.6ms |
| simple-2 | 8.2ms |
| if-expression | 7.4ms |
| projects-escaped | 7.1ms |
| search-results | 13.4ms |
| friends | 22.9ms |
| **TOTAL** | **71.3ms** |
Use build-time compilation for best performance. See "Build-Time Template Compilation" section above.
## Limitations vs JS Pug
### ViewEngine (Runtime Rendering)
The `ViewEngine` provides runtime template rendering with lazy-loading:
```zig
const std = @import("std");
const pugz = @import("pugz");
// Initialize once at server startup
var engine = try pugz.ViewEngine.init(allocator, .{
.views_dir = "src/views", // Root views directory
.mixins_dir = "mixins", // Mixins dir for lazy-loading (optional, default: "mixins")
.extension = ".pug", // File extension (default: .pug)
.pretty = true, // Pretty-print output (default: true)
});
defer engine.deinit();
// In request handler - use arena allocator per request
pub fn handleRequest(engine: *pugz.ViewEngine, allocator: std.mem.Allocator) ![]u8 {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
// Template path is relative to views_dir, extension added automatically
return try engine.render(arena.allocator(), "pages/home", .{
.title = "Home",
.user = .{ .name = "Alice" },
});
}
```
### Mixin Resolution (Lazy Loading)
Mixins are resolved in the following order:
1. **Same template** - Mixins defined in the current template file
2. **Mixins directory** - If not found, searches `views/mixins/*.pug` files (lazy-loaded on first use)
This lazy-loading approach means:
- Mixins are only parsed when first called
- No upfront loading of all mixin files at server startup
- Templates can override mixins from the mixins directory by defining them locally
### Directory Structure
```
src/views/
├── mixins/ # Lazy-loaded mixins (searched when mixin not found in template)
│ ├── buttons.pug # mixin btn(text), mixin btn-link(href, text)
│ └── cards.pug # mixin card(title), mixin card-simple(title, body)
├── layouts/
│ └── base.pug # Base layout with blocks
├── partials/
│ ├── header.pug
│ └── footer.pug
└── pages/
├── home.pug # extends layouts/base
└── about.pug # extends layouts/base
```
Templates can use:
- `extends layouts/base` - Paths relative to views_dir
- `include partials/header` - Paths relative to views_dir
- `+btn("Click")` - Mixins from mixins/ dir loaded on-demand
### Low-Level API
For inline templates or custom use cases:
```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)
- Interpolation-only text (e.g., `h1.class #{var}`)
- Conditionals (if/else if/else/unless)
- Iteration (each with index, else branch, objects, nested loops)
- 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)
- String comparison in conditions
1. **No JavaScript expressions**: `- var x = 1` not supported
2. **No nested field access**: `#{user.name}` not supported, only `#{name}`
3. **No filters**: `:markdown`, `:coffee` etc. not implemented
4. **String fields only**: Data binding works best with `[]const u8` fields
## Error Handling
The lexer and parser return errors for invalid syntax:
- `ParserError.UnexpectedToken`
- `ParserError.MissingCondition`
- `ParserError.MissingMixinName`
- `RuntimeError.ParseError` (wrapped for convenience API)
Uses error unions with detailed `PugError` context including line, column, and source snippet:
- `LexerError` - Tokenization errors
- `ParserError` - Syntax errors
- `ViewEngineError` - Template not found, parse errors
## Future Improvements
## File Structure
Potential areas for enhancement:
- Filter support (`:markdown`, `:stylus`, etc.)
- More complete JavaScript expression evaluation
- Source maps for debugging
- Mixin support in compiled templates
```
src/
├── root.zig # Public library API
├── view_engine.zig # High-level ViewEngine
├── pug.zig # Main entry point (static compilation)
├── template.zig # Data binding renderer
├── lexer.zig # Tokenizer
├── lexer_test.zig # Lexer tests
├── parser.zig # AST parser
├── parser_test.zig # Parser tests
├── runtime.zig # Shared utilities
├── error.zig # Error formatting
├── walk.zig # AST traversal
├── strip_comments.zig # Comment filtering
├── load.zig # File loading
├── linker.zig # Template inheritance
├── codegen.zig # HTML generation
├── tests/ # Integration tests
│ ├── general_test.zig
│ ├── doctype_test.zig
│ └── check_list_test.zig
└── benchmarks/ # Performance benchmarks
├── bench_v1.zig
└── bench_interpreted.zig
```

View File

@@ -1,6 +1,5 @@
*Yet not ready to use in production, i tried to get it done using Cluade but its not quite there where i want it*
*So i will try it by my self keeping PugJS version as a reference*
*! I am using ClaudeCode to build it*
*! Its Yet not ready for production use*
# Pugz

View File

@@ -1,8 +1,5 @@
const std = @import("std");
// Re-export build_templates for use by dependent packages
pub const build_templates = @import("src/build_templates.zig");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
@@ -46,19 +43,6 @@ pub fn build(b: *std.Build) void {
});
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);
// Integration tests - check_list tests (pug files vs expected html output)
const check_list_tests = b.addTest(.{
.root_module = b.createModule(.{
@@ -72,14 +56,11 @@ pub fn build(b: *std.Build) void {
});
const run_check_list_tests = b.addRunArtifact(check_list_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.
// A top level step for running all tests.
const test_step = b.step("test", "Run all tests");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_general_tests.step);
test_step.dependOn(&run_doctype_tests.step);
test_step.dependOn(&run_inheritance_tests.step);
test_step.dependOn(&run_check_list_tests.step);
// Individual test steps
@@ -89,82 +70,28 @@ pub fn build(b: *std.Build) void {
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);
const test_check_list_step = b.step("test-check-list", "Run check_list template tests");
test_check_list_step.dependOn(&run_check_list_tests.step);
// ─────────────────────────────────────────────────────────────────────────
// Compiled Templates Benchmark (compare with Pug.js bench.js)
// Uses auto-generated templates from src/benchmarks/templates/
// ─────────────────────────────────────────────────────────────────────────
const mod_fast = b.addModule("pugz-fast", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
.optimize = .ReleaseFast,
});
const bench_templates = build_templates.compileTemplates(b, .{
.source_dir = "src/benchmarks/templates",
});
const bench_compiled = b.addExecutable(.{
.name = "bench-compiled",
// Benchmark executable
const bench_exe = b.addExecutable(.{
.name = "bench",
.root_module = b.createModule(.{
.root_source_file = b.path("src/benchmarks/bench.zig"),
.target = target,
.optimize = .ReleaseFast,
.imports = &.{
.{ .name = "pugz", .module = mod_fast },
.{ .name = "tpls", .module = bench_templates },
.{ .name = "pugz", .module = mod },
},
}),
});
b.installArtifact(bench_exe);
b.installArtifact(bench_compiled);
const run_bench_compiled = b.addRunArtifact(bench_compiled);
run_bench_compiled.step.dependOn(b.getInstallStep());
const bench_compiled_step = b.step("bench-compiled", "Benchmark compiled templates (compare with Pug.js)");
bench_compiled_step.dependOn(&run_bench_compiled.step);
// ─────────────────────────────────────────────────────────────────────────
// Interpreted (Runtime) Benchmark
// ─────────────────────────────────────────────────────────────────────────
const bench_interpreted = b.addExecutable(.{
.name = "bench-interpreted",
.root_module = b.createModule(.{
.root_source_file = b.path("src/benchmarks/bench_interpreted.zig"),
.target = target,
.optimize = .ReleaseFast,
.imports = &.{
.{ .name = "pugz", .module = mod_fast },
},
}),
});
b.installArtifact(bench_interpreted);
const run_bench_interpreted = b.addRunArtifact(bench_interpreted);
run_bench_interpreted.step.dependOn(b.getInstallStep());
const bench_interpreted_step = b.step("bench-interpreted", "Benchmark interpreted (runtime) templates");
bench_interpreted_step.dependOn(&run_bench_interpreted.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.
const run_bench = b.addRunArtifact(bench_exe);
run_bench.setCwd(b.path("."));
const bench_step = b.step("bench", "Run benchmark");
bench_step.dependOn(&run_bench.step);
}

View File

@@ -14,13 +14,6 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
});
// Compile templates at build time using pugz's build_templates
// Generates views/generated.zig with all templates
const build_templates = @import("pugz").build_templates;
const compiled_templates = build_templates.compileTemplates(b, .{
.source_dir = "views",
});
// Main executable
const exe = b.addExecutable(.{
.name = "demo",
@@ -31,7 +24,6 @@ pub fn build(b: *std.Build) void {
.imports = &.{
.{ .name = "pugz", .module = pugz_dep.module("pugz") },
.{ .name = "httpz", .module = httpz_dep.module("httpz") },
.{ .name = "tpls", .module = compiled_templates },
},
}),
});

View File

@@ -1,22 +1,16 @@
//! Pugz Demo - Interpreted vs Compiled Templates
//! Pugz Demo - ViewEngine Template Rendering
//!
//! This demo shows two approaches:
//! 1. **Interpreted** (ViewEngine) - supports extends/blocks, parsed at runtime
//! 2. **Compiled** (build-time) - 3x faster, templates compiled to Zig code
//! This demo shows how to use ViewEngine for server-side rendering.
//!
//! Routes:
//! GET / - Compiled home page (fast)
//! GET /users - Compiled users list (fast)
//! GET /interpreted - Interpreted with inheritance (flexible)
//! GET /page-a - Interpreted page A
//! GET / - Home page
//! GET /users - Users list
//! GET /page-a - Page with data
const std = @import("std");
const httpz = @import("httpz");
const pugz = @import("pugz");
// Compiled templates - generated at build time from views/compiled/*.pug
const tpls = @import("tpls");
const Allocator = std.mem.Allocator;
/// Application state shared across all requests
@@ -42,33 +36,28 @@ pub fn main() !void {
var app = App.init(allocator);
const port = 8080;
const port = 8081;
var server = try httpz.Server(*App).init(allocator, .{ .port = port }, &app);
defer server.deinit();
var router = try server.router(.{});
// Compiled template routes (fast - 3x faster than Pug.js)
router.get("/", indexCompiled, .{});
router.get("/users", usersCompiled, .{});
// Interpreted template routes (flexible - supports extends/blocks)
router.get("/interpreted", indexInterpreted, .{});
router.get("/", index, .{});
router.get("/users", users, .{});
router.get("/page-a", pageA, .{});
router.get("/mixin-test", mixinTest, .{});
std.debug.print(
\\
\\Pugz Demo - Interpreted vs Compiled Templates
\\=============================================
\\Pugz Demo - ViewEngine Template Rendering
\\==========================================
\\Server running at http://localhost:{d}
\\
\\Compiled routes (3x faster than Pug.js):
\\ GET / - Home page (compiled)
\\ GET /users - Users list (compiled)
\\
\\Interpreted routes (supports extends/blocks):
\\ GET /interpreted - Home with ViewEngine
\\ GET /page-a - Page with inheritance
\\Routes:
\\ GET / - Home page
\\ GET /users - Users list
\\ GET /page-a - Page with data
\\ GET /mixin-test - Mixin test page
\\
\\Press Ctrl+C to stop.
\\
@@ -77,57 +66,10 @@ pub fn main() !void {
try server.listen();
}
// ─────────────────────────────────────────────────────────────────────────────
// Compiled template handlers (fast - no parsing at runtime)
// ─────────────────────────────────────────────────────────────────────────────
/// GET / - Compiled home page
fn indexCompiled(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = tpls.home(res.arena, .{
.title = "Welcome - Compiled",
.authenticated = true,
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// GET /users - Compiled users list
fn usersCompiled(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
const User = struct {
name: []const u8,
email: []const u8,
};
const html = tpls.users(res.arena, .{
.title = "Users - Compiled",
.users = &[_]User{
.{ .name = "Alice", .email = "alice@example.com" },
.{ .name = "Bob", .email = "bob@example.com" },
.{ .name = "Charlie", .email = "charlie@example.com" },
},
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
// ─────────────────────────────────────────────────────────────────────────────
// Interpreted template handlers (flexible - supports inheritance)
// ─────────────────────────────────────────────────────────────────────────────
/// GET /interpreted - Uses ViewEngine (parsed at runtime)
fn indexInterpreted(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
/// GET / - Home page
fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.view.render(res.arena, "index", .{
.title = "Home - Interpreted",
.title = "Welcome",
.authenticated = true,
}) catch |err| {
res.status = 500;
@@ -139,7 +81,21 @@ fn indexInterpreted(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
res.body = html;
}
/// GET /page-a - Demonstrates extends and block override
/// GET /users - Users list
fn users(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.view.render(res.arena, "users", .{
.title = "Users",
}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}
/// GET /page-a - Page with data
fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.view.render(res.arena, "page-a", .{
.title = "Page A - Pets",
@@ -154,3 +110,15 @@ fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
res.content_type = .HTML;
res.body = html;
}
/// GET /mixin-test - Mixin test page
fn mixinTest(app: *App, _: *httpz.Request, res: *httpz.Response) !void {
const html = app.view.render(res.arena, "mixin-test", .{}) catch |err| {
res.status = 500;
res.body = @errorName(err);
return;
};
res.content_type = .HTML;
res.body = html;
}

View File

@@ -214,6 +214,50 @@ pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 {
return o.items;
}
pub fn mixin_test(a: Allocator, d: anytype) Allocator.Error![]u8 {
var o: ArrayList = .empty;
try o.appendSlice(a, "<!DOCTYPE html><html><head><title>Mixin Test</title></head><body><h1>Mixin Test Page</h1><p>Testing button mixin:</p>");
{
const text = "Click Me";
const @"type" = "primary";
try o.appendSlice(a, "<button");
try o.appendSlice(a, " class=\"");
try o.appendSlice(a, "btn btn-");
try o.appendSlice(a, strVal(@"type"));
try o.appendSlice(a, "\"");
try o.appendSlice(a, ">");
try esc(&o, a, strVal(text));
try o.appendSlice(a, "</button>");
}
{
const text = "Cancel";
const @"type" = "btn btn-secondary";
try o.appendSlice(a, "<button");
try o.appendSlice(a, " class=\"");
try o.appendSlice(a, "btn btn-");
try o.appendSlice(a, strVal(@"type"));
try o.appendSlice(a, "\"");
try o.appendSlice(a, ">");
try esc(&o, a, strVal(text));
try o.appendSlice(a, "</button>");
}
try o.appendSlice(a, "<p>Testing link mixin:</p>");
{
const href = "/home";
const text = "Go Home";
try o.appendSlice(a, "<a class=\"btn btn-link\"");
try o.appendSlice(a, " href=\"");
try o.appendSlice(a, strVal(href));
try o.appendSlice(a, "\"");
try o.appendSlice(a, ">");
try esc(&o, a, strVal(text));
try o.appendSlice(a, "</a>");
}
try o.appendSlice(a, "</body></html>");
_ = d;
return o.items;
}
pub fn page_b(a: Allocator, d: anytype) Allocator.Error![]u8 {
var o: ArrayList = .empty;
try o.appendSlice(a, "<html><head><title>My Site - ");
@@ -281,6 +325,7 @@ pub const template_names = [_][]const u8{
"mixins_input_text",
"home",
"page_a",
"mixin_test",
"page_b",
"layout_2",
"layout",

View File

@@ -0,0 +1,15 @@
include mixins/buttons.pug
doctype html
html
head
title Mixin Test
body
h1 Mixin Test Page
p Testing button mixin:
+btn("Click Me")
+btn("Cancel", "secondary")
p Testing link mixin:
+btn-link("/home", "Go Home")

View File

@@ -1,257 +0,0 @@
//! 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,
/// Whether children should be rendered inline (block expansion with `:`).
is_inline: bool = false,
};
/// Text content node.
pub const Text = struct {
/// Segments of text (literals and interpolations).
segments: []TextSegment,
};
/// 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,
};

View File

@@ -1,22 +1,16 @@
//! Pugz Benchmark - Compiled Templates vs Pug.js
//! Pugz Benchmark - Template Rendering
//!
//! Both Pugz and Pug.js benchmarks read from the same files:
//! src/benchmarks/templates/*.pug (templates)
//! src/benchmarks/templates/*.json (data)
//! This benchmark uses template.zig renderWithData function.
//!
//! Run Pugz: zig build bench-all-compiled
//! Run Pug.js: cd src/benchmarks/pugjs && npm install && npm run bench
//! Run: zig build bench-v1
const std = @import("std");
const tpls = @import("tpls");
const pugz = @import("pugz");
const iterations: usize = 2000;
const templates_dir = "src/benchmarks/templates";
// ═══════════════════════════════════════════════════════════════════════════
// Data structures matching JSON files
// ═══════════════════════════════════════════════════════════════════════════
const SubFriend = struct {
id: i64,
name: []const u8,
@@ -58,10 +52,6 @@ const SearchRecord = struct {
sizes: ?[]const []const u8,
};
// ═══════════════════════════════════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════════════════════════════════
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
@@ -69,20 +59,17 @@ pub fn main() !void {
std.debug.print("\n", .{});
std.debug.print("╔═══════════════════════════════════════════════════════════════╗\n", .{});
std.debug.print("Compiled Zig Templates Benchmark ({d} iterations) ║\n", .{iterations});
std.debug.print("V1 Template Benchmark ({d} iterations) \n", .{iterations});
std.debug.print("║ Templates: {s}/*.pug ║\n", .{templates_dir});
std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{});
// ─────────────────────────────────────────────────────────────────────────
// Load JSON data
// ─────────────────────────────────────────────────────────────────────────
std.debug.print("\nLoading JSON data...\n", .{});
var data_arena = std.heap.ArenaAllocator.init(allocator);
defer data_arena.deinit();
const data_alloc = data_arena.allocator();
// Load all JSON files
const simple0 = try loadJson(struct { name: []const u8 }, data_alloc, "simple-0.json");
const simple1 = try loadJson(struct {
name: []const u8,
@@ -108,38 +95,27 @@ pub fn main() !void {
const search = try loadJson(struct { searchRecords: []const SearchRecord }, data_alloc, "search-results.json");
const friends_data = try loadJson(struct { friends: []const Friend }, data_alloc, "friends.json");
// Load template sources
const simple0_tpl = try loadTemplate(data_alloc, "simple-0.pug");
const simple1_tpl = try loadTemplate(data_alloc, "simple-1.pug");
const simple2_tpl = try loadTemplate(data_alloc, "simple-2.pug");
const if_expr_tpl = try loadTemplate(data_alloc, "if-expression.pug");
const projects_tpl = try loadTemplate(data_alloc, "projects-escaped.pug");
const search_tpl = try loadTemplate(data_alloc, "search-results.pug");
const friends_tpl = try loadTemplate(data_alloc, "friends.pug");
std.debug.print("Loaded. Starting benchmark...\n\n", .{});
var total: f64 = 0;
// ─────────────────────────────────────────────────────────────────────────
// Benchmark each template
// ─────────────────────────────────────────────────────────────────────────
total += try bench("simple-0", allocator, simple0_tpl, simple0);
total += try bench("simple-1", allocator, simple1_tpl, simple1);
total += try bench("simple-2", allocator, simple2_tpl, simple2);
total += try bench("if-expression", allocator, if_expr_tpl, if_expr);
total += try bench("projects-escaped", allocator, projects_tpl, projects);
total += try bench("search-results", allocator, search_tpl, search);
total += try bench("friends", allocator, friends_tpl, friends_data);
// simple-0
total += try bench("simple-0", allocator, tpls.simple_0, simple0);
// simple-1
total += try bench("simple-1", allocator, tpls.simple_1, simple1);
// simple-2
total += try bench("simple-2", allocator, tpls.simple_2, simple2);
// if-expression
total += try bench("if-expression", allocator, tpls.if_expression, if_expr);
// projects-escaped
total += try bench("projects-escaped", allocator, tpls.projects_escaped, projects);
// search-results
total += try bench("search-results", allocator, tpls.search_results, search);
// friends
total += try bench("friends", allocator, tpls.friends, friends_data);
// ─────────────────────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────────────────────
std.debug.print("\n", .{});
std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ "TOTAL", total });
std.debug.print("\n", .{});
@@ -152,10 +128,15 @@ fn loadJson(comptime T: type, alloc: std.mem.Allocator, comptime filename: []con
return parsed.value;
}
fn loadTemplate(alloc: std.mem.Allocator, comptime filename: []const u8) ![]const u8 {
const path = templates_dir ++ "/" ++ filename;
return try std.fs.cwd().readFileAlloc(alloc, path, 1 * 1024 * 1024);
}
fn bench(
name: []const u8,
allocator: std.mem.Allocator,
comptime render_fn: anytype,
template: []const u8,
data: anytype,
) !f64 {
var arena = std.heap.ArenaAllocator.init(allocator);
@@ -164,7 +145,10 @@ fn bench(
var timer = try std.time.Timer.start();
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
_ = try render_fn(arena.allocator(), data);
_ = pugz.template.renderWithData(arena.allocator(), template, data) catch |err| {
std.debug.print(" {s:<20} => ERROR: {}\n", .{ name, err });
return 0;
};
}
const ms = @as(f64, @floatFromInt(timer.read())) / 1_000_000.0;
std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ name, ms });

View File

@@ -1,154 +0,0 @@
//! Pugz Benchmark - Interpreted (Runtime) Mode
//!
//! This benchmark uses the ViewEngine to render templates at runtime,
//! reading from the same template/data files as the compiled benchmark.
//!
//! Run: zig build bench-interpreted
const std = @import("std");
const pugz = @import("pugz");
const iterations: usize = 2000;
const templates_dir = "src/benchmarks/templates";
// Data structures matching JSON files
const SubFriend = struct {
id: i64,
name: []const u8,
};
const Friend = struct {
name: []const u8,
balance: []const u8,
age: i64,
address: []const u8,
picture: []const u8,
company: []const u8,
email: []const u8,
emailHref: []const u8,
about: []const u8,
tags: []const []const u8,
friends: []const SubFriend,
};
const Account = struct {
balance: i64,
balanceFormatted: []const u8,
status: []const u8,
negative: bool,
};
const Project = struct {
name: []const u8,
url: []const u8,
description: []const u8,
};
const SearchRecord = struct {
imgUrl: []const u8,
viewItemUrl: []const u8,
title: []const u8,
description: []const u8,
featured: bool,
sizes: ?[]const []const u8,
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
std.debug.print("\n", .{});
std.debug.print("╔═══════════════════════════════════════════════════════════════╗\n", .{});
std.debug.print("║ Interpreted (Runtime) Benchmark ({d} iterations) ║\n", .{iterations});
std.debug.print("║ Templates: {s}/*.pug ║\n", .{templates_dir});
std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{});
// Load JSON data
std.debug.print("\nLoading JSON data...\n", .{});
var data_arena = std.heap.ArenaAllocator.init(allocator);
defer data_arena.deinit();
const data_alloc = data_arena.allocator();
const simple0 = try loadJson(struct { name: []const u8 }, data_alloc, "simple-0.json");
const simple1 = try loadJson(struct {
name: []const u8,
messageCount: i64,
colors: []const []const u8,
primary: bool,
}, data_alloc, "simple-1.json");
const simple2 = try loadJson(struct {
header: []const u8,
header2: []const u8,
header3: []const u8,
header4: []const u8,
header5: []const u8,
header6: []const u8,
list: []const []const u8,
}, data_alloc, "simple-2.json");
const if_expr = try loadJson(struct { accounts: []const Account }, data_alloc, "if-expression.json");
const projects = try loadJson(struct {
title: []const u8,
text: []const u8,
projects: []const Project,
}, data_alloc, "projects-escaped.json");
const search = try loadJson(struct { searchRecords: []const SearchRecord }, data_alloc, "search-results.json");
const friends_data = try loadJson(struct { friends: []const Friend }, data_alloc, "friends.json");
// Load template sources
const simple0_tpl = try loadTemplate(data_alloc, "simple-0.pug");
const simple1_tpl = try loadTemplate(data_alloc, "simple-1.pug");
const simple2_tpl = try loadTemplate(data_alloc, "simple-2.pug");
const if_expr_tpl = try loadTemplate(data_alloc, "if-expression.pug");
const projects_tpl = try loadTemplate(data_alloc, "projects-escaped.pug");
const search_tpl = try loadTemplate(data_alloc, "search-results.pug");
const friends_tpl = try loadTemplate(data_alloc, "friends.pug");
std.debug.print("Loaded. Starting benchmark...\n\n", .{});
var total: f64 = 0;
total += try bench("simple-0", allocator, simple0_tpl, simple0);
total += try bench("simple-1", allocator, simple1_tpl, simple1);
total += try bench("simple-2", allocator, simple2_tpl, simple2);
total += try bench("if-expression", allocator, if_expr_tpl, if_expr);
total += try bench("projects-escaped", allocator, projects_tpl, projects);
total += try bench("search-results", allocator, search_tpl, search);
total += try bench("friends", allocator, friends_tpl, friends_data);
std.debug.print("\n", .{});
std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ "TOTAL", total });
std.debug.print("\n", .{});
}
fn loadJson(comptime T: type, alloc: std.mem.Allocator, comptime filename: []const u8) !T {
const path = templates_dir ++ "/" ++ filename;
const content = try std.fs.cwd().readFileAlloc(alloc, path, 10 * 1024 * 1024);
const parsed = try std.json.parseFromSlice(T, alloc, content, .{});
return parsed.value;
}
fn loadTemplate(alloc: std.mem.Allocator, comptime filename: []const u8) ![]const u8 {
const path = templates_dir ++ "/" ++ filename;
return try std.fs.cwd().readFileAlloc(alloc, path, 1 * 1024 * 1024);
}
fn bench(
name: []const u8,
allocator: std.mem.Allocator,
template: []const u8,
data: anytype,
) !f64 {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
var timer = try std.time.Timer.start();
for (0..iterations) |_| {
_ = arena.reset(.retain_capacity);
_ = try pugz.renderTemplate(arena.allocator(), template, data);
}
const ms = @as(f64, @floatFromInt(timer.read())) / 1_000_000.0;
std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ name, ms });
return ms;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

403
src/error.zig Normal file
View File

@@ -0,0 +1,403 @@
const std = @import("std");
const mem = std.mem;
const Allocator = std.mem.Allocator;
const ArrayListUnmanaged = std.ArrayListUnmanaged;
// ============================================================================
// Pug Error - Error formatting with source context
// Based on pug-error package
// ============================================================================
/// Pug error with source context and formatting
pub const PugError = struct {
/// Error code (e.g., "PUG:SYNTAX_ERROR")
code: []const u8,
/// Short error message
msg: []const u8,
/// Line number (1-indexed)
line: usize,
/// Column number (1-indexed, 0 if unknown)
column: usize,
/// Source filename (optional)
filename: ?[]const u8,
/// Source code (optional, for context display)
src: ?[]const u8,
/// Full formatted message with context
full_message: ?[]const u8,
allocator: Allocator,
/// Track if full_message was allocated
owns_full_message: bool,
pub fn deinit(self: *PugError) void {
if (self.owns_full_message) {
if (self.full_message) |msg| {
self.allocator.free(msg);
}
}
}
/// Get the formatted message (with context if available)
pub fn getMessage(self: *const PugError) []const u8 {
if (self.full_message) |msg| {
return msg;
}
return self.msg;
}
/// Format as JSON-like structure for serialization
pub fn toJson(self: *const PugError, allocator: Allocator) ![]const u8 {
var result: ArrayListUnmanaged(u8) = .{};
errdefer result.deinit(allocator);
try result.appendSlice(allocator, "{\"code\":\"");
try result.appendSlice(allocator, self.code);
try result.appendSlice(allocator, "\",\"msg\":\"");
try appendJsonEscaped(allocator, &result, self.msg);
try result.appendSlice(allocator, "\",\"line\":");
var buf: [32]u8 = undefined;
const line_str = std.fmt.bufPrint(&buf, "{d}", .{self.line}) catch return error.FormatError;
try result.appendSlice(allocator, line_str);
try result.appendSlice(allocator, ",\"column\":");
const col_str = std.fmt.bufPrint(&buf, "{d}", .{self.column}) catch return error.FormatError;
try result.appendSlice(allocator, col_str);
if (self.filename) |fname| {
try result.appendSlice(allocator, ",\"filename\":\"");
try appendJsonEscaped(allocator, &result, fname);
try result.append(allocator, '"');
}
try result.append(allocator, '}');
return try result.toOwnedSlice(allocator);
}
};
/// Append JSON-escaped string to result
fn appendJsonEscaped(allocator: Allocator, result: *ArrayListUnmanaged(u8), s: []const u8) !void {
for (s) |c| {
switch (c) {
'"' => try result.appendSlice(allocator, "\\\""),
'\\' => try result.appendSlice(allocator, "\\\\"),
'\n' => try result.appendSlice(allocator, "\\n"),
'\r' => try result.appendSlice(allocator, "\\r"),
'\t' => try result.appendSlice(allocator, "\\t"),
else => {
if (c < 0x20) {
// Control character - encode as \uXXXX
var hex_buf: [6]u8 = undefined;
_ = std.fmt.bufPrint(&hex_buf, "\\u{x:0>4}", .{c}) catch unreachable;
try result.appendSlice(allocator, &hex_buf);
} else {
try result.append(allocator, c);
}
},
}
}
}
/// Create a Pug error with formatted message and source context.
/// Equivalent to pug-error's makeError function.
pub fn makeError(
allocator: Allocator,
code: []const u8,
message: []const u8,
options: struct {
line: usize,
column: usize = 0,
filename: ?[]const u8 = null,
src: ?[]const u8 = null,
},
) !PugError {
var err = PugError{
.code = code,
.msg = message,
.line = options.line,
.column = options.column,
.filename = options.filename,
.src = options.src,
.full_message = null,
.allocator = allocator,
.owns_full_message = false,
};
// Format full message with context
err.full_message = try formatErrorMessage(
allocator,
code,
message,
options.line,
options.column,
options.filename,
options.src,
);
err.owns_full_message = true;
return err;
}
/// Format error message with source context (±3 lines)
fn formatErrorMessage(
allocator: Allocator,
code: []const u8,
message: []const u8,
line: usize,
column: usize,
filename: ?[]const u8,
src: ?[]const u8,
) ![]const u8 {
_ = code; // Code is embedded in PugError struct
var result: ArrayListUnmanaged(u8) = .{};
errdefer result.deinit(allocator);
// Header: filename:line:column or Pug:line:column
if (filename) |fname| {
try result.appendSlice(allocator, fname);
} else {
try result.appendSlice(allocator, "Pug");
}
try result.append(allocator, ':');
var buf: [32]u8 = undefined;
const line_str = std.fmt.bufPrint(&buf, "{d}", .{line}) catch return error.FormatError;
try result.appendSlice(allocator, line_str);
if (column > 0) {
try result.append(allocator, ':');
const col_str = std.fmt.bufPrint(&buf, "{d}", .{column}) catch return error.FormatError;
try result.appendSlice(allocator, col_str);
}
try result.append(allocator, '\n');
// Source context if available
if (src) |source| {
const lines = try splitLines(allocator, source);
defer allocator.free(lines);
if (line >= 1 and line <= lines.len) {
// Show ±3 lines around error
const start = if (line > 3) line - 3 else 1;
const end = @min(lines.len, line + 3);
var i = start;
while (i <= end) : (i += 1) {
const line_idx = i - 1;
if (line_idx >= lines.len) break;
const src_line = lines[line_idx];
// Preamble: " > 5| " or " 5| "
if (i == line) {
try result.appendSlice(allocator, " > ");
} else {
try result.appendSlice(allocator, " ");
}
// Line number (right-aligned)
const num_str = std.fmt.bufPrint(&buf, "{d}", .{i}) catch return error.FormatError;
try result.appendSlice(allocator, num_str);
try result.appendSlice(allocator, "| ");
// Source line
try result.appendSlice(allocator, src_line);
try result.append(allocator, '\n');
// Column marker for error line
if (i == line and column > 0) {
// Calculate preamble length
const preamble_len = 4 + num_str.len + 2; // " > " + num + "| "
var j: usize = 0;
while (j < preamble_len + column - 1) : (j += 1) {
try result.append(allocator, '-');
}
try result.append(allocator, '^');
try result.append(allocator, '\n');
}
}
try result.append(allocator, '\n');
}
} else {
try result.append(allocator, '\n');
}
// Error message
try result.appendSlice(allocator, message);
return try result.toOwnedSlice(allocator);
}
/// Split source into lines (handles \n, \r\n, \r)
fn splitLines(allocator: Allocator, src: []const u8) ![][]const u8 {
var lines: ArrayListUnmanaged([]const u8) = .{};
errdefer lines.deinit(allocator);
var start: usize = 0;
var i: usize = 0;
while (i < src.len) {
if (src[i] == '\n') {
try lines.append(allocator, src[start..i]);
start = i + 1;
i += 1;
} else if (src[i] == '\r') {
try lines.append(allocator, src[start..i]);
// Handle \r\n
if (i + 1 < src.len and src[i + 1] == '\n') {
i += 2;
} else {
i += 1;
}
start = i;
} else {
i += 1;
}
}
// Last line (may not end with newline)
if (start <= src.len) {
try lines.append(allocator, src[start..]);
}
return try lines.toOwnedSlice(allocator);
}
// ============================================================================
// Common error codes
// ============================================================================
pub const ErrorCode = struct {
pub const SYNTAX_ERROR = "PUG:SYNTAX_ERROR";
pub const INVALID_TOKEN = "PUG:INVALID_TOKEN";
pub const UNEXPECTED_TOKEN = "PUG:UNEXPECTED_TOKEN";
pub const INVALID_INDENTATION = "PUG:INVALID_INDENTATION";
pub const INCONSISTENT_INDENTATION = "PUG:INCONSISTENT_INDENTATION";
pub const EXTENDS_NOT_FIRST = "PUG:EXTENDS_NOT_FIRST";
pub const UNEXPECTED_BLOCK = "PUG:UNEXPECTED_BLOCK";
pub const UNEXPECTED_NODES_IN_EXTENDING_ROOT = "PUG:UNEXPECTED_NODES_IN_EXTENDING_ROOT";
pub const NO_EXTENDS_PATH = "PUG:NO_EXTENDS_PATH";
pub const NO_INCLUDE_PATH = "PUG:NO_INCLUDE_PATH";
pub const MALFORMED_EXTENDS = "PUG:MALFORMED_EXTENDS";
pub const MALFORMED_INCLUDE = "PUG:MALFORMED_INCLUDE";
pub const FILTER_NOT_FOUND = "PUG:FILTER_NOT_FOUND";
pub const INVALID_FILTER = "PUG:INVALID_FILTER";
};
// ============================================================================
// Tests
// ============================================================================
test "makeError - basic error without source" {
const allocator = std.testing.allocator;
var err = try makeError(allocator, "PUG:TEST", "test error", .{
.line = 5,
.column = 10,
.filename = "test.pug",
});
defer err.deinit();
try std.testing.expectEqualStrings("PUG:TEST", err.code);
try std.testing.expectEqualStrings("test error", err.msg);
try std.testing.expectEqual(@as(usize, 5), err.line);
try std.testing.expectEqual(@as(usize, 10), err.column);
try std.testing.expectEqualStrings("test.pug", err.filename.?);
const msg = err.getMessage();
try std.testing.expect(mem.indexOf(u8, msg, "test.pug:5:10") != null);
try std.testing.expect(mem.indexOf(u8, msg, "test error") != null);
}
test "makeError - error with source context" {
const allocator = std.testing.allocator;
const src = "line 1\nline 2\nline 3 with error\nline 4\nline 5";
var err = try makeError(allocator, "PUG:SYNTAX_ERROR", "unexpected token", .{
.line = 3,
.column = 8,
.filename = "template.pug",
.src = src,
});
defer err.deinit();
const msg = err.getMessage();
// Should contain filename:line:column
try std.testing.expect(mem.indexOf(u8, msg, "template.pug:3:8") != null);
// Should contain the error line with marker
try std.testing.expect(mem.indexOf(u8, msg, "line 3 with error") != null);
// Should contain the error message
try std.testing.expect(mem.indexOf(u8, msg, "unexpected token") != null);
// Should have column marker
try std.testing.expect(mem.indexOf(u8, msg, "^") != null);
}
test "makeError - error with source shows context lines" {
const allocator = std.testing.allocator;
const src = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8";
var err = try makeError(allocator, "PUG:TEST", "test", .{
.line = 5,
.filename = null,
.src = src,
});
defer err.deinit();
const msg = err.getMessage();
// Should show lines 2-8 (5 ± 3)
try std.testing.expect(mem.indexOf(u8, msg, "line 2") != null);
try std.testing.expect(mem.indexOf(u8, msg, "line 5") != null);
try std.testing.expect(mem.indexOf(u8, msg, "line 8") != null);
// Line 1 should not be shown (too far before)
// Note: line 1 might appear in context depending on implementation
}
test "makeError - no filename uses Pug" {
const allocator = std.testing.allocator;
var err = try makeError(allocator, "PUG:TEST", "test error", .{
.line = 1,
});
defer err.deinit();
const msg = err.getMessage();
try std.testing.expect(mem.indexOf(u8, msg, "Pug:1") != null);
}
test "PugError.toJson" {
const allocator = std.testing.allocator;
var err = try makeError(allocator, "PUG:TEST", "test message", .{
.line = 10,
.column = 5,
.filename = "file.pug",
});
defer err.deinit();
const json = try err.toJson(allocator);
defer allocator.free(json);
try std.testing.expect(mem.indexOf(u8, json, "\"code\":\"PUG:TEST\"") != null);
try std.testing.expect(mem.indexOf(u8, json, "\"msg\":\"test message\"") != null);
try std.testing.expect(mem.indexOf(u8, json, "\"line\":10") != null);
try std.testing.expect(mem.indexOf(u8, json, "\"column\":5") != null);
try std.testing.expect(mem.indexOf(u8, json, "\"filename\":\"file.pug\"") != null);
}
test "splitLines - basic" {
const allocator = std.testing.allocator;
const lines = try splitLines(allocator, "a\nb\nc");
defer allocator.free(lines);
try std.testing.expectEqual(@as(usize, 3), lines.len);
try std.testing.expectEqualStrings("a", lines[0]);
try std.testing.expectEqualStrings("b", lines[1]);
try std.testing.expectEqualStrings("c", lines[2]);
}
test "splitLines - windows line endings" {
const allocator = std.testing.allocator;
const lines = try splitLines(allocator, "a\r\nb\r\nc");
defer allocator.free(lines);
try std.testing.expectEqual(@as(usize, 3), lines.len);
try std.testing.expectEqualStrings("a", lines[0]);
try std.testing.expectEqualStrings("b", lines[1]);
try std.testing.expectEqualStrings("c", lines[2]);
}

File diff suppressed because it is too large Load Diff

699
src/linker.zig Normal file
View File

@@ -0,0 +1,699 @@
// linker.zig - Zig port of pug-linker
//
// Handles template inheritance and linking:
// - Resolves extends (parent template inheritance)
// - Handles named blocks (replace/append/prepend modes)
// - Processes includes with yield blocks
// - Manages mixin hoisting from child to parent
const std = @import("std");
const Allocator = std.mem.Allocator;
const mem = std.mem;
// Import AST types from parser
const parser = @import("parser.zig");
pub const Node = parser.Node;
pub const NodeType = parser.NodeType;
// Import walk module
const walk_mod = @import("walk.zig");
pub const WalkOptions = walk_mod.WalkOptions;
pub const WalkContext = walk_mod.WalkContext;
pub const WalkError = walk_mod.WalkError;
pub const ReplaceResult = walk_mod.ReplaceResult;
// Import error types
const pug_error = @import("error.zig");
pub const PugError = pug_error.PugError;
// ============================================================================
// Linker Errors
// ============================================================================
pub const LinkerError = error{
OutOfMemory,
InvalidAST,
ExtendsNotFirst,
UnexpectedNodesInExtending,
UnexpectedBlock,
WalkError,
};
// ============================================================================
// Block Definitions Map
// ============================================================================
/// Map of block names to their definition nodes
pub const BlockDefinitions = std.StringHashMapUnmanaged(*Node);
// ============================================================================
// Linker Result
// ============================================================================
pub const LinkerResult = struct {
ast: *Node,
declared_blocks: BlockDefinitions,
has_extends: bool = false,
err: ?PugError = null,
pub fn deinit(self: *LinkerResult, allocator: Allocator) void {
self.declared_blocks.deinit(allocator);
if (self.err) |*e| {
e.deinit();
}
}
};
// ============================================================================
// Link Implementation
// ============================================================================
/// Link an AST, resolving extends and includes
pub fn link(allocator: Allocator, ast: *Node) LinkerError!LinkerResult {
// Top level must be a Block
if (ast.type != .Block) {
return error.InvalidAST;
}
var result = LinkerResult{
.ast = ast,
.declared_blocks = .{},
};
// Check for extends
var extends_node: ?*Node = null;
if (ast.nodes.items.len > 0) {
const first_node = ast.nodes.items[0];
if (first_node.type == .Extends) {
// Verify extends position
try checkExtendsPosition(allocator, ast);
// Remove extends node from the list
extends_node = ast.nodes.orderedRemove(0);
}
}
// Apply includes (convert RawInclude to Text, link Include ASTs)
result.ast = try applyIncludes(allocator, ast);
// Find declared blocks
result.declared_blocks = try findDeclaredBlocks(allocator, result.ast);
// Handle extends
if (extends_node) |ext_node| {
// Get mixins and expected blocks from current template
var mixins = std.ArrayListUnmanaged(*Node){};
defer mixins.deinit(allocator);
var expected_blocks = std.ArrayListUnmanaged(*Node){};
defer expected_blocks.deinit(allocator);
try collectMixinsAndBlocks(allocator, result.ast, &mixins, &expected_blocks);
// Link the parent template
if (ext_node.file) |file| {
_ = file;
// In a real implementation, we would:
// 1. Get file.ast (the loaded parent AST)
// 2. Recursively link it
// 3. Extend parent blocks with child blocks
// 4. Verify all expected blocks exist
// 5. Merge mixin definitions
// For now, mark that we have extends
result.has_extends = true;
}
}
return result;
}
/// Find all declared blocks (NamedBlock with mode="replace")
fn findDeclaredBlocks(allocator: Allocator, ast: *Node) LinkerError!BlockDefinitions {
var definitions = BlockDefinitions{};
const FindContext = struct {
defs: *BlockDefinitions,
alloc: Allocator,
fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
if (node.type == .NamedBlock) {
// Check mode - default is "replace"
const mode = node.mode orelse "replace";
if (mem.eql(u8, mode, "replace")) {
if (node.name) |name| {
self.defs.put(self.alloc, name, node) catch return error.OutOfMemory;
}
}
}
return null;
}
};
var find_ctx = FindContext{
.defs = &definitions,
.alloc = allocator,
};
var walk_options = WalkOptions{};
defer walk_options.deinit(allocator);
_ = walk_mod.walkASTWithUserData(
allocator,
ast,
FindContext.before,
null,
&walk_options,
&find_ctx,
) catch {
return error.WalkError;
};
return definitions;
}
/// Collect mixin definitions and named blocks from the AST
fn collectMixinsAndBlocks(
allocator: Allocator,
ast: *Node,
mixins: *std.ArrayListUnmanaged(*Node),
expected_blocks: *std.ArrayListUnmanaged(*Node),
) LinkerError!void {
for (ast.nodes.items) |node| {
switch (node.type) {
.NamedBlock => {
try expected_blocks.append(allocator, node);
},
.Block => {
// Recurse into nested blocks
try collectMixinsAndBlocks(allocator, node, mixins, expected_blocks);
},
.Mixin => {
// Only collect mixin definitions (not calls)
if (!node.call) {
try mixins.append(allocator, node);
}
},
else => {
// In extending template, only named blocks and mixins allowed at top level
// This would be an error in strict mode
},
}
}
}
/// Extend parent blocks with child block content
fn extendBlocks(
allocator: Allocator,
parent_blocks: *BlockDefinitions,
child_ast: *Node,
) LinkerError!void {
const ExtendContext = struct {
parent: *BlockDefinitions,
stack: std.StringHashMapUnmanaged(void),
alloc: Allocator,
fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
if (node.type == .NamedBlock) {
if (node.name) |name| {
// Check for circular reference
if (self.stack.contains(name)) {
return null; // Skip to avoid infinite loop
}
self.stack.put(self.alloc, name, {}) catch return error.OutOfMemory;
// Find parent block
if (self.parent.get(name)) |parent_block| {
const mode = node.mode orelse "replace";
if (mem.eql(u8, mode, "append")) {
// Append child nodes to parent
for (node.nodes.items) |child_node| {
parent_block.nodes.append(self.alloc, child_node) catch return error.OutOfMemory;
}
} else if (mem.eql(u8, mode, "prepend")) {
// Prepend child nodes to parent
for (node.nodes.items, 0..) |child_node, i| {
parent_block.nodes.insert(self.alloc, i, child_node) catch return error.OutOfMemory;
}
} else {
// Replace - clear parent and add child nodes
parent_block.nodes.clearRetainingCapacity();
for (node.nodes.items) |child_node| {
parent_block.nodes.append(self.alloc, child_node) catch return error.OutOfMemory;
}
}
}
}
}
return null;
}
fn after(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
if (node.type == .NamedBlock) {
if (node.name) |name| {
_ = self.stack.remove(name);
}
}
return null;
}
};
var extend_ctx = ExtendContext{
.parent = parent_blocks,
.stack = .{},
.alloc = allocator,
};
defer extend_ctx.stack.deinit(allocator);
var walk_options = WalkOptions{};
defer walk_options.deinit(allocator);
_ = walk_mod.walkASTWithUserData(
allocator,
child_ast,
ExtendContext.before,
ExtendContext.after,
&walk_options,
&extend_ctx,
) catch {
return error.WalkError;
};
}
/// Apply includes - convert RawInclude to Text, process Include nodes
fn applyIncludes(allocator: Allocator, ast: *Node) LinkerError!*Node {
const IncludeContext = struct {
alloc: Allocator,
fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
_ = self;
// Convert RawInclude to Text
if (node.type == .RawInclude) {
// In a real implementation:
// - Get file.str (the loaded file content)
// - Create a Text node with that content
// For now, just keep the node as-is
node.type = .Text;
// node.val = file.str with \r removed
}
return null;
}
fn after(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
_ = self;
// Process Include nodes
if (node.type == .Include) {
// In a real implementation:
// 1. Link the included file's AST
// 2. If it has extends, remove named blocks
// 3. Apply yield block
// For now, keep the node as-is
}
return null;
}
};
var include_ctx = IncludeContext{
.alloc = allocator,
};
var walk_options = WalkOptions{};
defer walk_options.deinit(allocator);
const result = walk_mod.walkASTWithUserData(
allocator,
ast,
IncludeContext.before,
IncludeContext.after,
&walk_options,
&include_ctx,
) catch {
return error.WalkError;
};
return result;
}
/// Check that extends is the first thing in the file
fn checkExtendsPosition(allocator: Allocator, ast: *Node) LinkerError!void {
var found_legit_extends = false;
const CheckContext = struct {
legit_extends: *bool,
has_extends: bool,
alloc: Allocator,
fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
if (node.type == .Extends) {
if (self.has_extends and !self.legit_extends.*) {
self.legit_extends.* = true;
} else {
// This would be an error - extends not first or multiple extends
// For now we just skip
}
}
return null;
}
};
var check_ctx = CheckContext{
.legit_extends = &found_legit_extends,
.has_extends = true,
.alloc = allocator,
};
var walk_options = WalkOptions{};
defer walk_options.deinit(allocator);
_ = walk_mod.walkASTWithUserData(
allocator,
ast,
CheckContext.before,
null,
&walk_options,
&check_ctx,
) catch {
return error.WalkError;
};
}
/// Remove named blocks (convert to regular blocks)
pub fn removeNamedBlocks(allocator: Allocator, ast: *Node) LinkerError!*Node {
const RemoveContext = struct {
alloc: Allocator,
fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
_ = self;
if (node.type == .NamedBlock) {
node.type = .Block;
node.name = null;
node.mode = null;
}
return null;
}
};
var remove_ctx = RemoveContext{
.alloc = allocator,
};
var walk_options = WalkOptions{};
defer walk_options.deinit(allocator);
return walk_mod.walkASTWithUserData(
allocator,
ast,
RemoveContext.before,
null,
&walk_options,
&remove_ctx,
) catch error.WalkError;
}
/// Apply yield block to included content
pub fn applyYield(allocator: Allocator, ast: *Node, block: ?*Node) LinkerError!*Node {
if (block == null or block.?.nodes.items.len == 0) {
return ast;
}
var replaced = false;
const YieldContext = struct {
yield_block: *Node,
was_replaced: *bool,
alloc: Allocator,
fn after(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
if (node.type == .YieldBlock) {
self.was_replaced.* = true;
node.type = .Block;
node.nodes.clearRetainingCapacity();
node.nodes.append(self.alloc, self.yield_block) catch return error.OutOfMemory;
}
return null;
}
};
var yield_ctx = YieldContext{
.yield_block = block.?,
.was_replaced = &replaced,
.alloc = allocator,
};
var walk_options = WalkOptions{};
defer walk_options.deinit(allocator);
const result = walk_mod.walkASTWithUserData(
allocator,
ast,
null,
YieldContext.after,
&walk_options,
&yield_ctx,
) catch {
return error.WalkError;
};
// If no yield block found, append to default location
if (!replaced) {
const default_loc = findDefaultYieldLocation(result);
default_loc.nodes.append(allocator, block.?) catch return error.OutOfMemory;
}
return result;
}
/// Find the default yield location (deepest block)
fn findDefaultYieldLocation(node: *Node) *Node {
var result = node;
for (node.nodes.items) |child| {
if (child.text_only) continue;
if (child.type == .Block) {
result = findDefaultYieldLocation(child);
} else if (child.nodes.items.len > 0) {
result = findDefaultYieldLocation(child);
}
}
return result;
}
// ============================================================================
// Tests
// ============================================================================
test "link - basic block" {
const allocator = std.testing.allocator;
// Create a simple AST
const text_node = try allocator.create(Node);
text_node.* = Node{
.type = .Text,
.val = "Hello",
.line = 1,
.column = 1,
};
var root = try allocator.create(Node);
root.* = Node{
.type = .Block,
.line = 1,
.column = 1,
};
try root.nodes.append(allocator, text_node);
defer {
root.deinit(allocator);
allocator.destroy(root);
}
var result = try link(allocator, root);
defer result.deinit(allocator);
try std.testing.expectEqual(root, result.ast);
try std.testing.expectEqual(false, result.has_extends);
}
test "link - with named block" {
const allocator = std.testing.allocator;
// Create named block
const text_node = try allocator.create(Node);
text_node.* = Node{
.type = .Text,
.val = "content",
.line = 2,
.column = 3,
};
const named_block = try allocator.create(Node);
named_block.* = Node{
.type = .NamedBlock,
.name = "content",
.mode = "replace",
.line = 2,
.column = 1,
};
try named_block.nodes.append(allocator, text_node);
var root = try allocator.create(Node);
root.* = Node{
.type = .Block,
.line = 1,
.column = 1,
};
try root.nodes.append(allocator, named_block);
defer {
root.deinit(allocator);
allocator.destroy(root);
}
var result = try link(allocator, root);
defer result.deinit(allocator);
// Should find the declared block
try std.testing.expect(result.declared_blocks.contains("content"));
}
test "findDeclaredBlocks - multiple blocks" {
const allocator = std.testing.allocator;
const block1 = try allocator.create(Node);
block1.* = Node{
.type = .NamedBlock,
.name = "header",
.mode = "replace",
.line = 1,
.column = 1,
};
const block2 = try allocator.create(Node);
block2.* = Node{
.type = .NamedBlock,
.name = "footer",
.mode = "replace",
.line = 5,
.column = 1,
};
const block3 = try allocator.create(Node);
block3.* = Node{
.type = .NamedBlock,
.name = "sidebar",
.mode = "append", // Should not be in declared blocks
.line = 10,
.column = 1,
};
var root = try allocator.create(Node);
root.* = Node{
.type = .Block,
.line = 1,
.column = 1,
};
try root.nodes.append(allocator, block1);
try root.nodes.append(allocator, block2);
try root.nodes.append(allocator, block3);
defer {
root.deinit(allocator);
allocator.destroy(root);
}
var blocks = try findDeclaredBlocks(allocator, root);
defer blocks.deinit(allocator);
try std.testing.expect(blocks.contains("header"));
try std.testing.expect(blocks.contains("footer"));
try std.testing.expect(!blocks.contains("sidebar")); // append mode
}
test "removeNamedBlocks" {
const allocator = std.testing.allocator;
const named_block = try allocator.create(Node);
named_block.* = Node{
.type = .NamedBlock,
.name = "content",
.mode = "replace",
.line = 1,
.column = 1,
};
var root = try allocator.create(Node);
root.* = Node{
.type = .Block,
.line = 1,
.column = 1,
};
try root.nodes.append(allocator, named_block);
defer {
root.deinit(allocator);
allocator.destroy(root);
}
const result = try removeNamedBlocks(allocator, root);
// Named block should now be a regular Block
try std.testing.expectEqual(NodeType.Block, result.nodes.items[0].type);
try std.testing.expectEqual(@as(?[]const u8, null), result.nodes.items[0].name);
}
test "findDefaultYieldLocation - nested blocks" {
const allocator = std.testing.allocator;
const inner_block = try allocator.create(Node);
inner_block.* = Node{
.type = .Block,
.line = 3,
.column = 1,
};
const outer_block = try allocator.create(Node);
outer_block.* = Node{
.type = .Block,
.line = 2,
.column = 1,
};
try outer_block.nodes.append(allocator, inner_block);
var root = try allocator.create(Node);
root.* = Node{
.type = .Block,
.line = 1,
.column = 1,
};
try root.nodes.append(allocator, outer_block);
defer {
root.deinit(allocator);
allocator.destroy(root);
}
const location = findDefaultYieldLocation(root);
// Should find the innermost block
try std.testing.expectEqual(inner_block, location);
}

412
src/load.zig Normal file
View File

@@ -0,0 +1,412 @@
// load.zig - Zig port of pug-load
//
// Handles loading of include/extends files during AST processing.
// Walks the AST and loads file dependencies.
const std = @import("std");
const fs = std.fs;
const Allocator = std.mem.Allocator;
const mem = std.mem;
// Import AST types from parser
const parser = @import("parser.zig");
pub const Node = parser.Node;
pub const NodeType = parser.NodeType;
pub const FileReference = parser.FileReference;
// Import walk module
const walk_mod = @import("walk.zig");
pub const walkAST = walk_mod.walkAST;
pub const WalkOptions = walk_mod.WalkOptions;
pub const WalkContext = walk_mod.WalkContext;
pub const WalkError = walk_mod.WalkError;
pub const ReplaceResult = walk_mod.ReplaceResult;
// Import lexer for lexing includes
const lexer = @import("lexer.zig");
pub const Token = lexer.Token;
pub const Lexer = lexer.Lexer;
// Import error types
const pug_error = @import("error.zig");
pub const PugError = pug_error.PugError;
// ============================================================================
// Load Options
// ============================================================================
/// Function type for resolving file paths
pub const ResolveFn = *const fn (
filename: []const u8,
source: ?[]const u8,
options: *const LoadOptions,
) LoadError![]const u8;
/// Function type for reading file contents
pub const ReadFn = *const fn (
allocator: Allocator,
filename: []const u8,
options: *const LoadOptions,
) LoadError![]const u8;
/// Function type for lexing source
pub const LexFn = *const fn (
allocator: Allocator,
src: []const u8,
options: *const LoadOptions,
) LoadError![]const Token;
/// Function type for parsing tokens
pub const ParseFn = *const fn (
allocator: Allocator,
tokens: []const Token,
options: *const LoadOptions,
) LoadError!*Node;
pub const LoadOptions = struct {
/// Base directory for absolute paths
basedir: ?[]const u8 = null,
/// Source filename
filename: ?[]const u8 = null,
/// Source content
src: ?[]const u8 = null,
/// Path resolution function
resolve: ?ResolveFn = null,
/// File reading function
read: ?ReadFn = null,
/// Lexer function
lex: ?LexFn = null,
/// Parser function
parse: ?ParseFn = null,
/// User data for callbacks
user_data: ?*anyopaque = null,
};
// ============================================================================
// Load Errors
// ============================================================================
pub const LoadError = error{
OutOfMemory,
FileNotFound,
AccessDenied,
InvalidPath,
MissingFilename,
MissingBasedir,
InvalidFileReference,
LexError,
ParseError,
WalkError,
InvalidUtf8,
};
// ============================================================================
// Load Result
// ============================================================================
pub const LoadResult = struct {
ast: *Node,
err: ?PugError = null,
pub fn deinit(self: *LoadResult, allocator: Allocator) void {
if (self.err) |*e| {
e.deinit();
}
self.ast.deinit(allocator);
allocator.destroy(self.ast);
}
};
// ============================================================================
// Default Implementations
// ============================================================================
/// Default path resolution - handles relative and absolute paths
pub fn defaultResolve(
filename: []const u8,
source: ?[]const u8,
options: *const LoadOptions,
) LoadError![]const u8 {
const trimmed = mem.trim(u8, filename, " \t\r\n");
if (trimmed.len == 0) {
return error.InvalidPath;
}
// Absolute path (starts with /)
if (trimmed[0] == '/') {
if (options.basedir == null) {
return error.MissingBasedir;
}
// Join basedir with filename (without leading /)
// Note: In a real implementation, we'd use path.join
// For now, return the path as-is for testing
return trimmed;
}
// Relative path
if (source == null) {
return error.MissingFilename;
}
// In a real implementation, join dirname(source) with filename
// For now, return the path as-is for testing
return trimmed;
}
/// Default file reading using std.fs
pub fn defaultRead(
allocator: Allocator,
filename: []const u8,
options: *const LoadOptions,
) LoadError![]const u8 {
_ = options;
const file = fs.cwd().openFile(filename, .{}) catch |err| {
return switch (err) {
error.FileNotFound => error.FileNotFound,
error.AccessDenied => error.AccessDenied,
else => error.FileNotFound,
};
};
defer file.close();
const content = file.readToEndAlloc(allocator, 1024 * 1024 * 10) catch {
return error.OutOfMemory;
};
return content;
}
// ============================================================================
// Load Implementation
// ============================================================================
/// Load file dependencies from an AST
/// Walks the AST and loads Include, RawInclude, and Extends nodes
pub fn load(
allocator: Allocator,
ast: *Node,
options: LoadOptions,
) LoadError!*Node {
// Create a context for the walk
const LoadContext = struct {
allocator: Allocator,
options: LoadOptions,
err: ?PugError = null,
fn beforeCallback(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult {
const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?));
// Only process Include, RawInclude, and Extends nodes
if (node.type != .Include and node.type != .RawInclude and node.type != .Extends) {
return null;
}
// Check if already loaded (str is set)
if (node.file) |*file| {
// Load the file content
self.loadFileReference(file, node) catch {
// Store error but continue walking
return null;
};
}
return null;
}
fn loadFileReference(self: *@This(), file: *FileReference, node: *Node) LoadError!void {
_ = node;
if (file.path == null) {
return error.InvalidFileReference;
}
// Resolve the path
const resolve_fn = self.options.resolve orelse defaultResolve;
const resolved_path = try resolve_fn(file.path.?, self.options.filename, &self.options);
// Read the file
const read_fn = self.options.read orelse defaultRead;
const content = try read_fn(self.allocator, resolved_path, &self.options);
_ = content;
// For Include/Extends, parse the content into an AST
// This would require lexer and parser functions to be provided
// For now, we just load the raw content
}
};
var load_ctx = LoadContext{
.allocator = allocator,
.options = options,
};
var walk_options = WalkOptions{};
defer walk_options.deinit(allocator);
const result = walk_mod.walkASTWithUserData(
allocator,
ast,
LoadContext.beforeCallback,
null,
&walk_options,
&load_ctx,
) catch {
return error.WalkError;
};
if (load_ctx.err) |*e| {
e.deinit();
return error.FileNotFound;
}
return result;
}
/// Load from a string source
pub fn loadString(
allocator: Allocator,
src: []const u8,
options: LoadOptions,
) LoadError!*Node {
// Need lex and parse functions
const lex_fn = options.lex orelse return error.LexError;
const parse_fn = options.parse orelse return error.ParseError;
// Lex the source
const tokens = try lex_fn(allocator, src, &options);
// Parse the tokens
var parse_options = options;
parse_options.src = src;
const ast = try parse_fn(allocator, tokens, &parse_options);
// Load dependencies
return load(allocator, ast, parse_options);
}
/// Load from a file
pub fn loadFile(
allocator: Allocator,
filename: []const u8,
options: LoadOptions,
) LoadError!*Node {
// Read the file
const read_fn = options.read orelse defaultRead;
const content = try read_fn(allocator, filename, &options);
defer allocator.free(content);
// Load from string with filename set
var file_options = options;
file_options.filename = filename;
return loadString(allocator, content, file_options);
}
// ============================================================================
// Path Utilities
// ============================================================================
/// Get the directory name from a path
pub fn dirname(path: []const u8) []const u8 {
if (mem.lastIndexOf(u8, path, "/")) |idx| {
if (idx == 0) return "/";
return path[0..idx];
}
return ".";
}
/// Join two path components
pub fn pathJoin(allocator: Allocator, base: []const u8, relative: []const u8) ![]const u8 {
if (relative.len > 0 and relative[0] == '/') {
return allocator.dupe(u8, relative);
}
const base_dir = dirname(base);
// Handle .. and . components
var result = std.ArrayListUnmanaged(u8){};
errdefer result.deinit(allocator);
try result.appendSlice(allocator, base_dir);
if (base_dir.len > 0 and base_dir[base_dir.len - 1] != '/') {
try result.append(allocator, '/');
}
try result.appendSlice(allocator, relative);
return result.toOwnedSlice(allocator);
}
// ============================================================================
// Tests
// ============================================================================
test "dirname - basic paths" {
try std.testing.expectEqualStrings(".", dirname("file.pug"));
try std.testing.expectEqualStrings("/home/user", dirname("/home/user/file.pug"));
try std.testing.expectEqualStrings("views", dirname("views/file.pug"));
try std.testing.expectEqualStrings("/", dirname("/file.pug"));
try std.testing.expectEqualStrings(".", dirname(""));
}
test "pathJoin - relative paths" {
const allocator = std.testing.allocator;
const result1 = try pathJoin(allocator, "/home/user/views/index.pug", "partials/header.pug");
defer allocator.free(result1);
try std.testing.expectEqualStrings("/home/user/views/partials/header.pug", result1);
const result2 = try pathJoin(allocator, "views/index.pug", "footer.pug");
defer allocator.free(result2);
try std.testing.expectEqualStrings("views/footer.pug", result2);
}
test "pathJoin - absolute paths" {
const allocator = std.testing.allocator;
const result = try pathJoin(allocator, "/home/user/views/index.pug", "/absolute/path.pug");
defer allocator.free(result);
try std.testing.expectEqualStrings("/absolute/path.pug", result);
}
test "defaultResolve - missing basedir for absolute path" {
const options = LoadOptions{};
const result = defaultResolve("/absolute/path.pug", null, &options);
try std.testing.expectError(error.MissingBasedir, result);
}
test "defaultResolve - missing filename for relative path" {
const options = LoadOptions{ .basedir = "/base" };
const result = defaultResolve("relative/path.pug", null, &options);
try std.testing.expectError(error.MissingFilename, result);
}
test "load - basic AST without includes" {
const allocator = std.testing.allocator;
// Create a simple AST with no includes
const text_node = try allocator.create(Node);
text_node.* = Node{
.type = .Text,
.val = "Hello",
.line = 1,
.column = 1,
};
var root = try allocator.create(Node);
root.* = Node{
.type = .Block,
.line = 1,
.column = 1,
};
try root.nodes.append(allocator, text_node);
defer {
root.deinit(allocator);
allocator.destroy(root);
}
// Load should succeed with no changes
const result = try load(allocator, root, .{});
try std.testing.expectEqual(root, result);
}

581
src/mixin.zig Normal file
View File

@@ -0,0 +1,581 @@
// mixin.zig - Mixin registry and expansion
//
// Handles mixin definitions and calls:
// - Collects mixin definitions from AST into a registry
// - Expands mixin calls by substituting arguments and block content
//
// Usage pattern in Pug:
// mixin button(text, type)
// button(class="btn btn-" + type)= text
//
// +button("Click", "primary")
//
// Include pattern:
// include mixins/_buttons.pug
// +primary-button("Click")
const std = @import("std");
const Allocator = std.mem.Allocator;
const mem = std.mem;
const parser = @import("parser.zig");
pub const Node = parser.Node;
pub const NodeType = parser.NodeType;
// ============================================================================
// Mixin Registry
// ============================================================================
/// Registry for mixin definitions
pub const MixinRegistry = struct {
allocator: Allocator,
mixins: std.StringHashMapUnmanaged(*Node),
pub fn init(allocator: Allocator) MixinRegistry {
return .{
.allocator = allocator,
.mixins = .{},
};
}
pub fn deinit(self: *MixinRegistry) void {
self.mixins.deinit(self.allocator);
}
/// Register a mixin definition
pub fn register(self: *MixinRegistry, name: []const u8, node: *Node) !void {
try self.mixins.put(self.allocator, name, node);
}
/// Get a mixin definition by name
pub fn get(self: *const MixinRegistry, name: []const u8) ?*Node {
return self.mixins.get(name);
}
/// Check if a mixin exists
pub fn contains(self: *const MixinRegistry, name: []const u8) bool {
return self.mixins.contains(name);
}
};
// ============================================================================
// Mixin Collector - Collect definitions from AST
// ============================================================================
/// Collect all mixin definitions from an AST into the registry
pub fn collectMixins(allocator: Allocator, ast: *Node, registry: *MixinRegistry) !void {
try collectMixinsFromNode(allocator, ast, registry);
}
fn collectMixinsFromNode(allocator: Allocator, node: *Node, registry: *MixinRegistry) !void {
// If this is a mixin definition (not a call), register it
if (node.type == .Mixin and !node.call) {
if (node.name) |name| {
try registry.register(name, node);
}
}
// Recurse into children
for (node.nodes.items) |child| {
try collectMixinsFromNode(allocator, child, registry);
}
}
// ============================================================================
// Mixin Expander - Expand mixin calls in AST
// ============================================================================
/// Error types for mixin expansion
pub const MixinError = error{
OutOfMemory,
MixinNotFound,
InvalidMixinCall,
};
/// Expand all mixin calls in an AST using the registry
/// Returns a new AST with mixin calls replaced by their expanded content
pub fn expandMixins(allocator: Allocator, ast: *Node, registry: *const MixinRegistry) MixinError!*Node {
return expandNode(allocator, ast, registry, null);
}
fn expandNode(
allocator: Allocator,
node: *Node,
registry: *const MixinRegistry,
caller_block: ?*Node,
) MixinError!*Node {
// Handle mixin call
if (node.type == .Mixin and node.call) {
return expandMixinCall(allocator, node, registry, caller_block);
}
// Handle MixinBlock - replace with caller's block content
if (node.type == .MixinBlock) {
if (caller_block) |block| {
// Clone the caller's block
return cloneNode(allocator, block);
} else {
// No block provided, return empty block
const empty = allocator.create(Node) catch return error.OutOfMemory;
empty.* = Node{
.type = .Block,
.line = node.line,
.column = node.column,
};
return empty;
}
}
// For other nodes, clone and recurse into children
const new_node = allocator.create(Node) catch return error.OutOfMemory;
new_node.* = node.*;
new_node.nodes = .{};
// Clone and expand children
for (node.nodes.items) |child| {
const expanded_child = try expandNode(allocator, child, registry, caller_block);
new_node.nodes.append(allocator, expanded_child) catch return error.OutOfMemory;
}
return new_node;
}
fn expandMixinCall(
allocator: Allocator,
call_node: *Node,
registry: *const MixinRegistry,
_: ?*Node,
) MixinError!*Node {
const mixin_name = call_node.name orelse return error.InvalidMixinCall;
// Look up mixin definition
const mixin_def = registry.get(mixin_name) orelse {
// Mixin not found - return a comment node indicating the error
const error_node = allocator.create(Node) catch return error.OutOfMemory;
error_node.* = Node{
.type = .Comment,
.val = mixin_name,
.buffer = true,
.line = call_node.line,
.column = call_node.column,
};
return error_node;
};
// Get the block content from the call (if any)
var call_block: ?*Node = null;
if (call_node.nodes.items.len > 0) {
// Create a block node containing the call's children
const block = allocator.create(Node) catch return error.OutOfMemory;
block.* = Node{
.type = .Block,
.line = call_node.line,
.column = call_node.column,
};
for (call_node.nodes.items) |child| {
const cloned = try cloneNode(allocator, child);
block.nodes.append(allocator, cloned) catch return error.OutOfMemory;
}
call_block = block;
}
// Create argument bindings
var arg_bindings = std.StringHashMapUnmanaged([]const u8){};
defer arg_bindings.deinit(allocator);
// Bind call arguments to mixin parameters
if (mixin_def.args) |params| {
if (call_node.args) |args| {
try bindArguments(allocator, params, args, &arg_bindings);
}
}
// Clone and expand the mixin body
const result = allocator.create(Node) catch return error.OutOfMemory;
result.* = Node{
.type = .Block,
.line = call_node.line,
.column = call_node.column,
};
// Expand each node in the mixin definition's body
for (mixin_def.nodes.items) |child| {
const expanded = try expandNodeWithArgs(allocator, child, registry, call_block, &arg_bindings);
result.nodes.append(allocator, expanded) catch return error.OutOfMemory;
}
return result;
}
fn expandNodeWithArgs(
allocator: Allocator,
node: *Node,
registry: *const MixinRegistry,
caller_block: ?*Node,
arg_bindings: *const std.StringHashMapUnmanaged([]const u8),
) MixinError!*Node {
// Handle mixin call (nested)
if (node.type == .Mixin and node.call) {
return expandMixinCall(allocator, node, registry, caller_block);
}
// Handle MixinBlock - replace with caller's block content
if (node.type == .MixinBlock) {
if (caller_block) |block| {
return cloneNode(allocator, block);
} else {
const empty = allocator.create(Node) catch return error.OutOfMemory;
empty.* = Node{
.type = .Block,
.line = node.line,
.column = node.column,
};
return empty;
}
}
// Clone the node
const new_node = allocator.create(Node) catch return error.OutOfMemory;
new_node.* = node.*;
new_node.nodes = .{};
new_node.attrs = .{};
// Substitute argument references in text/val
if (node.val) |val| {
new_node.val = try substituteArgs(allocator, val, arg_bindings);
}
// Clone attributes with argument substitution
for (node.attrs.items) |attr| {
var new_attr = attr;
if (attr.val) |val| {
new_attr.val = try substituteArgs(allocator, val, arg_bindings);
}
new_node.attrs.append(allocator, new_attr) catch return error.OutOfMemory;
}
// Recurse into children
for (node.nodes.items) |child| {
const expanded = try expandNodeWithArgs(allocator, child, registry, caller_block, arg_bindings);
new_node.nodes.append(allocator, expanded) catch return error.OutOfMemory;
}
return new_node;
}
/// Substitute argument references in a string and evaluate simple expressions
fn substituteArgs(
allocator: Allocator,
text: []const u8,
bindings: *const std.StringHashMapUnmanaged([]const u8),
) MixinError![]const u8 {
// Quick check - if no bindings or text doesn't contain any param names, return as-is
if (bindings.count() == 0) {
return text;
}
// Check if any substitution is needed
var needs_substitution = false;
var iter = bindings.iterator();
while (iter.next()) |entry| {
if (mem.indexOf(u8, text, entry.key_ptr.*) != null) {
needs_substitution = true;
break;
}
}
if (!needs_substitution) {
return text;
}
// Perform substitution
var result = std.ArrayListUnmanaged(u8){};
errdefer result.deinit(allocator);
var i: usize = 0;
while (i < text.len) {
var found_match = false;
// Check for parameter match at current position
var iter2 = bindings.iterator();
while (iter2.next()) |entry| {
const param = entry.key_ptr.*;
const value = entry.value_ptr.*;
if (i + param.len <= text.len and mem.eql(u8, text[i .. i + param.len], param)) {
// Check it's a word boundary (not part of a larger identifier)
const before_ok = i == 0 or !isIdentChar(text[i - 1]);
const after_ok = i + param.len >= text.len or !isIdentChar(text[i + param.len]);
if (before_ok and after_ok) {
result.appendSlice(allocator, value) catch return error.OutOfMemory;
i += param.len;
found_match = true;
break;
}
}
}
if (!found_match) {
result.append(allocator, text[i]) catch return error.OutOfMemory;
i += 1;
}
}
const substituted = result.toOwnedSlice(allocator) catch return error.OutOfMemory;
// Evaluate string concatenation expressions like "btn btn-" + "primary"
return evaluateStringConcat(allocator, substituted) catch return error.OutOfMemory;
}
/// Evaluate simple string concatenation expressions
/// Handles: "btn btn-" + primary -> "btn btn-primary"
/// Also handles: "btn btn-" + "primary" -> "btn btn-primary"
fn evaluateStringConcat(allocator: Allocator, expr: []const u8) ![]const u8 {
// Check if there's a + operator (string concat)
_ = mem.indexOf(u8, expr, " + ") orelse return expr;
var result = std.ArrayListUnmanaged(u8){};
errdefer result.deinit(allocator);
var remaining = expr;
var is_first_part = true;
while (remaining.len > 0) {
const next_plus = mem.indexOf(u8, remaining, " + ");
const part = if (next_plus) |pos| remaining[0..pos] else remaining;
// Extract string value (strip quotes and whitespace)
const stripped = mem.trim(u8, part, " \t");
const unquoted = stripQuotes(stripped);
// For the first part, we might want to keep it quoted in the final output
// For subsequent parts, just append the value
if (is_first_part) {
// If the first part is a quoted string, we'll build an unquoted result
result.appendSlice(allocator, unquoted) catch return error.OutOfMemory;
is_first_part = false;
} else {
result.appendSlice(allocator, unquoted) catch return error.OutOfMemory;
}
if (next_plus) |pos| {
remaining = remaining[pos + 3 ..]; // Skip " + "
} else {
break;
}
}
// Free original and return concatenated result
allocator.free(expr);
return result.toOwnedSlice(allocator);
}
fn isIdentChar(c: u8) bool {
return (c >= 'a' and c <= 'z') or
(c >= 'A' and c <= 'Z') or
(c >= '0' and c <= '9') or
c == '_' or c == '-';
}
/// Bind call arguments to mixin parameters
fn bindArguments(
allocator: Allocator,
params: []const u8,
args: []const u8,
bindings: *std.StringHashMapUnmanaged([]const u8),
) MixinError!void {
// Parse parameter names from definition: "text, type" or "text, type='primary'"
var param_names = std.ArrayListUnmanaged([]const u8){};
defer param_names.deinit(allocator);
var param_iter = mem.splitSequence(u8, params, ",");
while (param_iter.next()) |param_part| {
const trimmed = mem.trim(u8, param_part, " \t");
if (trimmed.len == 0) continue;
// Handle default values: "type='primary'" -> just get "type"
var param_name = trimmed;
if (mem.indexOf(u8, trimmed, "=")) |eq_pos| {
param_name = mem.trim(u8, trimmed[0..eq_pos], " \t");
}
// Handle rest args: "...items" -> "items"
if (mem.startsWith(u8, param_name, "...")) {
param_name = param_name[3..];
}
param_names.append(allocator, param_name) catch return error.OutOfMemory;
}
// Parse argument values from call: "'Click', 'primary'" or "text='Click'"
var arg_values = std.ArrayListUnmanaged([]const u8){};
defer arg_values.deinit(allocator);
// Simple argument parsing - split by comma but respect quotes
var in_string = false;
var string_char: u8 = 0;
var paren_depth: usize = 0;
var start: usize = 0;
for (args, 0..) |c, idx| {
if (!in_string) {
if (c == '"' or c == '\'') {
in_string = true;
string_char = c;
} else if (c == '(') {
paren_depth += 1;
} else if (c == ')') {
if (paren_depth > 0) paren_depth -= 1;
} else if (c == ',' and paren_depth == 0) {
const arg_val = mem.trim(u8, args[start..idx], " \t");
arg_values.append(allocator, stripQuotes(arg_val)) catch return error.OutOfMemory;
start = idx + 1;
}
} else {
if (c == string_char) {
in_string = false;
}
}
}
// Add last argument
if (start < args.len) {
const arg_val = mem.trim(u8, args[start..], " \t");
if (arg_val.len > 0) {
arg_values.append(allocator, stripQuotes(arg_val)) catch return error.OutOfMemory;
}
}
// Bind positional arguments
const min_len = @min(param_names.items.len, arg_values.items.len);
for (0..min_len) |i| {
bindings.put(allocator, param_names.items[i], arg_values.items[i]) catch return error.OutOfMemory;
}
}
fn stripQuotes(val: []const u8) []const u8 {
if (val.len < 2) return val;
const first = val[0];
const last = val[val.len - 1];
if ((first == '"' and last == '"') or (first == '\'' and last == '\'')) {
return val[1 .. val.len - 1];
}
return val;
}
/// Clone a node and all its children
fn cloneNode(allocator: Allocator, node: *Node) MixinError!*Node {
const new_node = allocator.create(Node) catch return error.OutOfMemory;
new_node.* = node.*;
new_node.nodes = .{};
new_node.attrs = .{};
// Clone attributes
for (node.attrs.items) |attr| {
new_node.attrs.append(allocator, attr) catch return error.OutOfMemory;
}
// Clone children recursively
for (node.nodes.items) |child| {
const cloned_child = try cloneNode(allocator, child);
new_node.nodes.append(allocator, cloned_child) catch return error.OutOfMemory;
}
return new_node;
}
// ============================================================================
// Tests
// ============================================================================
test "MixinRegistry - basic operations" {
const allocator = std.testing.allocator;
var registry = MixinRegistry.init(allocator);
defer registry.deinit();
// Create a mock mixin node
var mixin_node = Node{
.type = .Mixin,
.name = "button",
.line = 1,
.column = 1,
};
try registry.register("button", &mixin_node);
try std.testing.expect(registry.contains("button"));
try std.testing.expect(!registry.contains("nonexistent"));
const retrieved = registry.get("button");
try std.testing.expect(retrieved != null);
try std.testing.expectEqualStrings("button", retrieved.?.name.?);
}
test "bindArguments - simple positional" {
const allocator = std.testing.allocator;
var bindings = std.StringHashMapUnmanaged([]const u8){};
defer bindings.deinit(allocator);
try bindArguments(allocator, "text, type", "'Click', 'primary'", &bindings);
try std.testing.expectEqualStrings("Click", bindings.get("text").?);
try std.testing.expectEqualStrings("primary", bindings.get("type").?);
}
test "substituteArgs - basic substitution" {
const allocator = std.testing.allocator;
var bindings = std.StringHashMapUnmanaged([]const u8){};
defer bindings.deinit(allocator);
bindings.put(allocator, "title", "Hello") catch unreachable;
bindings.put(allocator, "name", "World") catch unreachable;
const result = try substituteArgs(allocator, "title is title and name is name", &bindings);
defer allocator.free(result);
try std.testing.expectEqualStrings("Hello is Hello and World is World", result);
}
test "stripQuotes" {
try std.testing.expectEqualStrings("hello", stripQuotes("'hello'"));
try std.testing.expectEqualStrings("hello", stripQuotes("\"hello\""));
try std.testing.expectEqualStrings("hello", stripQuotes("hello"));
try std.testing.expectEqualStrings("", stripQuotes("''"));
}
test "substituteArgs - string concatenation expression" {
const allocator = std.testing.allocator;
var bindings = std.StringHashMapUnmanaged([]const u8){};
defer bindings.deinit(allocator);
try bindings.put(allocator, "type", "primary");
// Test the exact format that comes from the parser
const input = "\"btn btn-\" + type";
const result = try substituteArgs(allocator, input, &bindings);
defer allocator.free(result);
// After substitution and concatenation evaluation, should be: btn btn-primary
try std.testing.expectEqualStrings("btn btn-primary", result);
}
test "evaluateStringConcat - basic" {
const allocator = std.testing.allocator;
// Test with quoted + unquoted
const input1 = try allocator.dupe(u8, "\"btn btn-\" + primary");
const result1 = try evaluateStringConcat(allocator, input1);
defer allocator.free(result1);
try std.testing.expectEqualStrings("btn btn-primary", result1);
// Test with both quoted
const input2 = try allocator.dupe(u8, "\"btn btn-\" + \"primary\"");
const result2 = try evaluateStringConcat(allocator, input2);
defer allocator.free(result2);
try std.testing.expectEqualStrings("btn btn-primary", result2);
}

File diff suppressed because it is too large Load Diff

BIN
src/playground/benchmark Executable file

Binary file not shown.

View File

@@ -0,0 +1,66 @@
// benchmark.zig - Benchmark for pugz (Zig Pug implementation)
//
// This benchmark matches the JavaScript pug benchmark for comparison
// Uses exact same templates as packages/pug/support/benchmark.js
const std = @import("std");
const pug = @import("../pug.zig");
const MIN_ITERATIONS: usize = 200;
const MIN_TIME_NS: u64 = 200_000_000; // 200ms minimum
fn benchmark(comptime name: []const u8, template: []const u8, iterations: *usize, elapsed_ns: *u64) !void {
const allocator = std.heap.page_allocator;
// Warmup
for (0..10) |_| {
var result = try pug.compile(allocator, template, .{});
result.deinit(allocator);
}
var timer = try std.time.Timer.start();
var count: usize = 0;
while (count < MIN_ITERATIONS or timer.read() < MIN_TIME_NS) {
var result = try pug.compile(allocator, template, .{});
result.deinit(allocator);
count += 1;
}
const elapsed = timer.read();
iterations.* = count;
elapsed_ns.* = elapsed;
const ops_per_sec = @as(f64, @floatFromInt(count)) * 1_000_000_000.0 / @as(f64, @floatFromInt(elapsed));
std.debug.print("{s}: {d:.0}\n", .{ name, ops_per_sec });
}
pub fn main() !void {
var iterations: usize = 0;
var elapsed_ns: u64 = 0;
// Tiny template - exact match to JS: 'html\n body\n h1 Title'
const tiny = "html\n body\n h1 Title";
try benchmark("tiny", tiny, &iterations, &elapsed_ns);
// Small template - exact match to JS (note trailing \n on each line)
const small =
"html\n" ++
" body\n" ++
" h1 Title\n" ++
" ul#menu\n" ++
" li: a(href=\"#\") Home\n" ++
" li: a(href=\"#\") About Us\n" ++
" li: a(href=\"#\") Store\n" ++
" li: a(href=\"#\") FAQ\n" ++
" li: a(href=\"#\") Contact\n";
try benchmark("small", small, &iterations, &elapsed_ns);
// Medium template - Array(30).join(str) creates 29 copies in JS
const medium = small ** 29;
try benchmark("medium", medium, &iterations, &elapsed_ns);
// Large template - Array(100).join(str) creates 99 copies in JS
const large = small ** 99;
try benchmark("large", large, &iterations, &elapsed_ns);
}

View File

@@ -0,0 +1,274 @@
// benchmark_examples.zig - Benchmark pug example files
//
// Tests the same example files as the JS benchmark
const std = @import("std");
const pug = @import("../pug.zig");
const Example = struct {
name: []const u8,
source: []const u8,
};
// Example templates (matching JS pug examples that don't use includes/extends)
const examples = [_]Example{
.{
.name = "attributes.pug",
.source =
\\div#id.left.container(class='user user-' + name)
\\ h1.title= name
\\ form
\\ //- unbuffered comment :)
\\ // An example of attributes.
\\ input(type='text' name='user[name]' value=name)
\\ input(checked, type='checkbox', name='user[blocked]')
\\ input(type='submit', value='Update')
,
},
.{
.name = "code.pug",
.source =
\\- var title = "Things"
\\
\\-
\\ var subtitle = ["Really", "long",
\\ "list", "of",
\\ "words"]
\\h1= title
\\h2= subtitle.join(" ")
\\
\\ul#users
\\ each user, name in users
\\ // expands to if (user.isA == 'ferret')
\\ if user.isA == 'ferret'
\\ li(class='user-' + name) #{name} is just a ferret
\\ else
\\ li(class='user-' + name) #{name} #{user.email}
,
},
.{
.name = "dynamicscript.pug",
.source =
\\html
\\ head
\\ title Dynamic Inline JavaScript
\\ script.
\\ var users = !{JSON.stringify(users).replace(/<\//g, "<\\/")}
,
},
.{
.name = "each.pug",
.source =
\\ul#users
\\ each user, name in users
\\ li(class='user-' + name) #{name} #{user.email}
,
},
.{
.name = "extend-layout.pug",
.source =
\\html
\\ head
\\ h1 My Site - #{title}
\\ block scripts
\\ script(src='/jquery.js')
\\ body
\\ block content
\\ block foot
\\ #footer
\\ p some footer content
,
},
.{
.name = "form.pug",
.source =
\\form(method="post")
\\ fieldset
\\ legend General
\\ p
\\ label(for="user[name]") Username:
\\ input(type="text", name="user[name]", value=user.name)
\\ p
\\ label(for="user[email]") Email:
\\ input(type="text", name="user[email]", value=user.email)
\\ .tip.
\\ Enter a valid
\\ email address
\\ such as <em>tj@vision-media.ca</em>.
\\ fieldset
\\ legend Location
\\ p
\\ label(for="user[city]") City:
\\ input(type="text", name="user[city]", value=user.city)
\\ p
\\ select(name="user[province]")
\\ option(value="") -- Select Province --
\\ option(value="AB") Alberta
\\ option(value="BC") British Columbia
\\ option(value="SK") Saskatchewan
\\ option(value="MB") Manitoba
\\ option(value="ON") Ontario
\\ option(value="QC") Quebec
\\ p.buttons
\\ input(type="submit", value="Save")
,
},
.{
.name = "layout.pug",
.source =
\\doctype html
\\html(lang="en")
\\ head
\\ title Example
\\ script.
\\ if (foo) {
\\ bar();
\\ }
\\ body
\\ h1 Pug - node template engine
\\ #container
,
},
.{
.name = "pet.pug",
.source =
\\.pet
\\ h2= pet.name
\\ p #{pet.name} is <em>#{pet.age}</em> year(s) old.
,
},
.{
.name = "rss.pug",
.source =
\\doctype xml
\\rss(version='2.0')
\\channel
\\ title RSS Title
\\ description Some description here
\\ link http://google.com
\\ lastBuildDate Mon, 06 Sep 2010 00:01:00 +0000
\\ pubDate Mon, 06 Sep 2009 16:45:00 +0000
\\
\\ each item in items
\\ item
\\ title= item.title
\\ description= item.description
\\ link= item.link
,
},
.{
.name = "text.pug",
.source =
\\| An example of an
\\a(href='#') inline
\\| link.
\\
\\form
\\ label Username:
\\ input(type='text', name='user[name]')
\\ p
\\ | Just an example of some text usage.
\\ | You can have <em>inline</em> html,
\\ | as well as
\\ strong tags
\\ | .
\\
\\ | Interpolation is also supported. The
\\ | username is currently "#{name}".
\\
\\ label Email:
\\ input(type='text', name='user[email]')
\\ p
\\ | Email is currently
\\ em= email
\\ | .
,
},
.{
.name = "whitespace.pug",
.source =
\\- var js = '<script></script>'
\\doctype html
\\html
\\
\\ head
\\ title= "Some " + "JavaScript"
\\ != js
\\
\\
\\
\\ body
,
},
};
pub fn main() !void {
const allocator = std.heap.page_allocator;
std.debug.print("=== Zig Pugz Example Benchmark ===\n\n", .{});
var passed: usize = 0;
var failed: usize = 0;
var total_time_ns: u64 = 0;
var html_outputs: [examples.len]?[]const u8 = undefined;
for (&html_outputs) |*h| h.* = null;
for (examples, 0..) |example, idx| {
const iterations: usize = 100;
var success = false;
var time_ns: u64 = 0;
// Warmup
for (0..5) |_| {
var result = pug.compile(allocator, example.source, .{}) catch continue;
result.deinit(allocator);
}
// Benchmark
var timer = try std.time.Timer.start();
var i: usize = 0;
while (i < iterations) : (i += 1) {
var result = pug.compile(allocator, example.source, .{}) catch break;
if (i == iterations - 1) {
// Keep last HTML for output
html_outputs[idx] = result.html;
} else {
result.deinit(allocator);
}
success = true;
}
time_ns = timer.read();
if (success and i == iterations) {
const time_ms = @as(f64, @floatFromInt(time_ns)) / 1_000_000.0 / @as(f64, @floatFromInt(iterations));
std.debug.print("{s}: OK ({d:.3} ms)\n", .{ example.name, time_ms });
passed += 1;
total_time_ns += time_ns;
} else {
std.debug.print("{s}: FAILED\n", .{example.name});
failed += 1;
}
}
std.debug.print("\n=== Summary ===\n", .{});
std.debug.print("Passed: {d}/{d}\n", .{ passed, examples.len });
std.debug.print("Failed: {d}/{d}\n", .{ failed, examples.len });
if (passed > 0) {
const total_ms = @as(f64, @floatFromInt(total_time_ns)) / 1_000_000.0 / 100.0;
std.debug.print("Total time (successful): {d:.3} ms\n", .{total_ms});
std.debug.print("Average time: {d:.3} ms\n", .{total_ms / @as(f64, @floatFromInt(passed))});
}
// Output HTML for comparison
std.debug.print("\n=== HTML Output ===\n", .{});
for (examples, 0..) |example, idx| {
if (html_outputs[idx]) |html| {
std.debug.print("\n--- {s} ---\n", .{example.name});
const max_len = @min(html.len, 500);
std.debug.print("{s}", .{html[0..max_len]});
if (html.len > 500) std.debug.print("...", .{});
std.debug.print("\n", .{});
}
}
}

View File

@@ -0,0 +1,8 @@
div#id.left.container(class='user user-' + name)
h1.title= name
form
//- unbuffered comment :)
// An example of attributes.
input(type='text' name='user[name]' value=name)
input(checked, type='checkbox', name='user[blocked]')
input(type='submit', value='Update')

View File

@@ -0,0 +1,17 @@
- var title = "Things"
-
var subtitle = ["Really", "long",
"list", "of",
"words"]
h1= title
h2= subtitle.join(" ")
ul#users
each user, name in users
// expands to if (user.isA == 'ferret')
if user.isA == 'ferret'
li(class='user-' + name) #{name} is just a ferret
else
li(class='user-' + name) #{name} #{user.email}

View File

@@ -0,0 +1,5 @@
html
head
title Dynamic Inline JavaScript
script.
var users = !{JSON.stringify(users).replace(/<\//g, "<\\/")}

View File

@@ -0,0 +1,3 @@
ul#users
each user, name in users
li(class='user-' + name) #{name} #{user.email}

View File

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

View File

@@ -0,0 +1,11 @@
extends extend-layout.pug
block scripts
script(src='/jquery.js')
script(src='/pets.js')
block content
h1= title
each pet in pets
include pet.pug

View File

@@ -0,0 +1,29 @@
form(method="post")
fieldset
legend General
p
label(for="user[name]") Username:
input(type="text", name="user[name]", value=user.name)
p
label(for="user[email]") Email:
input(type="text", name="user[email]", value=user.email)
.tip.
Enter a valid
email address
such as <em>tj@vision-media.ca</em>.
fieldset
legend Location
p
label(for="user[city]") City:
input(type="text", name="user[city]", value=user.city)
p
select(name="user[province]")
option(value="") -- Select Province --
option(value="AB") Alberta
option(value="BC") British Columbia
option(value="SK") Saskatchewan
option(value="MB") Manitoba
option(value="ON") Ontario
option(value="QC") Quebec
p.buttons
input(type="submit", value="Save")

View File

@@ -0,0 +1,7 @@
html
include includes/head.pug
body
h1 My Site
p Welcome to my super lame site.
include includes/foot.pug

View File

@@ -0,0 +1,14 @@
doctype html
html(lang="en")
head
title Example
script.
if (foo) {
bar();
}
body
h1 Pug - node template engine
#container
:markdown-it
Pug is a _high performance_ template engine for [node](http://nodejs.org),
inspired by [haml](http://haml-lang.com/), and written by [TJ Holowaychuk](http://github.com/visionmedia).

View File

@@ -0,0 +1,14 @@
include mixins/dialog.pug
include mixins/profile.pug
.one
+dialog
.two
+dialog-title('Whoop')
.three
+dialog-title-desc('Whoop', 'Just a mixin')
#profile
+profile(user)

View File

@@ -0,0 +1,3 @@
.pet
h2= pet.name
p #{pet.name} is <em>#{pet.age}</em> year(s) old.

View File

@@ -0,0 +1,14 @@
doctype xml
rss(version='2.0')
channel
title RSS Title
description Some description here
link http://google.com
lastBuildDate Mon, 06 Sep 2010 00:01:00 +0000
pubDate Mon, 06 Sep 2009 16:45:00 +0000
each item in items
item
title= item.title
description= item.description
link= item.link

View File

@@ -0,0 +1,36 @@
| An example of an
a(href='#') inline
| link.
form
label Username:
input(type='text', name='user[name]')
p
| Just an example of some text usage.
| You can have <em>inline</em> html,
| as well as
strong tags
| .
| Interpolation is also supported. The
| username is currently "#{name}".
label Email:
input(type='text', name='user[email]')
p
| Email is currently
em= email
| .
// alternatively, if we plan on having only
// text or inline-html, we can use a trailing
// "." to let pug know we want to omit pipes
label Username:
input(type='text')
p.
Just an example, like before
however now we can omit those
annoying pipes!.
Wahoo.

View File

@@ -0,0 +1,11 @@
- var js = '<script></script>'
doctype html
html
head
title= "Some " + "JavaScript"
!= js
body

70
src/playground/run_js.js Normal file
View File

@@ -0,0 +1,70 @@
/**
* JS Pug - Process all .pug files in playground folder
*/
const fs = require('fs');
const path = require('path');
const pug = require('../../pug');
const dir = path.join(__dirname, 'examples');
// Get all .pug files
const pugFiles = fs.readdirSync(dir)
.filter(f => f.endsWith('.pug'))
.sort();
console.log('=== JS Pug Playground ===\n');
console.log(`Found ${pugFiles.length} .pug files\n`);
let passed = 0;
let failed = 0;
let totalTimeMs = 0;
for (const file of pugFiles) {
const filePath = path.join(dir, file);
const source = fs.readFileSync(filePath, 'utf8');
const iterations = 100;
let success = false;
let html = '';
let error = '';
let timeMs = 0;
try {
const start = process.hrtime.bigint();
for (let i = 0; i < iterations; i++) {
html = pug.render(source, {
filename: filePath,
basedir: dir
});
}
const end = process.hrtime.bigint();
timeMs = Number(end - start) / 1_000_000 / iterations;
success = true;
passed++;
totalTimeMs += timeMs;
} catch (e) {
error = e.message.split('\n')[0];
failed++;
}
if (success) {
console.log(`${file} (${timeMs.toFixed(3)} ms)`);
// Show first 200 chars of output
const preview = html.replace(/\s+/g, ' ').substring(0, 200);
console.log(`${preview}${html.length > 200 ? '...' : ''}\n`);
} else {
console.log(`${file}`);
console.log(`${error}\n`);
}
}
console.log('=== Summary ===');
console.log(`Passed: ${passed}/${pugFiles.length}`);
console.log(`Failed: ${failed}/${pugFiles.length}`);
if (passed > 0) {
console.log(`Total time: ${totalTimeMs.toFixed(3)} ms`);
console.log(`Average: ${(totalTimeMs / passed).toFixed(3)} ms per file`);
}

0
src/playground/run_zig Executable file
View File

120
src/playground/run_zig.zig Normal file
View File

@@ -0,0 +1,120 @@
// Zig Pugz - Process all .pug files in playground/examples folder
const std = @import("std");
const pug = @import("../pug.zig");
const fs = std.fs;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
std.debug.print("=== Zig Pugz Playground ===\n\n", .{});
// Open the examples directory relative to cwd
var dir = fs.cwd().openDir("playground/examples", .{ .iterate = true }) catch |err| {
// Try from playground directory
dir = fs.cwd().openDir("examples", .{ .iterate = true }) catch {
std.debug.print("Error opening examples directory: {}\n", .{err});
return;
};
};
defer dir.close();
// Collect .pug files
var files = std.ArrayList([]const u8).init(allocator);
defer {
for (files.items) |f| allocator.free(f);
files.deinit();
}
var iter = dir.iterate();
while (try iter.next()) |entry| {
if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".pug")) {
const name = try allocator.dupe(u8, entry.name);
try files.append(name);
}
}
// Sort files
std.mem.sort([]const u8, files.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
std.debug.print("Found {d} .pug files\n\n", .{files.items.len});
var passed: usize = 0;
var failed: usize = 0;
var total_time_ns: u64 = 0;
for (files.items) |filename| {
// Read file
const file = dir.openFile(filename, .{}) catch {
std.debug.print("✗ {s}\n → Could not open file\n\n", .{filename});
failed += 1;
continue;
};
defer file.close();
const source = file.readToEndAlloc(allocator, 1024 * 1024) catch {
std.debug.print("✗ {s}\n → Could not read file\n\n", .{filename});
failed += 1;
continue;
};
defer allocator.free(source);
// Benchmark
const iterations: usize = 100;
var success = false;
var last_html: ?[]const u8 = null;
// Warmup
for (0..5) |_| {
var result = pug.compile(allocator, source, .{}) catch continue;
result.deinit(allocator);
}
var timer = try std.time.Timer.start();
var i: usize = 0;
while (i < iterations) : (i += 1) {
var result = pug.compile(allocator, source, .{}) catch break;
if (i == iterations - 1) {
last_html = result.html;
} else {
result.deinit(allocator);
}
success = true;
}
const elapsed_ns = timer.read();
if (success and i == iterations) {
const time_ms = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000.0 / @as(f64, @floatFromInt(iterations));
std.debug.print("✓ {s} ({d:.3} ms)\n", .{ filename, time_ms });
// Show preview
if (last_html) |html| {
const max_len = @min(html.len, 200);
std.debug.print(" → {s}{s}\n\n", .{ html[0..max_len], if (html.len > 200) "..." else "" });
allocator.free(html);
}
passed += 1;
total_time_ns += elapsed_ns;
} else {
std.debug.print("✗ {s}\n → Compilation failed\n\n", .{filename});
failed += 1;
}
}
std.debug.print("=== Summary ===\n", .{});
std.debug.print("Passed: {d}/{d}\n", .{ passed, files.items.len });
std.debug.print("Failed: {d}/{d}\n", .{ failed, files.items.len });
if (passed > 0) {
const total_ms = @as(f64, @floatFromInt(total_time_ns)) / 1_000_000.0 / 100.0;
std.debug.print("Total time: {d:.3} ms\n", .{total_ms});
std.debug.print("Average: {d:.3} ms per file\n", .{total_ms / @as(f64, @floatFromInt(passed))});
}
}

457
src/pug.zig Normal file
View File

@@ -0,0 +1,457 @@
// pug.zig - Main entry point for Pug template engine in Zig
//
// This is the main module that ties together all the Pug compilation stages:
// 1. Lexer - tokenizes the source
// 2. Parser - builds the AST
// 3. Strip Comments - removes comment tokens
// 4. Load - loads includes and extends
// 5. Linker - resolves template inheritance
// 6. Codegen - generates HTML output
const std = @import("std");
const Allocator = std.mem.Allocator;
const mem = std.mem;
// ============================================================================
// Module Exports
// ============================================================================
pub const lexer = @import("lexer.zig");
pub const parser = @import("parser.zig");
pub const runtime = @import("runtime.zig");
pub const pug_error = @import("error.zig");
pub const walk = @import("walk.zig");
pub const strip_comments = @import("strip_comments.zig");
pub const load = @import("load.zig");
pub const linker = @import("linker.zig");
pub const codegen = @import("codegen.zig");
// Re-export commonly used types
pub const Token = lexer.Token;
pub const TokenType = lexer.TokenType;
pub const Lexer = lexer.Lexer;
pub const Parser = parser.Parser;
pub const Node = parser.Node;
pub const NodeType = parser.NodeType;
pub const PugError = pug_error.PugError;
pub const Compiler = codegen.Compiler;
// ============================================================================
// Compile Options
// ============================================================================
pub const CompileOptions = struct {
/// Source filename for error messages
filename: ?[]const u8 = null,
/// Base directory for absolute includes
basedir: ?[]const u8 = null,
/// Pretty print output with indentation
pretty: bool = false,
/// Strip unbuffered comments
strip_unbuffered_comments: bool = true,
/// Strip buffered comments
strip_buffered_comments: bool = false,
/// Include debug information
debug: bool = false,
/// Doctype to use
doctype: ?[]const u8 = null,
};
// ============================================================================
// Compile Result
// ============================================================================
pub const CompileResult = struct {
html: []const u8,
err: ?PugError = null,
pub fn deinit(self: *CompileResult, allocator: Allocator) void {
allocator.free(self.html);
if (self.err) |*e| {
e.deinit();
}
}
};
// ============================================================================
// Compilation Errors
// ============================================================================
pub const CompileError = error{
OutOfMemory,
LexerError,
ParserError,
LoadError,
LinkerError,
CodegenError,
FileNotFound,
AccessDenied,
InvalidUtf8,
};
// ============================================================================
// Main Compilation Functions
// ============================================================================
/// Compile a Pug template string to HTML
pub fn compile(
allocator: Allocator,
source: []const u8,
options: CompileOptions,
) CompileError!CompileResult {
var result = CompileResult{
.html = &[_]u8{},
};
// Stage 1: Lex the source
var lex_inst = Lexer.init(allocator, source, .{
.filename = options.filename,
}) catch {
return error.LexerError;
};
defer lex_inst.deinit();
const tokens = lex_inst.getTokens() catch {
if (lex_inst.last_error) |err| {
// Try to create detailed error, fall back to basic error if allocation fails
result.err = pug_error.makeError(
allocator,
"PUG:LEXER_ERROR",
err.message,
.{
.line = err.line,
.column = err.column,
.filename = options.filename,
.src = source,
},
) catch blk: {
// If error creation fails, create minimal error without source context
break :blk pug_error.makeError(allocator, "PUG:LEXER_ERROR", err.message, .{
.line = err.line,
.column = err.column,
.filename = options.filename,
.src = null, // Skip source to reduce allocation
}) catch null;
};
}
return error.LexerError;
};
// Stage 2: Strip comments
var stripped = strip_comments.stripComments(
allocator,
tokens,
.{
.strip_unbuffered = options.strip_unbuffered_comments,
.strip_buffered = options.strip_buffered_comments,
.filename = options.filename,
},
) catch {
return error.LexerError;
};
defer stripped.deinit(allocator);
// Stage 3: Parse tokens to AST
var parse = Parser.init(allocator, stripped.tokens.items, options.filename, source);
defer parse.deinit();
const ast = parse.parse() catch {
if (parse.err) |err| {
// Try to create detailed error, fall back to basic error if allocation fails
result.err = pug_error.makeError(
allocator,
"PUG:PARSER_ERROR",
err.message,
.{
.line = err.line,
.column = err.column,
.filename = options.filename,
.src = source,
},
) catch blk: {
// If error creation fails, create minimal error without source context
break :blk pug_error.makeError(allocator, "PUG:PARSER_ERROR", err.message, .{
.line = err.line,
.column = err.column,
.filename = options.filename,
.src = null,
}) catch null;
};
}
return error.ParserError;
};
defer {
ast.deinit(allocator);
allocator.destroy(ast);
}
// Stage 4: Link (resolve extends/blocks)
var link_result = linker.link(allocator, ast) catch {
return error.LinkerError;
};
defer link_result.deinit(allocator);
// Stage 5: Generate HTML
var compiler = Compiler.init(allocator, .{
.pretty = options.pretty,
.doctype = options.doctype,
.debug = options.debug,
});
defer compiler.deinit();
const html = compiler.compile(link_result.ast) catch {
return error.CodegenError;
};
result.html = html;
return result;
}
/// Compile a Pug file to HTML
pub fn compileFile(
allocator: Allocator,
filename: []const u8,
options: CompileOptions,
) CompileError!CompileResult {
// Read the file
const file = std.fs.cwd().openFile(filename, .{}) catch |err| {
return switch (err) {
error.FileNotFound => error.FileNotFound,
error.AccessDenied => error.AccessDenied,
else => error.FileNotFound,
};
};
defer file.close();
const source = file.readToEndAlloc(allocator, 10 * 1024 * 1024) catch {
return error.OutOfMemory;
};
defer allocator.free(source);
// Compile with filename set
var file_options = options;
file_options.filename = filename;
return compile(allocator, source, file_options);
}
/// Render a Pug template string to HTML (convenience function)
pub fn render(
allocator: Allocator,
source: []const u8,
) CompileError![]const u8 {
var result = try compile(allocator, source, .{});
if (result.err) |*e| {
e.deinit();
}
return result.html;
}
/// Render a Pug template string to pretty-printed HTML
pub fn renderPretty(
allocator: Allocator,
source: []const u8,
) CompileError![]const u8 {
var result = try compile(allocator, source, .{ .pretty = true });
if (result.err) |*e| {
e.deinit();
}
return result.html;
}
/// Render a Pug file to HTML
pub fn renderFile(
allocator: Allocator,
filename: []const u8,
) CompileError![]const u8 {
var result = try compileFile(allocator, filename, .{});
if (result.err) |*e| {
e.deinit();
}
return result.html;
}
// ============================================================================
// Tests
// ============================================================================
test "compile - simple text" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "| Hello, World!", .{});
defer result.deinit(allocator);
try std.testing.expectEqualStrings("Hello, World!", result.html);
}
test "compile - simple tag" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "div", .{});
defer result.deinit(allocator);
try std.testing.expectEqualStrings("<div></div>", result.html);
}
test "compile - tag with text" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "p Hello", .{});
defer result.deinit(allocator);
try std.testing.expectEqualStrings("<p>Hello</p>", result.html);
}
test "compile - tag with class shorthand" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "div.container", .{});
defer result.deinit(allocator);
// Parser stores class values with quotes, verify class attribute is present
try std.testing.expect(mem.indexOf(u8, result.html, "class=") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "container") != null);
}
test "compile - tag with id shorthand" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "div#main", .{});
defer result.deinit(allocator);
// Parser stores id values with quotes, verify id attribute is present
try std.testing.expect(mem.indexOf(u8, result.html, "id=") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "main") != null);
}
test "compile - tag with attributes" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "a(href=\"/home\") Home", .{});
defer result.deinit(allocator);
// Parser stores attribute values with quotes, verify attribute is present
try std.testing.expect(mem.indexOf(u8, result.html, "href=") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "/home") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "Home") != null);
}
test "compile - nested tags" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "div\n span Hello", .{});
defer result.deinit(allocator);
try std.testing.expectEqualStrings("<div><span>Hello</span></div>", result.html);
}
test "compile - self-closing tag" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "br", .{});
defer result.deinit(allocator);
try std.testing.expectEqualStrings("<br>", result.html);
}
test "compile - doctype" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "doctype html\nhtml", .{});
defer result.deinit(allocator);
try std.testing.expect(mem.startsWith(u8, result.html, "<!DOCTYPE html>"));
}
test "compile - unbuffered comment stripped" {
const allocator = std.testing.allocator;
// Unbuffered comments (//-) are stripped by default
var result = try compile(allocator, "//- This is stripped\ndiv", .{});
defer result.deinit(allocator);
// The comment text should not appear
try std.testing.expect(mem.indexOf(u8, result.html, "stripped") == null);
// But the div should
try std.testing.expect(mem.indexOf(u8, result.html, "<div>") != null);
}
test "compile - buffered comment visible" {
const allocator = std.testing.allocator;
// Buffered comments (//) are kept by default
var result = try compile(allocator, "// This is visible", .{});
defer result.deinit(allocator);
// Buffered comments should be in output
try std.testing.expect(mem.indexOf(u8, result.html, "<!--") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "visible") != null);
}
test "render - convenience function" {
const allocator = std.testing.allocator;
const html = try render(allocator, "p test");
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>test</p>", html);
}
test "compile - multiple tags" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "p First\np Second", .{});
defer result.deinit(allocator);
try std.testing.expectEqualStrings("<p>First</p><p>Second</p>", result.html);
}
test "compile - interpolation text" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "p Hello, World!", .{});
defer result.deinit(allocator);
try std.testing.expectEqualStrings("<p>Hello, World!</p>", result.html);
}
test "compile - multiple classes" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "div.foo.bar", .{});
defer result.deinit(allocator);
try std.testing.expect(mem.indexOf(u8, result.html, "class=\"") != null);
}
test "compile - class and id" {
const allocator = std.testing.allocator;
var result = try compile(allocator, "div#main.container", .{});
defer result.deinit(allocator);
// Parser stores values with quotes, check that both id and class are present
try std.testing.expect(mem.indexOf(u8, result.html, "id=") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "main") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "class=") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "container") != null);
}
test "compile - deeply nested" {
const allocator = std.testing.allocator;
var result = try compile(allocator,
\\html
\\ head
\\ title Test
\\ body
\\ div Hello
, .{});
defer result.deinit(allocator);
try std.testing.expect(mem.indexOf(u8, result.html, "<html>") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "<head>") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "<title>Test</title>") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "<body>") != null);
try std.testing.expect(mem.indexOf(u8, result.html, "<div>Hello</div>") != null);
}

View File

@@ -1,69 +1,23 @@
//! 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
//!
//! ## Quick Start (Server Usage)
//!
//! ```zig
//! const pugz = @import("pugz");
//!
//! // Initialize view engine once at startup
//! var engine = try pugz.ViewEngine.init(allocator, .{
//! .views_dir = "src/views",
//! });
//! defer engine.deinit();
//!
//! // Render templates (use arena allocator per request)
//! var arena = std.heap.ArenaAllocator.init(allocator);
//! defer arena.deinit();
//!
//! const html = try engine.render(arena.allocator(), "pages/home", .{
//! .title = "Home",
//! });
//! ```
// Pugz - A Pug-like HTML template engine written in Zig
//
// Quick Start:
// const pugz = @import("pugz");
// const engine = pugz.ViewEngine.init(.{ .views_dir = "views" });
// const html = try engine.render(allocator, "index", .{ .title = "Home" });
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");
pub const pug = @import("pug.zig");
pub const view_engine = @import("view_engine.zig");
pub const diagnostic = @import("diagnostic.zig");
pub const template = @import("template.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 renderWithOptions = runtime.renderWithOptions;
pub const RenderOptions = runtime.RenderOptions;
pub const renderTemplate = runtime.renderTemplate;
// High-level API
// Re-export main types
pub const ViewEngine = view_engine.ViewEngine;
pub const CompiledTemplate = view_engine.CompiledTemplate;
pub const compile = pug.compile;
pub const compileFile = pug.compileFile;
pub const render = pug.render;
pub const renderFile = pug.renderFile;
pub const CompileOptions = pug.CompileOptions;
pub const CompileResult = pug.CompileResult;
pub const CompileError = pug.CompileError;
// Build-time template compilation
pub const build_templates = @import("build_templates.zig");
pub const compileTemplates = build_templates.compileTemplates;
test {
_ = @import("std").testing.refAllDecls(@This());
}
// Convenience function for inline templates with data
pub const renderTemplate = template.renderWithData;

118
src/run_playground.zig Normal file
View File

@@ -0,0 +1,118 @@
// Zig Pugz - Process all .pug files in playground/examples folder
const std = @import("std");
const pug = @import("pug.zig");
const fs = std.fs;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
std.debug.print("=== Zig Pugz Playground ===\n\n", .{});
// Open the examples directory
var dir = fs.cwd().openDir("playground/examples", .{ .iterate = true }) catch {
std.debug.print("Error: Could not open playground/examples directory\n", .{});
std.debug.print("Run from packages/pugz/ directory\n", .{});
return;
};
defer dir.close();
// Collect .pug files
var files = std.ArrayListUnmanaged([]const u8){};
defer {
for (files.items) |f| allocator.free(f);
files.deinit(allocator);
}
var iter = dir.iterate();
while (try iter.next()) |entry| {
if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".pug")) {
const name = try allocator.dupe(u8, entry.name);
try files.append(allocator, name);
}
}
// Sort files
std.mem.sort([]const u8, files.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
std.debug.print("Found {d} .pug files\n\n", .{files.items.len});
var passed: usize = 0;
var failed: usize = 0;
var total_time_ns: u64 = 0;
for (files.items) |filename| {
// Read file
const file = dir.openFile(filename, .{}) catch {
std.debug.print("x {s}\n -> Could not open file\n\n", .{filename});
failed += 1;
continue;
};
defer file.close();
const source = file.readToEndAlloc(allocator, 1024 * 1024) catch {
std.debug.print("x {s}\n -> Could not read file\n\n", .{filename});
failed += 1;
continue;
};
defer allocator.free(source);
// Benchmark
const iterations: usize = 100;
var success = false;
var last_html: ?[]const u8 = null;
// Warmup
for (0..5) |_| {
var result = pug.compile(allocator, source, .{}) catch continue;
result.deinit(allocator);
}
var timer = try std.time.Timer.start();
var i: usize = 0;
while (i < iterations) : (i += 1) {
var result = pug.compile(allocator, source, .{}) catch break;
if (i == iterations - 1) {
last_html = result.html;
} else {
result.deinit(allocator);
}
success = true;
}
const elapsed_ns = timer.read();
if (success and i == iterations) {
const time_ms = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000.0 / @as(f64, @floatFromInt(iterations));
std.debug.print("OK {s} ({d:.3} ms)\n", .{ filename, time_ms });
// Show preview
if (last_html) |html| {
const max_len = @min(html.len, 200);
std.debug.print(" -> {s}{s}\n\n", .{ html[0..max_len], if (html.len > 200) "..." else "" });
allocator.free(html);
}
passed += 1;
total_time_ns += elapsed_ns;
} else {
std.debug.print("FAIL {s}\n -> Compilation failed\n\n", .{filename});
failed += 1;
}
}
std.debug.print("=== Summary ===\n", .{});
std.debug.print("Passed: {d}/{d}\n", .{ passed, files.items.len });
std.debug.print("Failed: {d}/{d}\n", .{ failed, files.items.len });
if (passed > 0) {
const total_ms = @as(f64, @floatFromInt(total_time_ns)) / 1_000_000.0 / 100.0;
std.debug.print("Total time: {d:.3} ms\n", .{total_ms});
std.debug.print("Average: {d:.3} ms per file\n", .{total_ms / @as(f64, @floatFromInt(passed))});
}
}

File diff suppressed because it is too large Load Diff

353
src/strip_comments.zig Normal file
View File

@@ -0,0 +1,353 @@
// strip_comments.zig - Zig port of pug-strip-comments
//
// Filters out comment tokens from a token stream.
// Handles both buffered and unbuffered comments with pipeless text support.
const std = @import("std");
const Allocator = std.mem.Allocator;
// Import token types from lexer
const lexer = @import("lexer.zig");
pub const Token = lexer.Token;
pub const TokenType = lexer.TokenType;
// Import error types
const pug_error = @import("error.zig");
pub const PugError = pug_error.PugError;
// ============================================================================
// Strip Comments Options
// ============================================================================
pub const StripCommentsOptions = struct {
/// Strip unbuffered comments (default: true)
strip_unbuffered: bool = true,
/// Strip buffered comments (default: false)
strip_buffered: bool = false,
/// Source filename for error messages
filename: ?[]const u8 = null,
};
// ============================================================================
// Errors
// ============================================================================
pub const StripCommentsError = error{
OutOfMemory,
UnexpectedToken,
};
// ============================================================================
// Strip Comments Result
// ============================================================================
pub const StripCommentsResult = struct {
tokens: std.ArrayListUnmanaged(Token),
err: ?PugError = null,
pub fn deinit(self: *StripCommentsResult, allocator: Allocator) void {
self.tokens.deinit(allocator);
}
};
// ============================================================================
// Strip Comments Implementation
// ============================================================================
/// Strip comments from a token stream
/// Returns filtered tokens with comments removed based on options
pub fn stripComments(
allocator: Allocator,
input: []const Token,
options: StripCommentsOptions,
) StripCommentsError!StripCommentsResult {
var result = StripCommentsResult{
.tokens = .{},
};
// State tracking
var in_comment = false;
var in_pipeless_text = false;
var comment_is_buffered = false;
for (input) |tok| {
const should_include = switch (tok.type) {
.comment => blk: {
if (in_comment) {
// Unexpected comment while already in comment
result.err = pug_error.makeError(
allocator,
"UNEXPECTED_TOKEN",
"`comment` encountered when already in a comment",
.{
.line = tok.loc.start.line,
.column = tok.loc.start.column,
.filename = options.filename,
.src = null,
},
) catch null;
return error.UnexpectedToken;
}
// Check if this is a buffered comment
comment_is_buffered = tok.isBuffered();
// Determine if we should strip this comment
if (comment_is_buffered) {
in_comment = options.strip_buffered;
} else {
in_comment = options.strip_unbuffered;
}
break :blk !in_comment;
},
.start_pipeless_text => blk: {
if (!in_comment) {
break :blk true;
}
if (in_pipeless_text) {
// Unexpected start_pipeless_text
result.err = pug_error.makeError(
allocator,
"UNEXPECTED_TOKEN",
"`start-pipeless-text` encountered when already in pipeless text mode",
.{
.line = tok.loc.start.line,
.column = tok.loc.start.column,
.filename = options.filename,
.src = null,
},
) catch null;
return error.UnexpectedToken;
}
in_pipeless_text = true;
break :blk false;
},
.end_pipeless_text => blk: {
if (!in_comment) {
break :blk true;
}
if (!in_pipeless_text) {
// Unexpected end_pipeless_text
result.err = pug_error.makeError(
allocator,
"UNEXPECTED_TOKEN",
"`end-pipeless-text` encountered when not in pipeless text mode",
.{
.line = tok.loc.start.line,
.column = tok.loc.start.column,
.filename = options.filename,
.src = null,
},
) catch null;
return error.UnexpectedToken;
}
in_pipeless_text = false;
in_comment = false;
break :blk false;
},
// Text tokens right after comment but before pipeless text
.text, .text_html => !in_comment,
// All other tokens
else => blk: {
if (in_pipeless_text) {
break :blk false;
}
in_comment = false;
break :blk true;
},
};
if (should_include) {
try result.tokens.append(allocator, tok);
}
}
return result;
}
/// Convenience function - strip with default options (unbuffered only)
pub fn stripUnbufferedComments(
allocator: Allocator,
input: []const Token,
) StripCommentsError!StripCommentsResult {
return stripComments(allocator, input, .{});
}
/// Convenience function - strip all comments
pub fn stripAllComments(
allocator: Allocator,
input: []const Token,
) StripCommentsError!StripCommentsResult {
return stripComments(allocator, input, .{
.strip_unbuffered = true,
.strip_buffered = true,
});
}
// ============================================================================
// Tests
// ============================================================================
test "stripComments - no comments" {
const allocator = std.testing.allocator;
const tokens = [_]Token{
.{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "div" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 4 } } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 2, .column = 1 } } },
};
var result = try stripComments(allocator, &tokens, .{});
defer result.deinit(allocator);
try std.testing.expectEqual(@as(usize, 3), result.tokens.items.len);
}
test "stripComments - strip unbuffered comment" {
const allocator = std.testing.allocator;
const tokens = [_]Token{
.{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "div" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 4 } } },
.{ .type = .comment, .loc = .{ .start = .{ .line = 2, .column = 1 } }, .buffer = .{ .boolean = false } },
.{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 4 } }, .val = .{ .string = "comment text" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 2, .column = 16 } } },
.{ .type = .tag, .loc = .{ .start = .{ .line = 3, .column = 1 } }, .val = .{ .string = "span" } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 4, .column = 1 } } },
};
var result = try stripComments(allocator, &tokens, .{});
defer result.deinit(allocator);
// Should strip comment and its text, keep tags and structure
try std.testing.expectEqual(@as(usize, 5), result.tokens.items.len);
try std.testing.expectEqual(TokenType.tag, result.tokens.items[0].type);
try std.testing.expectEqual(TokenType.newline, result.tokens.items[1].type);
try std.testing.expectEqual(TokenType.newline, result.tokens.items[2].type);
try std.testing.expectEqual(TokenType.tag, result.tokens.items[3].type);
try std.testing.expectEqual(TokenType.eos, result.tokens.items[4].type);
}
test "stripComments - keep buffered comment by default" {
const allocator = std.testing.allocator;
const tokens = [_]Token{
.{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "div" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 4 } } },
.{ .type = .comment, .loc = .{ .start = .{ .line = 2, .column = 1 } }, .buffer = .{ .boolean = true } },
.{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 4 } }, .val = .{ .string = "buffered comment" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 2, .column = 20 } } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 3, .column = 1 } } },
};
var result = try stripComments(allocator, &tokens, .{});
defer result.deinit(allocator);
// Should keep buffered comment
try std.testing.expectEqual(@as(usize, 6), result.tokens.items.len);
}
test "stripComments - strip buffered when option set" {
const allocator = std.testing.allocator;
const tokens = [_]Token{
.{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "div" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 4 } } },
.{ .type = .comment, .loc = .{ .start = .{ .line = 2, .column = 1 } }, .buffer = .{ .boolean = true } },
.{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 4 } }, .val = .{ .string = "buffered comment" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 2, .column = 20 } } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 3, .column = 1 } } },
};
var result = try stripComments(allocator, &tokens, .{ .strip_buffered = true });
defer result.deinit(allocator);
// Should strip buffered comment
try std.testing.expectEqual(@as(usize, 4), result.tokens.items.len);
}
test "stripComments - pipeless text in comment" {
const allocator = std.testing.allocator;
const tokens = [_]Token{
.{ .type = .comment, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .buffer = .{ .boolean = false } },
.{ .type = .start_pipeless_text, .loc = .{ .start = .{ .line = 1, .column = 1 } } },
.{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 3 } }, .val = .{ .string = "line 1" } },
.{ .type = .text, .loc = .{ .start = .{ .line = 3, .column = 3 } }, .val = .{ .string = "line 2" } },
.{ .type = .end_pipeless_text, .loc = .{ .start = .{ .line = 4, .column = 1 } } },
.{ .type = .tag, .loc = .{ .start = .{ .line = 5, .column = 1 } }, .val = .{ .string = "div" } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 6, .column = 1 } } },
};
var result = try stripComments(allocator, &tokens, .{});
defer result.deinit(allocator);
// Should strip everything in the comment including pipeless text
try std.testing.expectEqual(@as(usize, 2), result.tokens.items.len);
try std.testing.expectEqual(TokenType.tag, result.tokens.items[0].type);
try std.testing.expectEqual(TokenType.eos, result.tokens.items[1].type);
}
test "stripComments - pipeless text outside comment" {
const allocator = std.testing.allocator;
const tokens = [_]Token{
.{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "script" } },
.{ .type = .dot, .loc = .{ .start = .{ .line = 1, .column = 7 } } },
.{ .type = .start_pipeless_text, .loc = .{ .start = .{ .line = 1, .column = 8 } } },
.{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 3 } }, .val = .{ .string = "var x = 1;" } },
.{ .type = .end_pipeless_text, .loc = .{ .start = .{ .line = 3, .column = 1 } } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 4, .column = 1 } } },
};
var result = try stripComments(allocator, &tokens, .{});
defer result.deinit(allocator);
// Should keep all tokens - no comments
try std.testing.expectEqual(@as(usize, 6), result.tokens.items.len);
}
test "stripComments - keep unbuffered when option disabled" {
const allocator = std.testing.allocator;
const tokens = [_]Token{
.{ .type = .comment, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .buffer = .{ .boolean = false } },
.{ .type = .text, .loc = .{ .start = .{ .line = 1, .column = 4 } }, .val = .{ .string = "keep me" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 11 } } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 2, .column = 1 } } },
};
var result = try stripComments(allocator, &tokens, .{ .strip_unbuffered = false });
defer result.deinit(allocator);
// Should keep unbuffered comment
try std.testing.expectEqual(@as(usize, 4), result.tokens.items.len);
}
test "stripAllComments - strips both types" {
const allocator = std.testing.allocator;
const tokens = [_]Token{
.{ .type = .comment, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .buffer = .{ .boolean = false } },
.{ .type = .text, .loc = .{ .start = .{ .line = 1, .column = 4 } }, .val = .{ .string = "unbuffered" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 14 } } },
.{ .type = .comment, .loc = .{ .start = .{ .line = 2, .column = 1 } }, .buffer = .{ .boolean = true } },
.{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 4 } }, .val = .{ .string = "buffered" } },
.{ .type = .newline, .loc = .{ .start = .{ .line = 2, .column = 12 } } },
.{ .type = .tag, .loc = .{ .start = .{ .line = 3, .column = 1 } }, .val = .{ .string = "div" } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 4, .column = 1 } } },
};
var result = try stripAllComments(allocator, &tokens);
defer result.deinit(allocator);
// Should strip both comments, keep tag and structure
try std.testing.expectEqual(@as(usize, 4), result.tokens.items.len);
try std.testing.expectEqual(TokenType.newline, result.tokens.items[0].type);
try std.testing.expectEqual(TokenType.newline, result.tokens.items[1].type);
try std.testing.expectEqual(TokenType.tag, result.tokens.items[2].type);
try std.testing.expectEqual(TokenType.eos, result.tokens.items[3].type);
}

683
src/template.zig Normal file
View File

@@ -0,0 +1,683 @@
// template.zig - Runtime template rendering with data binding
//
// This module provides runtime data binding for Pug templates.
// It allows passing a Zig struct and rendering dynamic content.
// Reuses utilities from runtime.zig for escaping and attribute rendering.
const std = @import("std");
const Allocator = std.mem.Allocator;
const pug = @import("pug.zig");
const parser = @import("parser.zig");
const Node = parser.Node;
const runtime = @import("runtime.zig");
pub const TemplateError = error{
OutOfMemory,
LexerError,
ParserError,
};
/// Render context tracks state like doctype mode
pub const RenderContext = struct {
/// true = HTML5 terse mode (default), false = XHTML mode
terse: bool = true,
};
/// Render a template with data
pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) ![]const u8 {
// Lex
var lex = pug.lexer.Lexer.init(allocator, source, .{}) catch return error.OutOfMemory;
defer lex.deinit();
const tokens = lex.getTokens() catch return error.LexerError;
// Strip comments
var stripped = pug.strip_comments.stripComments(allocator, tokens, .{}) catch return error.OutOfMemory;
defer stripped.deinit(allocator);
// Parse
var parse = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source);
defer parse.deinit();
const ast = parse.parse() catch {
return error.ParserError;
};
defer {
ast.deinit(allocator);
allocator.destroy(ast);
}
// Render with data
var output = std.ArrayListUnmanaged(u8){};
errdefer output.deinit(allocator);
// Detect doctype to set terse mode
var ctx = RenderContext{};
detectDoctype(ast, &ctx);
try renderNode(allocator, &output, ast, data, &ctx);
return output.toOwnedSlice(allocator);
}
/// Scan AST for doctype and set terse mode accordingly
fn detectDoctype(node: *Node, ctx: *RenderContext) void {
if (node.type == .Doctype) {
if (node.val) |val| {
// XHTML doctypes use non-terse mode
if (std.mem.eql(u8, val, "xml") or
std.mem.eql(u8, val, "strict") or
std.mem.eql(u8, val, "transitional") or
std.mem.eql(u8, val, "frameset") or
std.mem.eql(u8, val, "1.1") or
std.mem.eql(u8, val, "basic") or
std.mem.eql(u8, val, "mobile"))
{
ctx.terse = false;
}
}
return;
}
// Check children
for (node.nodes.items) |child| {
detectDoctype(child, ctx);
if (!ctx.terse) return;
}
}
fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
switch (node.type) {
.Block, .NamedBlock => {
for (node.nodes.items) |child| {
try renderNode(allocator, output, child, data, ctx);
}
},
.Tag, .InterpolatedTag => try renderTag(allocator, output, node, data, ctx),
.Text => try renderText(allocator, output, node, data),
.Code => try renderCode(allocator, output, node, data, ctx),
.Comment => try renderComment(allocator, output, node),
.BlockComment => try renderBlockComment(allocator, output, node, data, ctx),
.Doctype => try renderDoctype(allocator, output, node),
.Each => try renderEach(allocator, output, node, data, ctx),
.Mixin => {
// Mixin definitions are skipped (only mixin calls render)
if (!node.call) return;
for (node.nodes.items) |child| {
try renderNode(allocator, output, child, data, ctx);
}
},
else => {
for (node.nodes.items) |child| {
try renderNode(allocator, output, child, data, ctx);
}
},
}
}
fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
const name = tag.name orelse "div";
try output.appendSlice(allocator, "<");
try output.appendSlice(allocator, name);
// Render attributes using runtime.attr()
for (tag.attrs.items) |attr| {
const attr_val = try evaluateAttrValue(allocator, attr.val, data);
const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) {
error.FormatError => return error.OutOfMemory,
error.OutOfMemory => return error.OutOfMemory,
};
defer allocator.free(attr_str);
try output.appendSlice(allocator, attr_str);
}
// Self-closing logic differs by mode:
// - HTML5 terse: void elements are self-closing without />
// - XHTML/XML: only explicit / makes tags self-closing
const is_void = isSelfClosing(name);
const is_self_closing = if (ctx.terse)
tag.self_closing or is_void
else
tag.self_closing;
if (is_self_closing and tag.nodes.items.len == 0 and tag.val == null) {
if (ctx.terse and !tag.self_closing) {
try output.appendSlice(allocator, ">");
} else {
try output.appendSlice(allocator, "/>");
}
return;
}
try output.appendSlice(allocator, ">");
// Render text content
if (tag.val) |val| {
try processInterpolation(allocator, output, val, false, data);
}
// Render children
for (tag.nodes.items) |child| {
try renderNode(allocator, output, child, data, ctx);
}
// Close tag
if (!is_self_closing) {
try output.appendSlice(allocator, "</");
try output.appendSlice(allocator, name);
try output.appendSlice(allocator, ">");
}
}
/// Evaluate attribute value from AST to runtime.AttrValue
fn evaluateAttrValue(allocator: Allocator, val: ?[]const u8, data: anytype) !runtime.AttrValue {
_ = allocator;
const v = val orelse return .{ .boolean = true }; // No value = boolean attribute
// Handle boolean literals
if (std.mem.eql(u8, v, "true")) return .{ .boolean = true };
if (std.mem.eql(u8, v, "false")) return .{ .boolean = false };
if (std.mem.eql(u8, v, "null") or std.mem.eql(u8, v, "undefined")) return .none;
// Quoted string - extract inner value
if (v.len >= 2 and (v[0] == '"' or v[0] == '\'')) {
return .{ .string = v[1 .. v.len - 1] };
}
// Expression - try to look up in data
if (getFieldValue(data, v)) |value| {
return .{ .string = value };
}
// Unknown expression - return as string literal
return .{ .string = v };
}
fn renderText(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), text: *Node, data: anytype) Allocator.Error!void {
if (text.val) |val| {
try processInterpolation(allocator, output, val, false, data);
}
}
fn renderCode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), code: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
if (code.buffer) {
if (code.val) |val| {
// Check if it's a string literal (quoted)
if (val.len >= 2 and (val[0] == '"' or val[0] == '\'')) {
const inner = val[1 .. val.len - 1];
if (code.must_escape) {
try runtime.appendEscaped(allocator, output, inner);
} else {
try output.appendSlice(allocator, inner);
}
} else if (getFieldValue(data, val)) |value| {
if (code.must_escape) {
try runtime.appendEscaped(allocator, output, value);
} else {
try output.appendSlice(allocator, value);
}
}
}
}
for (code.nodes.items) |child| {
try renderNode(allocator, output, child, data, ctx);
}
}
fn renderEach(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), each: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
const collection_name = each.obj orelse return;
const item_name = each.val orelse "item";
_ = item_name;
const T = @TypeOf(data);
const info = @typeInfo(T);
if (info != .@"struct") return;
inline for (info.@"struct".fields) |field| {
if (std.mem.eql(u8, field.name, collection_name)) {
const collection = @field(data, field.name);
const CollType = @TypeOf(collection);
const coll_info = @typeInfo(CollType);
if (coll_info == .pointer and coll_info.pointer.size == .slice) {
for (collection) |item| {
const ItemType = @TypeOf(item);
if (ItemType == []const u8) {
for (each.nodes.items) |child| {
try renderNodeWithItem(allocator, output, child, data, item, ctx);
}
} else {
for (each.nodes.items) |child| {
try renderNode(allocator, output, child, data, ctx);
}
}
}
return;
}
}
}
if (each.alternate) |alt| {
try renderNode(allocator, output, alt, data, ctx);
}
}
fn renderNodeWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, item: []const u8, ctx: *const RenderContext) Allocator.Error!void {
switch (node.type) {
.Block, .NamedBlock => {
for (node.nodes.items) |child| {
try renderNodeWithItem(allocator, output, child, data, item, ctx);
}
},
.Tag, .InterpolatedTag => try renderTagWithItem(allocator, output, node, data, item, ctx),
.Text => try renderTextWithItem(allocator, output, node, item),
.Code => {
if (node.buffer) {
if (node.must_escape) {
try runtime.appendEscaped(allocator, output, item);
} else {
try output.appendSlice(allocator, item);
}
}
},
else => {
for (node.nodes.items) |child| {
try renderNodeWithItem(allocator, output, child, data, item, ctx);
}
},
}
}
fn renderTagWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *Node, data: anytype, item: []const u8, ctx: *const RenderContext) Allocator.Error!void {
const name = tag.name orelse "div";
try output.appendSlice(allocator, "<");
try output.appendSlice(allocator, name);
// Render attributes using runtime.attr()
for (tag.attrs.items) |attr| {
const attr_val = try evaluateAttrValue(allocator, attr.val, data);
const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) {
error.FormatError => return error.OutOfMemory,
error.OutOfMemory => return error.OutOfMemory,
};
defer allocator.free(attr_str);
try output.appendSlice(allocator, attr_str);
}
const is_void = isSelfClosing(name);
const is_self_closing = if (ctx.terse)
tag.self_closing or is_void
else
tag.self_closing;
if (is_self_closing and tag.nodes.items.len == 0 and tag.val == null) {
if (ctx.terse and !tag.self_closing) {
try output.appendSlice(allocator, ">");
} else {
try output.appendSlice(allocator, "/>");
}
return;
}
try output.appendSlice(allocator, ">");
if (tag.val) |val| {
try processInterpolationWithItem(allocator, output, val, true, data, item);
}
for (tag.nodes.items) |child| {
try renderNodeWithItem(allocator, output, child, data, item, ctx);
}
if (!is_self_closing) {
try output.appendSlice(allocator, "</");
try output.appendSlice(allocator, name);
try output.appendSlice(allocator, ">");
}
}
fn renderTextWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), text: *Node, item: []const u8) Allocator.Error!void {
if (text.val) |val| {
try runtime.appendEscaped(allocator, output, val);
_ = item;
}
}
fn processInterpolationWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8, escape: bool, data: anytype, item: []const u8) Allocator.Error!void {
_ = data;
var i: usize = 0;
while (i < text.len) {
if (i + 1 < text.len and text[i] == '#' and text[i + 1] == '{') {
var j = i + 2;
var brace_count: usize = 1;
while (j < text.len and brace_count > 0) {
if (text[j] == '{') brace_count += 1;
if (text[j] == '}') brace_count -= 1;
j += 1;
}
if (brace_count == 0) {
if (escape) {
try runtime.appendEscaped(allocator, output, item);
} else {
try output.appendSlice(allocator, item);
}
i = j;
continue;
}
}
if (escape) {
if (runtime.escapeChar(text[i])) |esc| {
try output.appendSlice(allocator, esc);
} else {
try output.append(allocator, text[i]);
}
} else {
try output.append(allocator, text[i]);
}
i += 1;
}
}
fn renderComment(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), comment: *Node) Allocator.Error!void {
if (!comment.buffer) return;
try output.appendSlice(allocator, "<!--");
if (comment.val) |val| {
try output.appendSlice(allocator, val);
}
try output.appendSlice(allocator, "-->");
}
fn renderBlockComment(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), comment: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void {
if (!comment.buffer) return;
try output.appendSlice(allocator, "<!--");
if (comment.val) |val| {
try output.appendSlice(allocator, val);
}
for (comment.nodes.items) |child| {
try renderNode(allocator, output, child, data, ctx);
}
try output.appendSlice(allocator, "-->");
}
// Doctype mappings
const doctypes = 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\">" },
});
fn renderDoctype(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), doctype: *Node) Allocator.Error!void {
if (doctype.val) |val| {
if (doctypes.get(val)) |dt| {
try output.appendSlice(allocator, dt);
} else {
try output.appendSlice(allocator, "<!DOCTYPE ");
try output.appendSlice(allocator, val);
try output.appendSlice(allocator, ">");
}
} else {
try output.appendSlice(allocator, "<!DOCTYPE html>");
}
}
/// Process interpolation #{expr} in text
/// escape_quotes: true for attribute values (escape "), false for text content
fn processInterpolation(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8, escape_quotes: bool, data: anytype) Allocator.Error!void {
var i: usize = 0;
while (i < text.len) {
if (i + 1 < text.len and text[i] == '#' and text[i + 1] == '{') {
var j = i + 2;
var brace_count: usize = 1;
while (j < text.len and brace_count > 0) {
if (text[j] == '{') brace_count += 1;
if (text[j] == '}') brace_count -= 1;
j += 1;
}
if (brace_count == 0) {
const expr = std.mem.trim(u8, text[i + 2 .. j - 1], " \t");
if (getFieldValue(data, expr)) |value| {
if (escape_quotes) {
try runtime.appendEscaped(allocator, output, value);
} else {
// Text content: escape < > & but not quotes
try appendTextEscaped(allocator, output, value);
}
}
i = j;
continue;
}
}
// Regular character - use appropriate escaping
const c = text[i];
if (escape_quotes) {
if (runtime.escapeChar(c)) |esc| {
try output.appendSlice(allocator, esc);
} else {
try output.append(allocator, c);
}
} else {
// Text content: escape < > & but not quotes, preserve HTML entities
switch (c) {
'<' => try output.appendSlice(allocator, "&lt;"),
'>' => try output.appendSlice(allocator, "&gt;"),
'&' => {
if (isHtmlEntity(text[i..])) {
try output.append(allocator, c);
} else {
try output.appendSlice(allocator, "&amp;");
}
},
else => try output.append(allocator, c),
}
}
i += 1;
}
}
/// Get a field value from the data struct by name
fn getFieldValue(data: anytype, name: []const u8) ?[]const u8 {
const T = @TypeOf(data);
const info = @typeInfo(T);
if (info != .@"struct") return null;
inline for (info.@"struct".fields) |field| {
if (std.mem.eql(u8, field.name, name)) {
const value = @field(data, field.name);
const ValueType = @TypeOf(value);
if (ValueType == []const u8) {
return value;
}
const value_info = @typeInfo(ValueType);
if (value_info == .pointer) {
const ptr = value_info.pointer;
if (ptr.size == .one) {
const child_info = @typeInfo(ptr.child);
if (child_info == .array and child_info.array.child == u8) {
return value;
}
}
}
}
}
return null;
}
/// Escape for text content - escapes < > & (NOT quotes)
/// Preserves existing HTML entities like &#8217;
fn appendTextEscaped(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), str: []const u8) Allocator.Error!void {
var i: usize = 0;
while (i < str.len) {
const c = str[i];
switch (c) {
'<' => try output.appendSlice(allocator, "&lt;"),
'>' => try output.appendSlice(allocator, "&gt;"),
'&' => {
if (isHtmlEntity(str[i..])) {
try output.append(allocator, c);
} else {
try output.appendSlice(allocator, "&amp;");
}
},
else => try output.append(allocator, c),
}
i += 1;
}
}
/// Check if string starts with a valid HTML entity
fn isHtmlEntity(str: []const u8) bool {
if (str.len < 3 or str[0] != '&') return false;
var i: usize = 1;
// Numeric entity: &#digits; or &#xhex;
if (str[i] == '#') {
i += 1;
if (i >= str.len) return false;
if (str[i] == 'x' or str[i] == 'X') {
i += 1;
if (i >= str.len) return false;
var has_hex = false;
while (i < str.len and i < 10) : (i += 1) {
const ch = str[i];
if (ch == ';') return has_hex;
if ((ch >= '0' and ch <= '9') or
(ch >= 'a' and ch <= 'f') or
(ch >= 'A' and ch <= 'F'))
{
has_hex = true;
} else {
return false;
}
}
return false;
}
var has_digit = false;
while (i < str.len and i < 10) : (i += 1) {
const ch = str[i];
if (ch == ';') return has_digit;
if (ch >= '0' and ch <= '9') {
has_digit = true;
} else {
return false;
}
}
return false;
}
// Named entity: &name;
var has_alpha = false;
while (i < str.len and i < 32) : (i += 1) {
const ch = str[i];
if (ch == ';') return has_alpha;
if ((ch >= 'a' and ch <= 'z') or (ch >= 'A' and ch <= 'Z') or (ch >= '0' and ch <= '9')) {
has_alpha = true;
} else {
return false;
}
}
return false;
}
fn isSelfClosing(name: []const u8) bool {
const self_closing_tags = [_][]const u8{
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
};
for (self_closing_tags) |tag| {
if (std.mem.eql(u8, name, tag)) return true;
}
return false;
}
// ============================================================================
// Tests
// ============================================================================
test "simple interpolation" {
const allocator = std.testing.allocator;
const html = try renderWithData(allocator, "p Hello, #{name}!", .{ .name = "World" });
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>Hello, World!</p>", html);
}
test "multiple interpolations" {
const allocator = std.testing.allocator;
const html = try renderWithData(allocator, "p #{greeting}, #{name}!", .{
.greeting = "Hello",
.name = "World",
});
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>Hello, World!</p>", html);
}
test "attribute with data" {
const allocator = std.testing.allocator;
const html = try renderWithData(allocator, "a(href=url) Click", .{ .url = "/home" });
defer allocator.free(html);
try std.testing.expectEqualStrings("<a href=\"/home\">Click</a>", html);
}
test "buffered code" {
const allocator = std.testing.allocator;
const html = try renderWithData(allocator, "p= message", .{ .message = "Hello" });
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>Hello</p>", html);
}
test "escape html" {
const allocator = std.testing.allocator;
const html = try renderWithData(allocator, "p #{content}", .{ .content = "<b>bold</b>" });
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>&lt;b&gt;bold&lt;/b&gt;</p>", html);
}
test "no data - static template" {
const allocator = std.testing.allocator;
const html = try renderWithData(allocator, "p Hello, World!", .{});
defer allocator.free(html);
try std.testing.expectEqualStrings("<p>Hello, World!</p>", html);
}
test "nested tags with data" {
const allocator = std.testing.allocator;
const html = try renderWithData(allocator,
\\div
\\ h1 #{title}
\\ p #{body}
, .{
.title = "Welcome",
.body = "Hello there!",
});
defer allocator.free(html);
try std.testing.expectEqualStrings("<div><h1>Welcome</h1><p>Hello there!</p></div>", html);
}

View File

@@ -0,0 +1,301 @@
'use strict';
var assert = require('assert');
var utils = require('util');
var attrs = require('../');
var options;
function test(input, expected, locals) {
var opts = options;
locals = locals || {};
locals.pug = locals.pug || require('pug-runtime');
it(
utils.inspect(input).replace(/\n/g, '') + ' => ' + utils.inspect(expected),
function() {
var src = attrs(input, opts);
var localKeys = Object.keys(locals).sort();
var output = Function(
localKeys.join(', '),
'return (' + src + ');'
).apply(
null,
localKeys.map(function(key) {
return locals[key];
})
);
if (opts.format === 'html') {
expect(output).toBe(expected);
} else {
expect(output).toEqual(expected);
}
}
);
}
function withOptions(opts, fn) {
describe('options: ' + utils.inspect(opts), function() {
options = opts;
fn();
});
}
withOptions(
{
terse: true,
format: 'html',
runtime: function(name) {
return 'pug.' + name;
},
},
function() {
test([], '');
test([{name: 'foo', val: 'false', mustEscape: true}], '');
test([{name: 'foo', val: 'true', mustEscape: true}], ' foo');
test([{name: 'foo', val: false, mustEscape: true}], '');
test([{name: 'foo', val: true, mustEscape: true}], ' foo');
test([{name: 'foo', val: 'foo', mustEscape: true}], '', {foo: false});
test([{name: 'foo', val: 'foo', mustEscape: true}], ' foo', {foo: true});
test([{name: 'foo', val: '"foo"', mustEscape: true}], ' foo="foo"');
test(
[
{name: 'foo', val: '"foo"', mustEscape: true},
{name: 'bar', val: '"bar"', mustEscape: true},
],
' foo="foo" bar="bar"'
);
test([{name: 'foo', val: 'foo', mustEscape: true}], ' foo="fooo"', {
foo: 'fooo',
});
test(
[
{name: 'foo', val: 'foo', mustEscape: true},
{name: 'bar', val: 'bar', mustEscape: true},
],
' foo="fooo" bar="baro"',
{foo: 'fooo', bar: 'baro'}
);
test(
[{name: 'style', val: '{color: "red"}', mustEscape: true}],
' style="color:red;"'
);
test(
[{name: 'style', val: '{color: color}', mustEscape: true}],
' style="color:red;"',
{color: 'red'}
);
test(
[
{name: 'class', val: '"foo"', mustEscape: true},
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
],
' class="foo bar baz"'
);
test(
[
{name: 'class', val: '{foo: foo}', mustEscape: true},
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
],
' class="foo bar baz"',
{foo: true}
);
test(
[
{name: 'class', val: '{foo: foo}', mustEscape: true},
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
],
' class="bar baz"',
{foo: false}
);
test(
[
{name: 'class', val: 'foo', mustEscape: true},
{name: 'class', val: '"<str>"', mustEscape: true},
],
' class="&lt;foo&gt; &lt;str&gt;"',
{foo: '<foo>'}
);
test(
[
{name: 'foo', val: '"foo"', mustEscape: true},
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
],
' class="bar baz" foo="foo"'
);
test(
[
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
{name: 'foo', val: '"foo"', mustEscape: true},
],
' class="bar baz" foo="foo"'
);
test([{name: 'foo', val: '"<foo>"', mustEscape: false}], ' foo="<foo>"');
test(
[{name: 'foo', val: '"<foo>"', mustEscape: true}],
' foo="&lt;foo&gt;"'
);
test([{name: 'foo', val: 'foo', mustEscape: false}], ' foo="<foo>"', {
foo: '<foo>',
});
test([{name: 'foo', val: 'foo', mustEscape: true}], ' foo="&lt;foo&gt;"', {
foo: '<foo>',
});
}
);
withOptions(
{
terse: false,
format: 'html',
runtime: function(name) {
return 'pug.' + name;
},
},
function() {
test([{name: 'foo', val: 'false', mustEscape: true}], '');
test([{name: 'foo', val: 'true', mustEscape: true}], ' foo="foo"');
test([{name: 'foo', val: false, mustEscape: true}], '');
test([{name: 'foo', val: true, mustEscape: true}], ' foo="foo"');
test([{name: 'foo', val: 'foo', mustEscape: true}], '', {foo: false});
test([{name: 'foo', val: 'foo', mustEscape: true}], ' foo="foo"', {
foo: true,
});
}
);
withOptions(
{
terse: true,
format: 'object',
runtime: function(name) {
return 'pug.' + name;
},
},
function() {
test([], {});
test([{name: 'foo', val: 'false', mustEscape: true}], {foo: false});
test([{name: 'foo', val: 'true', mustEscape: true}], {foo: true});
test([{name: 'foo', val: false, mustEscape: true}], {foo: false});
test([{name: 'foo', val: true, mustEscape: true}], {foo: true});
test(
[{name: 'foo', val: 'foo', mustEscape: true}],
{foo: false},
{foo: false}
);
test(
[{name: 'foo', val: 'foo', mustEscape: true}],
{foo: true},
{foo: true}
);
test([{name: 'foo', val: '"foo"', mustEscape: true}], {foo: 'foo'});
test(
[
{name: 'foo', val: '"foo"', mustEscape: true},
{name: 'bar', val: '"bar"', mustEscape: true},
],
{foo: 'foo', bar: 'bar'}
);
test(
[{name: 'foo', val: 'foo', mustEscape: true}],
{foo: 'fooo'},
{foo: 'fooo'}
);
test(
[
{name: 'foo', val: 'foo', mustEscape: true},
{name: 'bar', val: 'bar', mustEscape: true},
],
{foo: 'fooo', bar: 'baro'},
{foo: 'fooo', bar: 'baro'}
);
test([{name: 'style', val: '{color: "red"}', mustEscape: true}], {
style: 'color:red;',
});
test(
[{name: 'style', val: '{color: color}', mustEscape: true}],
{style: 'color:red;'},
{color: 'red'}
);
test(
[
{name: 'class', val: '"foo"', mustEscape: true},
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
],
{class: 'foo bar baz'}
);
test(
[
{name: 'class', val: '{foo: foo}', mustEscape: true},
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
],
{class: 'foo bar baz'},
{foo: true}
);
test(
[
{name: 'class', val: '{foo: foo}', mustEscape: true},
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
],
{class: 'bar baz'},
{foo: false}
);
test(
[
{name: 'class', val: 'foo', mustEscape: true},
{name: 'class', val: '"<str>"', mustEscape: true},
],
{class: '&lt;foo&gt; &lt;str&gt;'},
{foo: '<foo>'}
);
test(
[
{name: 'foo', val: '"foo"', mustEscape: true},
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
],
{class: 'bar baz', foo: 'foo'}
);
test(
[
{name: 'class', val: '["bar", "baz"]', mustEscape: true},
{name: 'foo', val: '"foo"', mustEscape: true},
],
{class: 'bar baz', foo: 'foo'}
);
test([{name: 'foo', val: '"<foo>"', mustEscape: false}], {foo: '<foo>'});
test([{name: 'foo', val: '"<foo>"', mustEscape: true}], {
foo: '&lt;foo&gt;',
});
test(
[{name: 'foo', val: 'foo', mustEscape: false}],
{foo: '<foo>'},
{foo: '<foo>'}
);
test(
[{name: 'foo', val: 'foo', mustEscape: true}],
{foo: '&lt;foo&gt;'},
{foo: '<foo>'}
);
}
);
withOptions(
{
terse: false,
format: 'object',
runtime: function(name) {
return 'pug.' + name;
},
},
function() {
test([{name: 'foo', val: 'false', mustEscape: true}], {foo: false});
test([{name: 'foo', val: 'true', mustEscape: true}], {foo: true});
test([{name: 'foo', val: false, mustEscape: true}], {foo: false});
test([{name: 'foo', val: true, mustEscape: true}], {foo: true});
test(
[{name: 'foo', val: 'foo', mustEscape: true}],
{foo: false},
{foo: false}
);
test(
[{name: 'foo', val: 'foo', mustEscape: true}],
{foo: true},
{foo: true}
);
}
);

View File

@@ -0,0 +1,284 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`filters can be aliased 1`] = `
Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 0,
"nodes": Array [
Object {
"attributeBlocks": Array [],
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 2,
"nodes": Array [
Object {
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 3,
"nodes": Array [
Object {
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 3,
"nodes": Array [
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 4,
"type": "Text",
"val": "function myFunc(foo) {",
},
Object {
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 5,
"type": "Text",
"val": "
",
},
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 5,
"type": "Text",
"val": " return foo;",
},
Object {
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 6,
"type": "Text",
"val": "
",
},
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 6,
"type": "Text",
"val": "}",
},
],
"type": "Block",
},
"column": 9,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 3,
"name": "minify",
"type": "Text",
"val": "function myFunc(n) {
return n;
}
",
},
],
"type": "Block",
},
"column": 3,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 3,
"name": "cdata",
"type": "Text",
"val": "<![CDATA[function myFunc(n){return n}]]>",
},
],
"type": "Block",
},
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"isInline": false,
"line": 2,
"name": "script",
"selfClosing": false,
"type": "Tag",
},
],
"type": "Block",
}
`;
exports[`options are applied before aliases 1`] = `
Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 0,
"nodes": Array [
Object {
"attributeBlocks": Array [],
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 2,
"nodes": Array [
Object {
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 3,
"nodes": Array [
Object {
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 3,
"nodes": Array [
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 4,
"type": "Text",
"val": "function myFunc(foo) {",
},
Object {
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 5,
"type": "Text",
"val": "
",
},
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 5,
"type": "Text",
"val": " return foo;",
},
Object {
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 6,
"type": "Text",
"val": "
",
},
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 6,
"type": "Text",
"val": "}",
},
],
"type": "Block",
},
"column": 9,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 3,
"name": "minify",
"type": "Text",
"val": "function myFunc(n) {
return n;
}
",
},
],
"type": "Block",
},
"column": 3,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 3,
"name": "cdata",
"type": "Text",
"val": "<![CDATA[function myFunc(n) {
return n;
}]]>",
},
Object {
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 7,
"nodes": Array [
Object {
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 7,
"nodes": Array [
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 8,
"type": "Text",
"val": "function myFunc(foo) {",
},
Object {
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 9,
"type": "Text",
"val": "
",
},
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 9,
"type": "Text",
"val": " return foo;",
},
Object {
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 10,
"type": "Text",
"val": "
",
},
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 10,
"type": "Text",
"val": "}",
},
],
"type": "Block",
},
"column": 9,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 7,
"name": "uglify-js",
"type": "Text",
"val": "function myFunc(n) {
return n;
}
",
},
],
"type": "Block",
},
"column": 3,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"line": 7,
"name": "cdata",
"type": "Text",
"val": "<![CDATA[function myFunc(n){return n}]]>",
},
],
"type": "Block",
},
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/filter-aliases.test.js",
"isInline": false,
"line": 2,
"name": "script",
"selfClosing": false,
"type": "Tag",
},
],
"type": "Block",
}
`;
exports[`we do not support chains of aliases 1`] = `
Object {
"code": "PUG:FILTER_ALISE_CHAIN",
"message": "<basedir>/packages/pug-filters/test/filter-aliases.test.js:3:9
The filter \\"minify-js\\" is an alias for \\"minify\\", which is an alias for \\"uglify-js\\". Pug does not support chains of filter aliases.",
}
`;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`per filter options are applied, even to nested filters 1`] = `
Object {
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 0,
"nodes": Array [
Object {
"attributeBlocks": Array [],
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 2,
"nodes": Array [
Object {
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 3,
"nodes": Array [
Object {
"attrs": Array [],
"block": Object {
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 3,
"nodes": Array [
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 4,
"type": "Text",
"val": "function myFunc(foo) {",
},
Object {
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 5,
"type": "Text",
"val": "
",
},
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 5,
"type": "Text",
"val": " return foo;",
},
Object {
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 6,
"type": "Text",
"val": "
",
},
Object {
"column": 5,
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 6,
"type": "Text",
"val": "}",
},
],
"type": "Block",
},
"column": 9,
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 3,
"name": "uglify-js",
"type": "Text",
"val": "function myFunc(n) {
return n;
}
",
},
],
"type": "Block",
},
"column": 3,
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"line": 3,
"name": "cdata",
"type": "Text",
"val": "<![CDATA[function myFunc(n) {
return n;
}]]>",
},
],
"type": "Block",
},
"column": 1,
"filename": "<basedir>/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js",
"isInline": false,
"line": 2,
"name": "script",
"selfClosing": false,
"type": "Tag",
},
],
"type": "Block",
}
`;

View File

@@ -0,0 +1,84 @@
{
"type": "Block",
"nodes": [
{
"type": "Code",
"val": "var users = [{ name: 'tobi', age: 2 }]",
"buffer": false,
"mustEscape": false,
"isInline": false,
"line": 1,
"filename": "filters-empty.tokens.json"
},
{
"type": "Tag",
"name": "fb:users",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Each",
"obj": "users",
"val": "user",
"key": null,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "fb:user",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "cdata",
"block": {
"type": "Block",
"nodes": [],
"line": 6,
"filename": "filters-empty.tokens.json"
},
"attrs": [],
"line": 6,
"filename": "filters-empty.tokens.json"
}
],
"line": 5,
"filename": "filters-empty.tokens.json"
},
"attrs": [
{
"name": "age",
"val": "user.age",
"mustEscape": true
}
],
"attributeBlocks": [],
"isInline": false,
"line": 5,
"filename": "filters-empty.tokens.json"
}
],
"line": 5,
"filename": "filters-empty.tokens.json"
},
"line": 4,
"filename": "filters-empty.tokens.json"
}
],
"line": 3,
"filename": "filters-empty.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 3,
"filename": "filters-empty.tokens.json"
}
],
"line": 0,
"filename": "filters-empty.tokens.json"
}

View File

@@ -0,0 +1,83 @@
{
"type": "Block",
"nodes": [
{
"type": "Code",
"val": "users = [{ name: 'tobi', age: 2 }]",
"buffer": false,
"mustEscape": false,
"isInline": false,
"line": 2,
"filename": "filters.cdata.tokens.json"
},
{
"type": "Tag",
"name": "fb:users",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Each",
"obj": "users",
"val": "user",
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "fb:user",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "cdata",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "#{user.name}",
"line": 8
}
]
},
"attrs": [],
"line": 7,
"filename": "filters.cdata.tokens.json"
}
]
},
"attrs": [
{
"name": "age",
"val": "user.age",
"mustEscape": true
}
],
"attributeBlocks": [],
"isInline": false,
"line": 6,
"filename": "filters.cdata.tokens.json"
}
],
"line": 6,
"filename": "filters.cdata.tokens.json"
},
"line": 5,
"filename": "filters.cdata.tokens.json"
}
]
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 4,
"filename": "filters.cdata.tokens.json"
}
],
"line": 0,
"filename": "filters.cdata.tokens.json"
}

View File

@@ -0,0 +1,84 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "script",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "coffee-script",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "regexp = /\\n/",
"line": 3
}
],
"line": 2,
"filename": "filters.coffeescript.tokens.json"
},
"attrs": [],
"line": 2,
"filename": "filters.coffeescript.tokens.json"
},
{
"type": "Filter",
"name": "coffee-script",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "math =",
"line": 5
},
{
"type": "Text",
"val": "\n",
"line": 6
},
{
"type": "Text",
"val": " square: (value) -> value * value",
"line": 6
}
],
"line": 4,
"filename": "filters.coffeescript.tokens.json"
},
"attrs": [
{
"name": "minify",
"val": "true",
"mustEscape": true
}
],
"line": 4,
"filename": "filters.coffeescript.tokens.json"
}
],
"line": 1,
"filename": "filters.coffeescript.tokens.json"
},
"attrs": [
{
"name": "type",
"val": "'text/javascript'",
"mustEscape": true
}
],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.coffeescript.tokens.json"
}
],
"line": 0,
"filename": "filters.coffeescript.tokens.json"
}

View File

@@ -0,0 +1,101 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "html",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "body",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "custom",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "Line 1",
"line": 4
},
{
"type": "Text",
"val": "\n",
"line": 5
},
{
"type": "Text",
"val": "Line 2",
"line": 5
},
{
"type": "Text",
"val": "\n",
"line": 6
},
{
"type": "Text",
"val": "",
"line": 6
},
{
"type": "Text",
"val": "\n",
"line": 7
},
{
"type": "Text",
"val": "Line 4",
"line": 7
}
],
"line": 3,
"filename": "filters.custom.tokens.json"
},
"attrs": [
{
"name": "opt",
"val": "'val'",
"mustEscape": true
},
{
"name": "num",
"val": "2",
"mustEscape": true
}
],
"line": 3,
"filename": "filters.custom.tokens.json"
}
],
"line": 2,
"filename": "filters.custom.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 2,
"filename": "filters.custom.tokens.json"
}
],
"line": 1,
"filename": "filters.custom.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.custom.tokens.json"
}
],
"line": 0,
"filename": "filters.custom.tokens.json"
}

View File

@@ -0,0 +1,91 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "html",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "body",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "pre",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "RawInclude",
"file": {
"type": "FileReference",
"line": 4,
"filename": "filters.include.custom.tokens.json",
"path": "filters.include.custom.pug",
"fullPath": "test/cases/filters.include.custom.pug",
"str": "html\n body\n pre\n include:custom(opt='val' num=2) filters.include.custom.pug\n"
},
"line": 4,
"filename": "filters.include.custom.tokens.json",
"filters": [
{
"type": "IncludeFilter",
"name": "custom",
"attrs": [
{
"name": "opt",
"val": "'val'",
"mustEscape": true
},
{
"name": "num",
"val": "2",
"mustEscape": true
}
],
"line": 4,
"filename": "filters.include.custom.tokens.json"
}
]
}
],
"line": 3,
"filename": "filters.include.custom.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 3,
"filename": "filters.include.custom.tokens.json"
}
],
"line": 2,
"filename": "filters.include.custom.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 2,
"filename": "filters.include.custom.tokens.json"
}
],
"line": 1,
"filename": "filters.include.custom.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.include.custom.tokens.json"
}
],
"line": 0,
"filename": "filters.include.custom.tokens.json"
}

View File

@@ -0,0 +1,4 @@
html
body
pre
include:custom(opt='val' num=2) filters.include.custom.pug

View File

@@ -0,0 +1,160 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "html",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "body",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "RawInclude",
"file": {
"type": "FileReference",
"line": 3,
"filename": "filters.include.tokens.json",
"path": "some.md",
"fullPath": "test/cases/some.md",
"str": "Just _some_ markdown **tests**.\n\nWith new line.\n"
},
"line": 3,
"filename": "filters.include.tokens.json",
"filters": [
{
"type": "IncludeFilter",
"name": "markdown-it",
"attrs": [],
"line": 3,
"filename": "filters.include.tokens.json"
}
]
},
{
"type": "Tag",
"name": "script",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "RawInclude",
"file": {
"type": "FileReference",
"line": 5,
"filename": "filters.include.tokens.json",
"path": "include-filter-coffee.coffee",
"fullPath": "test/cases/include-filter-coffee.coffee",
"str": "math =\n square: (value) -> value * value\n"
},
"line": 5,
"filename": "filters.include.tokens.json",
"filters": [
{
"type": "IncludeFilter",
"name": "coffee-script",
"attrs": [
{
"name": "minify",
"val": "true",
"mustEscape": true
}
],
"line": 5,
"filename": "filters.include.tokens.json"
}
]
}
],
"line": 4,
"filename": "filters.include.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 4,
"filename": "filters.include.tokens.json"
},
{
"type": "Tag",
"name": "script",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "RawInclude",
"file": {
"type": "FileReference",
"line": 7,
"filename": "filters.include.tokens.json",
"path": "include-filter-coffee.coffee",
"fullPath": "test/cases/include-filter-coffee.coffee",
"str": "math =\n square: (value) -> value * value\n"
},
"line": 7,
"filename": "filters.include.tokens.json",
"filters": [
{
"type": "IncludeFilter",
"name": "cdata",
"attrs": [],
"line": 7,
"filename": "filters.include.tokens.json"
},
{
"type": "IncludeFilter",
"name": "coffee-script",
"attrs": [
{
"name": "minify",
"val": "false",
"mustEscape": true
}
],
"line": 7,
"filename": "filters.include.tokens.json"
}
]
}
],
"line": 6,
"filename": "filters.include.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 6,
"filename": "filters.include.tokens.json"
}
],
"line": 2,
"filename": "filters.include.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 2,
"filename": "filters.include.tokens.json"
}
],
"line": 1,
"filename": "filters.include.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.include.tokens.json"
}
],
"line": 0,
"filename": "filters.include.tokens.json"
}

View File

@@ -0,0 +1,56 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "p",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "before ",
"line": 1,
"filename": "filters.inline.tokens.json"
},
{
"type": "Filter",
"name": "cdata",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "inside",
"line": 1,
"filename": "filters.inline.tokens.json"
}
],
"line": 1,
"filename": "filters.inline.tokens.json"
},
"attrs": [],
"line": 1,
"filename": "filters.inline.tokens.json"
},
{
"type": "Text",
"val": " after",
"line": 1,
"filename": "filters.inline.tokens.json"
}
],
"line": 1,
"filename": "filters.inline.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.inline.tokens.json"
}
],
"line": 0,
"filename": "filters.inline.tokens.json"
}

View File

@@ -0,0 +1,113 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "html",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "head",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "style",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "less",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "@pad: 15px;",
"line": 5
},
{
"type": "Text",
"val": "\n",
"line": 6
},
{
"type": "Text",
"val": "body {",
"line": 6
},
{
"type": "Text",
"val": "\n",
"line": 7
},
{
"type": "Text",
"val": " padding: @pad;",
"line": 7
},
{
"type": "Text",
"val": "\n",
"line": 8
},
{
"type": "Text",
"val": "}",
"line": 8
}
],
"line": 4,
"filename": "filters.less.tokens.json"
},
"attrs": [],
"line": 4,
"filename": "filters.less.tokens.json"
}
],
"line": 3,
"filename": "filters.less.tokens.json"
},
"attrs": [
{
"name": "type",
"val": "\"text/css\"",
"mustEscape": true
}
],
"attributeBlocks": [],
"isInline": false,
"line": 3,
"filename": "filters.less.tokens.json"
}
],
"line": 2,
"filename": "filters.less.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 2,
"filename": "filters.less.tokens.json"
}
],
"line": 1,
"filename": "filters.less.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.less.tokens.json"
}
],
"line": 0,
"filename": "filters.less.tokens.json"
}

View File

@@ -0,0 +1,70 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "html",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "body",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "markdown-it",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "This is _some_ awesome **markdown**",
"line": 4
},
{
"type": "Text",
"val": "\n",
"line": 5
},
{
"type": "Text",
"val": "whoop.",
"line": 5
}
],
"line": 3,
"filename": "filters.markdown.tokens.json"
},
"attrs": [],
"line": 3,
"filename": "filters.markdown.tokens.json"
}
],
"line": 2,
"filename": "filters.markdown.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 2,
"filename": "filters.markdown.tokens.json"
}
],
"line": 1,
"filename": "filters.markdown.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.markdown.tokens.json"
}
],
"line": 0,
"filename": "filters.markdown.tokens.json"
}

View File

@@ -0,0 +1,161 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "script",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "cdata",
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "uglify-js",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "(function() {",
"line": 3
},
{
"type": "Text",
"val": "\n",
"line": 4
},
{
"type": "Text",
"val": " console.log('test')",
"line": 4
},
{
"type": "Text",
"val": "\n",
"line": 5
},
{
"type": "Text",
"val": "})()",
"line": 5
}
],
"line": 2,
"filename": "filters.nested.tokens.json"
},
"attrs": [],
"line": 2,
"filename": "filters.nested.tokens.json"
}
],
"line": 2,
"filename": "filters.nested.tokens.json"
},
"attrs": [],
"line": 2,
"filename": "filters.nested.tokens.json"
}
],
"line": 1,
"filename": "filters.nested.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.nested.tokens.json"
},
{
"type": "Tag",
"name": "script",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "cdata",
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "uglify-js",
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "coffee-script",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "(->",
"line": 8
},
{
"type": "Text",
"val": "\n",
"line": 9
},
{
"type": "Text",
"val": " console.log 'test'",
"line": 9
},
{
"type": "Text",
"val": "\n",
"line": 10
},
{
"type": "Text",
"val": ")()",
"line": 10
}
],
"line": 7,
"filename": "filters.nested.tokens.json"
},
"attrs": [],
"line": 7,
"filename": "filters.nested.tokens.json"
}
],
"line": 7,
"filename": "filters.nested.tokens.json"
},
"attrs": [],
"line": 7,
"filename": "filters.nested.tokens.json"
}
],
"line": 7,
"filename": "filters.nested.tokens.json"
},
"attrs": [],
"line": 7,
"filename": "filters.nested.tokens.json"
}
],
"line": 6,
"filename": "filters.nested.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 6,
"filename": "filters.nested.tokens.json"
}
],
"line": 0,
"filename": "filters.nested.tokens.json"
}

View File

@@ -0,0 +1,109 @@
{
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "html",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "head",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Tag",
"name": "style",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [
{
"type": "Filter",
"name": "stylus",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "body",
"line": 5
},
{
"type": "Text",
"val": "\n",
"line": 6
},
{
"type": "Text",
"val": " padding: 50px",
"line": 6
}
],
"line": 4,
"filename": "filters.stylus.tokens.json"
},
"attrs": [],
"line": 4,
"filename": "filters.stylus.tokens.json"
}
],
"line": 3,
"filename": "filters.stylus.tokens.json"
},
"attrs": [
{
"name": "type",
"val": "\"text/css\"",
"mustEscape": true
}
],
"attributeBlocks": [],
"isInline": false,
"line": 3,
"filename": "filters.stylus.tokens.json"
}
],
"line": 2,
"filename": "filters.stylus.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 2,
"filename": "filters.stylus.tokens.json"
},
{
"type": "Tag",
"name": "body",
"selfClosing": false,
"block": {
"type": "Block",
"nodes": [],
"line": 7,
"filename": "filters.stylus.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 7,
"filename": "filters.stylus.tokens.json"
}
],
"line": 1,
"filename": "filters.stylus.tokens.json"
},
"attrs": [],
"attributeBlocks": [],
"isInline": false,
"line": 1,
"filename": "filters.stylus.tokens.json"
}
],
"line": 0,
"filename": "filters.stylus.tokens.json"
}

View File

@@ -0,0 +1,2 @@
math =
square: (value) -> value * value

View File

@@ -0,0 +1,3 @@
Just _some_ markdown **tests**.
With new line.

View File

@@ -0,0 +1,9 @@
var assert = require('assert');
module.exports = {
custom: function(str, options) {
expect(options.opt).toBe('val');
expect(options.num).toBe(2);
return 'BEGIN' + str + 'END';
},
};

View File

@@ -0,0 +1,3 @@
- var opt = 'a'
:cdata(option=opt)
hey

View File

@@ -0,0 +1,37 @@
{
"type": "Block",
"nodes": [
{
"type": "Code",
"val": "var opt = 'a'",
"buffer": false,
"escape": false,
"isInline": false,
"line": 1
},
{
"type": "Filter",
"name": "cdata",
"block": {
"type": "Block",
"nodes": [
{
"type": "Text",
"val": "hey",
"line": 3
}
],
"line": 2
},
"attrs": [
{
"name": "option",
"val": "opt",
"escaped": true
}
],
"line": 2
}
],
"line": 0
}

View File

@@ -0,0 +1,88 @@
const lex = require('pug-lexer');
const parse = require('pug-parser');
const handleFilters = require('../').handleFilters;
const customFilters = {};
test('filters can be aliased', () => {
const source = `
script
:cdata:minify
function myFunc(foo) {
return foo;
}
`;
const ast = parse(lex(source, {filename: __filename}), {
filename: __filename,
src: source,
});
const options = {};
const aliases = {
minify: 'uglify-js',
};
const output = handleFilters(ast, customFilters, options, aliases);
expect(output).toMatchSnapshot();
});
test('we do not support chains of aliases', () => {
const source = `
script
:cdata:minify-js
function myFunc(foo) {
return foo;
}
`;
const ast = parse(lex(source, {filename: __filename}), {
filename: __filename,
src: source,
});
const options = {};
const aliases = {
'minify-js': 'minify',
minify: 'uglify-js',
};
try {
const output = handleFilters(ast, customFilters, options, aliases);
} catch (ex) {
expect({
code: ex.code,
message: ex.message,
}).toMatchSnapshot();
return;
}
throw new Error('Expected an exception');
});
test('options are applied before aliases', () => {
const source = `
script
:cdata:minify
function myFunc(foo) {
return foo;
}
:cdata:uglify-js
function myFunc(foo) {
return foo;
}
`;
const ast = parse(lex(source, {filename: __filename}), {
filename: __filename,
src: source,
});
const options = {
minify: {output: {beautify: true}},
};
const aliases = {
minify: 'uglify-js',
};
const output = handleFilters(ast, customFilters, options, aliases);
expect(output).toMatchSnapshot();
});

View File

@@ -0,0 +1,55 @@
'use strict';
var fs = require('fs');
var assert = require('assert');
var handleFilters = require('../').handleFilters;
var customFilters = require('./custom-filters.js');
process.chdir(__dirname + '/../');
var testCases;
testCases = fs.readdirSync(__dirname + '/cases').filter(function(name) {
return /\.input\.json$/.test(name);
});
//
testCases.forEach(function(filename) {
function read(path) {
return fs.readFileSync(__dirname + '/cases/' + path, 'utf8');
}
test('cases/' + filename, function() {
var actualAst = JSON.stringify(
handleFilters(JSON.parse(read(filename)), customFilters),
null,
' '
);
expect(actualAst).toMatchSnapshot();
});
});
testCases = fs.readdirSync(__dirname + '/errors').filter(function(name) {
return /\.input\.json$/.test(name);
});
testCases.forEach(function(filename) {
function read(path) {
return fs.readFileSync(__dirname + '/errors/' + path, 'utf8');
}
test('errors/' + filename, function() {
var actual;
try {
handleFilters(JSON.parse(read(filename)), customFilters);
throw new Error('Expected ' + filename + ' to throw an exception.');
} catch (ex) {
if (!ex || !ex.code || ex.code.indexOf('PUG:') !== 0) throw ex;
actual = {
msg: ex.msg,
code: ex.code,
line: ex.line,
};
}
expect(actual).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,28 @@
const lex = require('pug-lexer');
const parse = require('pug-parser');
const handleFilters = require('../').handleFilters;
const customFilters = {};
test('per filter options are applied, even to nested filters', () => {
const source = `
script
:cdata:uglify-js
function myFunc(foo) {
return foo;
}
`;
const ast = parse(lex(source, {filename: __filename}), {
filename: __filename,
src: source,
});
const options = {
'uglify-js': {output: {beautify: true}},
};
const output = handleFilters(ast, customFilters, options);
expect(output).toMatchSnapshot();
// TODO: render with `options.filterOptions['uglify-js']`
});

View File

@@ -0,0 +1,3 @@
- var avatar = '219b77f9d21de75e81851b6b886057c7'
div.avatar-div(style=`background-image: url(https://www.gravatar.com/avatar/${avatar})`)

View File

@@ -0,0 +1,7 @@
- var user = { name: 'tobi' }
foo(data-user=user)
foo(data-items=[1,2,3])
foo(data-username='tobi')
foo(data-escaped={message: "Let's rock!"})
foo(data-ampersand={message: "a quote: &quot; this & that"})
foo(data-epoc=new Date(0))

View File

@@ -0,0 +1,22 @@
- var id = 5
- function answer() { return 42; }
a(href='/user/' + id, class='button')
a(href = '/user/' + id, class = 'button')
meta(key='answer', value=answer())
a(class = ['class1', 'class2'])
a.tag-class(class = ['class1', 'class2'])
a(href='/user/' + id class='button')
a(href = '/user/' + id class = 'button')
meta(key='answer' value=answer())
a(class = ['class1', 'class2'])
a.tag-class(class = ['class1', 'class2'])
div(id=id)&attributes({foo: 'bar'})
- var bar = null
div(foo=null bar=bar)&attributes({baz: 'baz'})
div(...object)
div(...object after="after")
div(before="before" ...object)
div(before="before" ...object after="after")

View File

@@ -0,0 +1,43 @@
a(href='/contact') contact
a(href='/save').button save
a(foo, bar, baz)
a(foo='foo, bar, baz', bar=1)
a(foo='((foo))', bar= (1) ? 1 : 0 )
select
option(value='foo', selected) Foo
option(selected, value='bar') Bar
a(foo="class:")
input(pattern='\\S+')
a(href='/contact') contact
a(href='/save').button save
a(foo bar baz)
a(foo='foo, bar, baz' bar=1)
a(foo='((foo))' bar= (1) ? 1 : 0 )
select
option(value='foo' selected) Foo
option(selected value='bar') Bar
a(foo="class:")
input(pattern='\\S+')
foo(terse="true")
foo(date=new Date(0))
foo(abc
,def)
foo(abc,
def)
foo(abc,
def)
foo(abc
,def)
foo(abc
def)
foo(abc
def)
- var attrs = {foo: 'bar', bar: '<baz>'}
div&attributes(attrs)
a(foo='foo' "bar"="bar")
a(foo='foo' 'bar'='bar')

View File

@@ -0,0 +1,3 @@
script(type='text/x-template')
div(id!='user-<%= user.id %>')
h1 <%= user.title %>

View File

@@ -0,0 +1,3 @@
html
body
h1 Title

View File

@@ -0,0 +1,8 @@
ul
li foo
li bar
li baz

View File

@@ -0,0 +1,12 @@
-
list = ["uno", "dos", "tres",
"cuatro", "cinco", "seis"];
//- Without a block, the element is accepted and no code is generated
-
each item in list
-
string = item.charAt(0)
.toUpperCase() +
item.slice(1);
li= string

View File

@@ -0,0 +1,5 @@
ul
li: a(href='#') foo
li: a(href='#') bar
p baz

View File

@@ -0,0 +1,2 @@
ul
li.list-item: .foo: #bar baz

View File

@@ -0,0 +1,4 @@
figure
blockquote
| Try to define yourself by what you do, and you&#8217;ll burnout every time. You are. That is enough. I rest in that.
figcaption from @thefray at 1:43pm on May 10

View File

@@ -0,0 +1,4 @@
extends ./auxiliary/blocks-in-blocks-layout.pug
block body
h1 Page 2

View File

@@ -0,0 +1,19 @@
//- see https://github.com/pugjs/pug/issues/1589
-var ajax = true
-if( ajax )
//- return only contents if ajax requests
block contents
p ajax contents
-else
//- return all html
doctype html
html
head
meta( charset='utf8' )
title sample
body
block contents
p all contetns

View File

@@ -0,0 +1,10 @@
html
body
- var friends = 1
case friends
when 0
p you have no friends
when 1
p you have a friend
default
p you have #{friends} friends

View File

@@ -0,0 +1,19 @@
html
body
- var friends = 1
case friends
when 0: p you have no friends
when 1: p you have a friend
default: p you have #{friends} friends
- var friends = 0
case friends
when 0
when 1
p you have very few friends
default
p you have #{friends} friends
- var friend = 'Tim:G'
case friend
when 'Tim:G': p Friend is a string
when {tim: 'g'}: p Friend is an object

View File

@@ -0,0 +1,3 @@
a(class='')
a(class=null)
a(class=undefined)

View File

@@ -0,0 +1,14 @@
a(class=['foo', 'bar', 'baz'])
a.foo(class='bar').baz
a.foo-bar_baz
a(class={foo: true, bar: false, baz: true})
a.-foo
a.3foo

View File

@@ -0,0 +1,43 @@
- if (true)
p foo
- else
p bar
- if (true) {
p foo
- } else {
p bar
- }
if true
p foo
p bar
p baz
else
p bar
unless true
p foo
else
p bar
if 'nested'
if 'works'
p yay
//- allow empty blocks
if false
else
.bar
if true
.bar
else
.bing
if false
.bing
else if false
.bar
else
.foo

View File

@@ -0,0 +1,2 @@
p= '<script>'
p!= '<script>'

View File

@@ -0,0 +1,35 @@
- var items = [1,2,3]
ul
- items.forEach(function(item){
li= item
- })
- var items = [1,2,3]
ul
for item, i in items
li(class='item-' + i)= item
ul
each item, i in items
li= item
ul
each $item in items
li= $item
- var nums = [1, 2, 3]
- var letters = ['a', 'b', 'c']
ul
for l in letters
for n in nums
li #{n}: #{l}
- var count = 1
- var counter = function() { return [count++, count++, count++] }
ul
for n in counter()
li #{n}

View File

@@ -0,0 +1,10 @@
p= null
p= undefined
p= ''
p= 0
p= false
p(foo=null)
p(foo=undefined)
p(foo='')
p(foo=0)
p(foo=false)

View File

@@ -0,0 +1,10 @@
doctype html
html
body
- var s = 'this'
case s
//- Comment
when 'this'
p It's this!
when 'that'
p It's that!

View File

@@ -0,0 +1,29 @@
// foo
ul
// bar
li one
// baz
li two
//
ul
li foo
// block
// inline follow
li three
// block
// inline followed by tags
ul
li four
//if IE lt 9
// inline
script(src='/lame.js')
// end-inline
p five
.foo // not a comment

View File

@@ -0,0 +1,9 @@
//-
s/s.
//- test/cases/comments.source.pug
//-
test/cases/comments.source.pug
when
()

View File

@@ -0,0 +1 @@
doctype custom stuff

View File

@@ -0,0 +1,4 @@
doctype
html
body
h1 Title

View File

@@ -0,0 +1 @@
doctype html

View File

@@ -0,0 +1,52 @@
- var users = []
ul
for user in users
li= user.name
else
li no users!
- var users = [{ name: 'tobi', friends: ['loki'] }, { name: 'loki' }]
if users
ul
for user in users
li= user.name
else
li no users!
- var user = { name: 'tobi', age: 10 }
ul
each val, key in user
li #{key}: #{val}
else
li user has no details!
- var user = {}
ul
each prop, key in user
li #{key}: #{val}
else
li user has no details!
- var user = Object.create(null)
- user.name = 'tobi'
ul
each val, key in user
li #{key}: #{val}
else
li user has no details!
- var ofKeyword = [{ name: 'tobi', friends: ['loki'] }, { name: 'loki' }]
ul
each val of ofKeyword
li= user.name
ul
each val of ["variable with of keyword"]
li= val

View File

@@ -0,0 +1,2 @@
script.
var re = /\d+/;

View File

@@ -0,0 +1,8 @@
doctype html
html
head
title escape-test
body
textarea
- var txt = '<param name="flashvars" value="a=&quot;value_a&quot;&b=&quot;value_b&quot;&c=3"/>'
| #{txt}

Some files were not shown because too many files have changed in this diff Show More