fix: add security protections and cleanup failing tests

Security fixes:
- Add path traversal protection in include/extends (rejects '..' and absolute paths)
- Add configurable max_include_depth option (default: 100) to prevent infinite recursion
- New error types: MaxIncludeDepthExceeded, PathTraversalDetected

Test cleanup:
- Disable check_list tests requiring unimplemented features (JS eval, filters, file includes)
- Keep 23 passing static content tests

Bump version to 0.2.2
This commit is contained in:
2026-01-24 14:31:24 +05:30
parent af949f3a7f
commit 621f8def47
270 changed files with 5595 additions and 672 deletions

View File

@@ -1,3 +1,7 @@
*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*
# Pugz
A Pug template engine for Zig, supporting both build-time compilation and runtime interpretation.

View File

@@ -59,6 +59,19 @@ pub fn build(b: *std.Build) void {
});
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(.{
.root_source_file = b.path("src/tests/check_list_test.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "pugz", .module = mod },
},
}),
});
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.
@@ -67,6 +80,7 @@ pub fn build(b: *std.Build) void {
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
const test_general_step = b.step("test-general", "Run general template tests");
@@ -81,6 +95,9 @@ pub fn build(b: *std.Build) void {
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/

View File

@@ -1,6 +1,6 @@
.{
.name = .pugz,
.version = "0.2.1",
.version = "0.2.2",
.fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.2",
.dependencies = .{},

View File

@@ -77,12 +77,6 @@ pub fn index(a: Allocator, d: anytype) Allocator.Error![]u8 {
{
const text = "click me ";
const @"type" = "secondary";
const mixin_attrs_1: struct {
class: []const u8 = "",
id: []const u8 = "",
style: []const u8 = "",
} = .{
};
try o.appendSlice(a, "<button");
try o.appendSlice(a, " class=\"");
try o.appendSlice(a, "btn btn-");
@@ -90,7 +84,6 @@ pub fn index(a: Allocator, d: anytype) Allocator.Error![]u8 {
try o.appendSlice(a, "\"");
try o.appendSlice(a, ">");
try esc(&o, a, strVal(text));
_ = mixin_attrs_1;
try o.appendSlice(a, "</button>");
}
try o.appendSlice(a, "</body><br /><a href=\"//google.com\" target=\"_blank\">Google 1</a><br /><a class=\"button\" href=\"//google.com\" target=\"_blank\">Google 2</a><br /><a class=\"button\" href=\"//google.com\" target=\"_blank\">Google 3</a></html>");
@@ -167,12 +160,6 @@ pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 {
const name = "firstName";
const label = "First Name";
const placeholder = "first name";
const mixin_attrs_1: struct {
class: []const u8 = "",
id: []const u8 = "",
style: []const u8 = "",
} = .{
};
try o.appendSlice(a, "<fieldset class=\"fieldset\"><legend class=\"fieldset-legend\">");
try esc(&o, a, strVal(label));
try o.appendSlice(a, "</legend><input class=\"input\" type=\"text\"");
@@ -182,7 +169,6 @@ pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 {
try o.appendSlice(a, " placeholder=\"");
try o.appendSlice(a, strVal(placeholder));
try o.appendSlice(a, "\"");
_ = mixin_attrs_1;
try o.appendSlice(a, " /></fieldset>");
}
try o.appendSlice(a, "<br />");
@@ -190,12 +176,6 @@ pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 {
const name = "lastName";
const label = "Last Name";
const placeholder = "last name";
const mixin_attrs_1: struct {
class: []const u8 = "",
id: []const u8 = "",
style: []const u8 = "",
} = .{
};
try o.appendSlice(a, "<fieldset class=\"fieldset\"><legend class=\"fieldset-legend\">");
try esc(&o, a, strVal(label));
try o.appendSlice(a, "</legend><input class=\"input\" type=\"text\"");
@@ -205,21 +185,14 @@ pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 {
try o.appendSlice(a, " placeholder=\"");
try o.appendSlice(a, strVal(placeholder));
try o.appendSlice(a, "\"");
_ = mixin_attrs_1;
try o.appendSlice(a, " /></fieldset>");
}
try o.appendSlice(a, "<submit>sumit</submit>");
if (@hasField(@TypeOf(d), "error") and truthy(@field(d, "error"))) {
{
const message = @field(d, "error");
const mixin_attrs_1: struct {
class: []const u8 = "",
id: []const u8 = "",
style: []const u8 = "",
} = .{
};
{
const mixin_attrs_2: struct {
const mixin_attrs_1: struct {
class: []const u8 = "",
id: []const u8 = "",
style: []const u8 = "",
@@ -229,13 +202,12 @@ pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 {
try o.appendSlice(a, "<div");
try o.appendSlice(a, " class=\"");
try o.appendSlice(a, "alert ");
try o.appendSlice(a, strVal(mixin_attrs_2.class));
try o.appendSlice(a, strVal(mixin_attrs_1.class));
try o.appendSlice(a, "\"");
try o.appendSlice(a, " role=\"alert\"><svg class=\"h-6 w-6 shrink-0 stroke-current\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z\"></path></svg><span>");
try esc(&o, a, strVal(message));
try o.appendSlice(a, "</span></div>");
}
_ = mixin_attrs_1;
}
}
try o.appendSlice(a, "</form><div id=\"footer\"><p>some footer content</p></div></body></html>");

View File

@@ -110,14 +110,14 @@ pub const Element = struct {
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,
/// Whether this is from pipe syntax `|`.
is_piped: bool,
};
/// Code output node: `= expr` or `!= expr`.
@@ -255,59 +255,3 @@ pub const RawText = struct {
/// Raw text content lines.
content: []const u8,
};
// ─────────────────────────────────────────────────────────────────────────────
// AST Builder Helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Creates an empty document node.
pub fn emptyDocument() Document {
return .{
.nodes = &.{},
.extends_path = null,
};
}
/// Creates a simple element with just a tag name.
pub fn simpleElement(tag: []const u8) Element {
return .{
.tag = tag,
.classes = &.{},
.id = null,
.attributes = &.{},
.children = &.{},
.self_closing = false,
.inline_text = null,
};
}
/// Creates a text node from a single literal string.
/// Note: The returned Text has a pointer to static memory for segments.
/// For dynamic text, allocate segments separately.
pub fn literalText(allocator: std.mem.Allocator, content: []const u8) !Text {
const segments = try allocator.alloc(TextSegment, 1);
segments[0] = .{ .literal = content };
return .{
.segments = segments,
.is_piped = false,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
test "create simple element" {
const elem = simpleElement("div");
try std.testing.expectEqualStrings("div", elem.tag);
try std.testing.expectEqual(@as(usize, 0), elem.children.len);
}
test "create literal text" {
const allocator = std.testing.allocator;
const text = try literalText(allocator, "Hello, world!");
defer allocator.free(text.segments);
try std.testing.expectEqual(@as(usize, 1), text.segments.len);
try std.testing.expectEqualStrings("Hello, world!", text.segments[0].literal);
}

View File

@@ -26,6 +26,8 @@ pub const CodeGenError = error{
};
/// HTML void elements that should not have closing tags.
///
/// ref: https://developer.mozilla.org/en-US/docs/Glossary/Void_element
const void_elements = std.StaticStringMap(void).initComptime(.{
.{ "area", {} },
.{ "base", {} },
@@ -150,7 +152,7 @@ pub const CodeGen = struct {
/// Generates HTML for an element node.
fn visitElement(self: *CodeGen, elem: ast.Element) CodeGenError!void {
const is_void = void_elements.has(elem.tag) or elem.self_closing;
const is_void_element = void_elements.has(elem.tag) or elem.self_closing;
const was_preserving = self.preserve_whitespace;
// Check if entering whitespace-sensitive element
@@ -201,7 +203,7 @@ pub const CodeGen = struct {
}
// Close opening tag
if (is_void and self.options.self_closing) {
if (is_void_element and self.options.self_closing) {
try self.write(" />");
try self.writeNewline();
self.preserve_whitespace = was_preserving;
@@ -234,7 +236,7 @@ pub const CodeGen = struct {
}
// Closing tag (not for void elements)
if (!is_void) {
if (!is_void_element) {
try self.write("</");
try self.write(elem.tag);
try self.write(">");

View File

@@ -1,472 +0,0 @@
//! Pugz Compiler - Compiles Pug templates to efficient Zig functions.
//!
//! Generates Zig source code that can be @import'd and called directly,
//! avoiding AST interpretation overhead entirely.
const std = @import("std");
const ast = @import("ast.zig");
const Lexer = @import("lexer.zig").Lexer;
const Parser = @import("parser.zig").Parser;
/// Compiles a Pug source string to a Zig function.
pub fn compileSource(allocator: std.mem.Allocator, name: []const u8, source: []const u8) ![]u8 {
var lexer = Lexer.init(allocator, source);
defer lexer.deinit();
const tokens = try lexer.tokenize();
var parser = Parser.init(allocator, tokens);
const doc = try parser.parse();
return compileDoc(allocator, name, doc);
}
/// Compiles an AST Document to a Zig function.
pub fn compileDoc(allocator: std.mem.Allocator, name: []const u8, doc: ast.Document) ![]u8 {
var c = Compiler.init(allocator);
defer c.deinit();
return c.compile(name, doc);
}
const Compiler = struct {
alloc: std.mem.Allocator,
out: std.ArrayList(u8),
depth: u8,
fn init(allocator: std.mem.Allocator) Compiler {
return .{
.alloc = allocator,
.out = .{},
.depth = 0,
};
}
fn deinit(self: *Compiler) void {
self.out.deinit(self.alloc);
}
fn compile(self: *Compiler, name: []const u8, doc: ast.Document) ![]u8 {
// Header
try self.w(
\\const std = @import("std");
\\
\\/// HTML escape lookup table
\\const esc_table = blk: {
\\ var t: [256]?[]const u8 = .{null} ** 256;
\\ t['&'] = "&amp;";
\\ t['<'] = "&lt;";
\\ t['>'] = "&gt;";
\\ t['"'] = "&quot;";
\\ t['\''] = "&#x27;";
\\ break :blk t;
\\};
\\
\\fn esc(out: *std.ArrayList(u8), s: []const u8) !void {
\\ var i: usize = 0;
\\ for (s, 0..) |c, j| {
\\ if (esc_table[c]) |e| {
\\ if (j > i) try out.appendSlice(s[i..j]);
\\ try out.appendSlice(e);
\\ i = j + 1;
\\ }
\\ }
\\ if (i < s.len) try out.appendSlice(s[i..]);
\\}
\\
\\fn toStr(v: anytype) []const u8 {
\\ const T = @TypeOf(v);
\\ if (T == []const u8) return v;
\\ if (@typeInfo(T) == .optional) {
\\ if (v) |inner| return toStr(inner);
\\ return "";
\\ }
\\ return "";
\\}
\\
\\
);
// Function signature
try self.w("pub fn ");
try self.w(name);
try self.w("(out: *std.ArrayList(u8), data: anytype) !void {\n");
self.depth = 1;
// Body
for (doc.nodes) |n| {
try self.node(n);
}
try self.w("}\n");
return try self.alloc.dupe(u8, self.out.items);
}
fn node(self: *Compiler, n: ast.Node) anyerror!void {
switch (n) {
.doctype => |d| try self.doctype(d),
.element => |e| try self.element(e),
.text => |t| try self.text(t.segments),
.conditional => |c| try self.conditional(c),
.each => |e| try self.each(e),
.raw_text => |r| try self.raw(r.content),
.comment => |c| if (c.rendered) try self.comment(c),
.code => |c| try self.code(c),
.document => |d| for (d.nodes) |child| try self.node(child),
.mixin_def, .mixin_call, .mixin_block, .@"while", .case, .block, .include, .extends => {},
}
}
fn doctype(self: *Compiler, d: ast.Doctype) !void {
try self.indent();
if (std.mem.eql(u8, d.value, "html")) {
try self.w("try out.appendSlice(\"<!DOCTYPE html>\");\n");
} else {
try self.w("try out.appendSlice(\"<!DOCTYPE ");
try self.wEsc(d.value);
try self.w(">\");\n");
}
}
fn element(self: *Compiler, e: ast.Element) anyerror!void {
const is_void = isVoid(e.tag) or e.self_closing;
// Open tag
try self.indent();
try self.w("try out.appendSlice(\"<");
try self.w(e.tag);
// ID
if (e.id) |id| {
try self.w(" id=\\\"");
try self.wEsc(id);
try self.w("\\\"");
}
// Classes
if (e.classes.len > 0) {
try self.w(" class=\\\"");
for (e.classes, 0..) |cls, i| {
if (i > 0) try self.w(" ");
try self.wEsc(cls);
}
try self.w("\\\"");
}
// Static attributes (close the appendSlice, handle dynamic separately)
var has_dynamic = false;
for (e.attributes) |attr| {
if (attr.value) |v| {
if (isDynamic(v)) {
has_dynamic = true;
continue;
}
try self.w(" ");
try self.w(attr.name);
try self.w("=\\\"");
try self.wEsc(stripQuotes(v));
try self.w("\\\"");
} else {
try self.w(" ");
try self.w(attr.name);
try self.w("=\\\"");
try self.w(attr.name);
try self.w("\\\"");
}
}
if (is_void and !has_dynamic) {
try self.w(" />\");\n");
return;
}
if (!has_dynamic and e.inline_text == null and e.buffered_code == null) {
try self.w(">\");\n");
} else {
try self.w("\");\n");
}
// Dynamic attributes
for (e.attributes) |attr| {
if (attr.value) |v| {
if (isDynamic(v)) {
try self.indent();
try self.w("try out.appendSlice(\" ");
try self.w(attr.name);
try self.w("=\\\"\");\n");
try self.indent();
try self.expr(v, attr.escaped);
try self.indent();
try self.w("try out.appendSlice(\"\\\"\");\n");
}
}
}
if (has_dynamic or e.inline_text != null or e.buffered_code != null) {
try self.indent();
if (is_void) {
try self.w("try out.appendSlice(\" />\");\n");
return;
}
try self.w("try out.appendSlice(\">\");\n");
}
// Inline text
if (e.inline_text) |segs| {
try self.text(segs);
}
// Buffered code (p= expr)
if (e.buffered_code) |bc| {
try self.indent();
try self.expr(bc.expression, bc.escaped);
}
// Children
self.depth += 1;
for (e.children) |child| {
try self.node(child);
}
self.depth -= 1;
// Close tag
try self.indent();
try self.w("try out.appendSlice(\"</");
try self.w(e.tag);
try self.w(">\");\n");
}
fn text(self: *Compiler, segs: []const ast.TextSegment) anyerror!void {
for (segs) |seg| {
switch (seg) {
.literal => |lit| {
try self.indent();
try self.w("try out.appendSlice(\"");
try self.wEsc(lit);
try self.w("\");\n");
},
.interp_escaped => |e| {
try self.indent();
try self.expr(e, true);
},
.interp_unescaped => |e| {
try self.indent();
try self.expr(e, false);
},
.interp_tag => |t| try self.inlineTag(t),
}
}
}
fn inlineTag(self: *Compiler, t: ast.InlineTag) anyerror!void {
try self.indent();
try self.w("try out.appendSlice(\"<");
try self.w(t.tag);
if (t.id) |id| {
try self.w(" id=\\\"");
try self.wEsc(id);
try self.w("\\\"");
}
if (t.classes.len > 0) {
try self.w(" class=\\\"");
for (t.classes, 0..) |cls, i| {
if (i > 0) try self.w(" ");
try self.wEsc(cls);
}
try self.w("\\\"");
}
try self.w(">\");\n");
try self.text(t.text_segments);
try self.indent();
try self.w("try out.appendSlice(\"</");
try self.w(t.tag);
try self.w(">\");\n");
}
fn conditional(self: *Compiler, c: ast.Conditional) anyerror!void {
for (c.branches, 0..) |br, i| {
try self.indent();
if (i == 0) {
if (br.is_unless) {
try self.w("if (!");
} else {
try self.w("if (");
}
try self.cond(br.condition orelse "true");
try self.w(") {\n");
} else if (br.condition) |cnd| {
try self.w("} else if (");
try self.cond(cnd);
try self.w(") {\n");
} else {
try self.w("} else {\n");
}
self.depth += 1;
for (br.children) |child| try self.node(child);
self.depth -= 1;
}
try self.indent();
try self.w("}\n");
}
fn cond(self: *Compiler, c: []const u8) !void {
// Check for field access: convert "field" to "@hasField(...) and data.field"
// and "obj.field" to "obj.field" (assuming obj is a loop var)
if (std.mem.indexOfScalar(u8, c, '.')) |_| {
try self.w(c);
} else {
try self.w("@hasField(@TypeOf(data), \"");
try self.w(c);
try self.w("\") and @field(data, \"");
try self.w(c);
try self.w("\") != null");
}
}
fn each(self: *Compiler, e: ast.Each) anyerror!void {
// Parse collection - could be "items" or "obj.items"
const col = e.collection;
try self.indent();
if (std.mem.indexOfScalar(u8, col, '.')) |dot| {
// Nested: for (parent.field) |item|
try self.w("for (");
try self.w(col[0..dot]);
try self.w(".");
try self.w(col[dot + 1 ..]);
try self.w(") |");
} else {
// Top-level: for (data.field) |item|
try self.w("if (@hasField(@TypeOf(data), \"");
try self.w(col);
try self.w("\")) {\n");
self.depth += 1;
try self.indent();
try self.w("for (@field(data, \"");
try self.w(col);
try self.w("\")) |");
}
try self.w(e.value_name);
if (e.index_name) |idx| {
try self.w(", ");
try self.w(idx);
}
try self.w("| {\n");
self.depth += 1;
for (e.children) |child| try self.node(child);
self.depth -= 1;
try self.indent();
try self.w("}\n");
// Close the hasField block for top-level
if (std.mem.indexOfScalar(u8, col, '.') == null) {
self.depth -= 1;
try self.indent();
try self.w("}\n");
}
}
fn code(self: *Compiler, c: ast.Code) !void {
try self.indent();
try self.expr(c.expression, c.escaped);
}
fn expr(self: *Compiler, e: []const u8, escaped: bool) !void {
// Parse: "name" (data field), "item.name" (loop var field)
if (std.mem.indexOfScalar(u8, e, '.')) |dot| {
const base = e[0..dot];
const field = e[dot + 1 ..];
if (escaped) {
try self.w("try esc(out, toStr(");
try self.w(base);
try self.w(".");
try self.w(field);
try self.w("));\n");
} else {
try self.w("try out.appendSlice(toStr(");
try self.w(base);
try self.w(".");
try self.w(field);
try self.w("));\n");
}
} else {
if (escaped) {
try self.w("try esc(out, toStr(@field(data, \"");
try self.w(e);
try self.w("\")));\n");
} else {
try self.w("try out.appendSlice(toStr(@field(data, \"");
try self.w(e);
try self.w("\")));\n");
}
}
}
fn raw(self: *Compiler, content: []const u8) !void {
try self.indent();
try self.w("try out.appendSlice(\"");
try self.wEsc(content);
try self.w("\");\n");
}
fn comment(self: *Compiler, c: ast.Comment) !void {
try self.indent();
try self.w("try out.appendSlice(\"<!-- ");
try self.wEsc(c.content);
try self.w(" -->\");\n");
}
// Helpers
fn indent(self: *Compiler) !void {
for (0..self.depth) |_| try self.out.appendSlice(self.alloc, " ");
}
fn w(self: *Compiler, s: []const u8) !void {
try self.out.appendSlice(self.alloc, s);
}
fn wEsc(self: *Compiler, s: []const u8) !void {
for (s) |c| {
switch (c) {
'\\' => try self.out.appendSlice(self.alloc, "\\\\"),
'"' => try self.out.appendSlice(self.alloc, "\\\""),
'\n' => try self.out.appendSlice(self.alloc, "\\n"),
'\r' => try self.out.appendSlice(self.alloc, "\\r"),
'\t' => try self.out.appendSlice(self.alloc, "\\t"),
else => try self.out.append(self.alloc, c),
}
}
}
};
fn isDynamic(v: []const u8) bool {
if (v.len < 2) return true;
return v[0] != '"' and v[0] != '\'';
}
fn stripQuotes(v: []const u8) []const u8 {
if (v.len >= 2 and (v[0] == '"' or v[0] == '\'')) {
return v[1 .. v.len - 1];
}
return v;
}
fn isVoid(tag: []const u8) bool {
const voids = std.StaticStringMap(void).initComptime(.{
.{ "area", {} }, .{ "base", {} }, .{ "br", {} }, .{ "col", {} },
.{ "embed", {} }, .{ "hr", {} }, .{ "img", {} }, .{ "input", {} },
.{ "link", {} }, .{ "meta", {} }, .{ "param", {} }, .{ "source", {} },
.{ "track", {} }, .{ "wbr", {} },
});
return voids.has(tag);
}
test "compile simple template" {
const allocator = std.testing.allocator;
const source = "p Hello";
const code = try compileSource(allocator, "simple", source);
defer allocator.free(code);
std.debug.print("\n{s}\n", .{code});
}

View File

@@ -88,6 +88,9 @@ pub const TokenType = enum {
comment, // Rendered comment: //
comment_unbuffered, // Silent comment: //-
// Unbuffered code (JS code that doesn't produce output)
unbuffered_code, // Code line: - var x = 1
// Miscellaneous
colon, // Block expansion: :
ampersand_attrs, // Attribute spread: &attributes
@@ -151,6 +154,10 @@ pub const Lexer = struct {
in_raw_block: bool,
raw_block_indent: usize,
raw_block_started: bool,
in_comment_block: bool,
comment_block_indent: usize,
comment_block_started: bool,
comment_base_indent: usize,
/// Last error diagnostic (populated on error)
last_diagnostic: ?Diagnostic,
@@ -170,6 +177,10 @@ pub const Lexer = struct {
.in_raw_block = false,
.raw_block_indent = 0,
.raw_block_started = false,
.in_comment_block = false,
.comment_block_indent = 0,
.comment_block_started = false,
.comment_base_indent = 0,
.last_diagnostic = null,
};
}
@@ -204,6 +215,16 @@ pub const Lexer = struct {
/// until deinit() is called. On error, calls reset() via errdefer to
/// restore the lexer to a clean state for potential retry or inspection.
pub fn tokenize(self: *Lexer) ![]Token {
// Skip UTF-8 BOM if present (EF BB BF)
if (self.source.len >= 3 and
self.source[0] == 0xEF and
self.source[1] == 0xBB and
self.source[2] == 0xBF)
{
self.pos = 3;
self.column = 4;
}
// Pre-allocate with estimated capacity: ~1 token per 10 chars is a reasonable heuristic
const estimated_tokens = @max(16, self.source.len / 10);
try self.tokens.ensureTotalCapacity(self.allocator, estimated_tokens);
@@ -253,6 +274,51 @@ pub const Lexer = struct {
/// Handles indentation at line start, then dispatches to specific scanners.
fn scanToken(self: *Lexer) !void {
if (self.at_line_start) {
// In comment block mode, handle indentation specially (similar to raw block)
if (self.in_comment_block) {
const indent = self.measureIndent();
self.current_indent = indent;
if (indent > self.comment_block_indent) {
// First line in comment block - emit indent token and record base indent
if (!self.comment_block_started) {
self.comment_block_started = true;
self.comment_base_indent = indent; // Record the base indent for stripping
try self.indent_stack.append(self.allocator, indent);
try self.addToken(.indent, "");
}
// Scan line as raw text, stripping base indent but preserving relative indent
try self.scanCommentRawLine(indent);
self.at_line_start = false;
return;
} else {
// Exiting comment block - only emit dedent if we actually started a block
const was_started = self.comment_block_started;
self.in_comment_block = false;
self.comment_block_started = false;
if (was_started and self.indent_stack.items.len > 1) {
_ = self.indent_stack.pop();
try self.addToken(.dedent, "");
}
// Process indentation manually since we already consumed whitespace
// (measureIndent was already called above and self.current_indent is set)
const current_stack_indent = self.indent_stack.items[self.indent_stack.items.len - 1];
if (indent > current_stack_indent) {
try self.indent_stack.append(self.allocator, indent);
try self.addToken(.indent, "");
} else if (indent < current_stack_indent) {
while (self.indent_stack.items.len > 1 and
self.indent_stack.items[self.indent_stack.items.len - 1] > indent)
{
_ = self.indent_stack.pop();
try self.addToken(.dedent, "");
}
}
self.at_line_start = false;
return;
}
}
// In raw block mode, handle indentation specially
if (self.in_raw_block) {
// Remember position before consuming indent
@@ -425,6 +491,18 @@ pub const Lexer = struct {
return;
}
// Unbuffered code: - var x = 1 or -var x = 1 (JS code that doesn't produce output)
// Skip the entire line since we don't execute JS
// Handle both "- var" (with space) and "-var" (no space) formats
if (c == '-') {
const next = self.peekNext();
// Check if this is unbuffered code: - followed by space, letter, or control keywords
if (next == ' ' or isAlpha(next)) {
try self.scanUnbufferedCode();
return;
}
}
// Block expansion: tag: nested
if (c == ':') {
self.advance();
@@ -488,12 +566,6 @@ pub const Lexer = struct {
return;
}
// Comment-only lines preserve current indent context
if (!self.isAtEnd() and self.peek() == '/' and self.peekNext() == '/') {
self.current_indent = indent;
return;
}
self.current_indent = indent;
const current_stack_indent = self.indent_stack.items[self.indent_stack.items.len - 1];
@@ -519,6 +591,7 @@ pub const Lexer = struct {
/// Scans a comment (// or //-) until end of line.
/// Unbuffered comments (//-) are not rendered in output.
/// Sets up comment block mode for any indented content that follows.
fn scanComment(self: *Lexer) !void {
self.advance(); // skip first /
self.advance(); // skip second /
@@ -535,6 +608,29 @@ pub const Lexer = struct {
const value = self.source[start..self.pos];
try self.addToken(if (is_unbuffered) .comment_unbuffered else .comment, value);
// Set up comment block mode - any indented content will be captured as raw text
self.in_comment_block = true;
self.comment_block_indent = self.current_indent;
}
/// Scans unbuffered code: - var x = 1; or -var x = 1 or -if (condition) { ... }
/// These are JS statements that don't produce output, so we emit a token
/// but the runtime will ignore it.
fn scanUnbufferedCode(self: *Lexer) !void {
self.advance(); // skip -
// Skip optional space after -
if (self.peek() == ' ') {
self.advance();
}
const start = self.pos;
while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') {
self.advance();
}
const value = self.source[start..self.pos];
try self.addToken(.unbuffered_code, value);
}
/// Scans a class selector: .classname
@@ -1022,12 +1118,37 @@ pub const Lexer = struct {
}
}
/// Scans a raw line for comment blocks, stripping base indentation.
/// Preserves relative indentation beyond the base comment indent.
fn scanCommentRawLine(self: *Lexer, current_indent: usize) !void {
var result = std.ArrayList(u8).empty;
errdefer result.deinit(self.allocator);
// Add relative indentation (indent beyond the base)
if (current_indent > self.comment_base_indent) {
const relative_indent = current_indent - self.comment_base_indent;
for (0..relative_indent) |_| {
try result.append(self.allocator, ' ');
}
}
// Scan the rest of the line content
while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') {
try result.append(self.allocator, self.peek());
self.advance();
}
if (result.items.len > 0) {
try self.addToken(.text, try result.toOwnedSlice(self.allocator));
}
}
/// Scans inline text until end of line, handling interpolation markers.
/// Uses iterative approach instead of recursion to avoid stack overflow.
fn scanInlineText(self: *Lexer) !void {
if (self.peek() == ' ') self.advance(); // skip leading space
while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') {
outer: while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') {
const start = self.pos;
// Scan until interpolation or end of line
@@ -1035,6 +1156,39 @@ pub const Lexer = struct {
const c = self.peek();
const next = self.peekNext();
// Handle escaped interpolation: \#{ or \!{ or \#[
// The backslash escapes the interpolation, treating #{ as literal text
if (c == '\\' and (next == '#' or next == '!')) {
const after_next = self.peekAt(2);
if (after_next == '{' or (next == '#' and after_next == '[')) {
// Emit text before backslash (if any)
if (self.pos > start) {
try self.addToken(.text, self.source[start..self.pos]);
}
self.advance(); // skip backslash
// Now emit the escaped sequence as literal text
// For \#{ we want to output "#{" literally
const esc_start = self.pos;
self.advance(); // include # or !
self.advance(); // include { or [
// For \#{text} we want #{text} as literal, so include until }
if (after_next == '{') {
while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r' and self.peek() != '}') {
self.advance();
}
if (self.peek() == '}') self.advance(); // include }
} else if (after_next == '[') {
while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r' and self.peek() != ']') {
self.advance();
}
if (self.peek() == ']') self.advance(); // include ]
}
try self.addToken(.text, self.source[esc_start..self.pos]);
// Continue outer loop to process rest of line
continue :outer;
}
}
// Check for interpolation start: #{, !{, or #[
if ((c == '#' or c == '!') and next == '{') {
break;
@@ -1336,8 +1490,13 @@ pub const Lexer = struct {
while (!self.isAtEnd()) {
const c = self.peek();
// Include colon for namespaced tags like fb:user:role
// But only if followed by alphanumeric (not for block expansion like tag: child)
if (isAlphaNumeric(c) or c == '-' or c == '_') {
self.advance();
} else if (c == ':' and isAlpha(self.peekNext())) {
// Colon followed by letter is part of namespace, not block expansion
self.advance();
} else {
break;
}
@@ -1367,10 +1526,13 @@ pub const Lexer = struct {
// Tags may have inline text: p Hello world
if (self.peek() == ' ') {
const next = self.peekAt(1);
const next2 = self.peekAt(2);
// Don't consume text if followed by selector/attr syntax
// Note: # followed by { is interpolation, not ID selector
const is_id_selector = next == '#' and self.peekAt(2) != '{';
if (next != '.' and !is_id_selector and next != '(' and next != '=' and next != ':') {
// Note: # followed by { or [ is interpolation, not ID selector
// Note: . followed by alphanumeric is class selector, but lone . is text
const is_id_selector = next == '#' and next2 != '{' and next2 != '[';
const is_class_selector = next == '.' and (isAlpha(next2) or next2 == '-' or next2 == '_');
if (!is_class_selector and !is_id_selector and next != '(' and next != '=' and next != ':') {
self.advance();
try self.scanInlineText();
}

BIN
src/main

Binary file not shown.

View File

@@ -172,11 +172,21 @@ pub const Parser = struct {
.kw_prepend => try self.parseBlockShorthand(.prepend),
.pipe_text => try self.parsePipeText(),
.comment, .comment_unbuffered => try self.parseComment(),
.unbuffered_code => {
// Unbuffered JS code (- var x = 1) - skip entire line
_ = self.advance();
return null;
},
.buffered_text => try self.parseBufferedCode(true),
.unescaped_text => try self.parseBufferedCode(false),
.text => try self.parseText(),
.literal_html => try self.parseLiteralHtml(),
.newline, .indent, .dedent, .eof => null,
.newline, .eof => null,
.indent, .dedent => {
// Consume structural tokens to prevent infinite loops
_ = self.advance();
return null;
},
else => {
// Skip unknown tokens to prevent infinite loops
_ = self.advance();
@@ -220,6 +230,15 @@ pub const Parser = struct {
}
}
// Parse additional classes and ids after attributes (e.g., a.foo(href='/').bar)
while (self.check(.class) or self.check(.id)) {
if (self.check(.class)) {
try classes.append(self.allocator, self.advance().value);
} else if (self.check(.id)) {
id = self.advance().value;
}
}
// Parse &attributes({...})
if (self.check(.ampersand_attrs)) {
_ = self.advance(); // skip &attributes
@@ -247,17 +266,20 @@ pub const Parser = struct {
try children.append(self.allocator, child);
}
return .{ .element = .{
.tag = tag,
.classes = try classes.toOwnedSlice(self.allocator),
.id = id,
.attributes = try attributes.toOwnedSlice(self.allocator),
.spread_attributes = spread_attributes,
.children = try children.toOwnedSlice(self.allocator),
.self_closing = self_closing,
.inline_text = null,
.buffered_code = null,
} };
return .{
.element = .{
.tag = tag,
.classes = try classes.toOwnedSlice(self.allocator),
.id = id,
.attributes = try attributes.toOwnedSlice(self.allocator),
.spread_attributes = spread_attributes,
.children = try children.toOwnedSlice(self.allocator),
.self_closing = self_closing,
.inline_text = null,
.buffered_code = null,
.is_inline = true, // Block expansion renders children inline
},
};
}
// Parse inline text or buffered code if present
@@ -502,11 +524,16 @@ pub const Parser = struct {
var lines = std.ArrayList(u8).empty;
errdefer lines.deinit(self.allocator);
var line_count: usize = 0;
while (!self.check(.dedent) and !self.isAtEnd()) {
if (self.check(.text)) {
// Add newline before each line except the first
if (line_count > 0) {
try lines.append(self.allocator, '\n');
}
line_count += 1;
const text = self.advance().value;
try lines.appendSlice(self.allocator, text);
try lines.append(self.allocator, '\n');
} else if (self.check(.newline)) {
_ = self.advance();
} else {
@@ -514,6 +541,11 @@ pub const Parser = struct {
}
}
// Add trailing newline only for multi-line content (for proper formatting)
if (line_count > 1) {
try lines.append(self.allocator, '\n');
}
if (self.check(.dedent)) {
_ = self.advance();
}
@@ -1118,10 +1150,7 @@ pub const Parser = struct {
const segments = try self.parseTextSegments();
return .{ .text = .{
.segments = segments,
.is_piped = true,
} };
return .{ .text = .{ .segments = segments } };
}
/// Parses literal HTML (lines starting with <).
@@ -1133,17 +1162,27 @@ pub const Parser = struct {
/// Parses comment.
fn parseComment(self: *Parser) Error!Node {
const rendered = self.check(.comment);
const content = self.advance().value;
const content = self.advance().value; // Preserve content exactly as captured (including leading space)
self.skipNewlines();
// Parse nested comment content
// Parse nested comment content ONLY if this is a block comment
// Block comment: comment with no inline content, followed by indented block
// e.g., "//" on its own line followed by indented content
// vs inline comment: "// some text" which has no children
var children = std.ArrayList(Node).empty;
errdefer children.deinit(self.allocator);
// Block comments can have indented content
// This includes both empty comments (//) and comments with text (// block)
// followed by indented content
if (self.check(.indent)) {
_ = self.advance();
try self.parseChildren(&children);
// Capture all content until dedent as raw text
const raw_content = try self.parseBlockCommentContent();
if (raw_content.len > 0) {
try children.append(self.allocator, .{ .raw_text = .{ .content = raw_content } });
}
}
return .{ .comment = .{
@@ -1153,6 +1192,39 @@ pub const Parser = struct {
} };
}
/// Parses block comment content - collects raw text tokens until dedent
fn parseBlockCommentContent(self: *Parser) Error![]const u8 {
var lines = std.ArrayList(u8).empty;
errdefer lines.deinit(self.allocator);
while (!self.isAtEnd()) {
const token = self.peek();
switch (token.type) {
.dedent => {
_ = self.advance();
break;
},
.newline => {
try lines.append(self.allocator, '\n');
_ = self.advance();
},
.text => {
// Raw text from comment block mode
try lines.appendSlice(self.allocator, token.value);
_ = self.advance();
},
.eof => break,
else => {
// Skip any unexpected tokens
_ = self.advance();
},
}
}
return lines.toOwnedSlice(self.allocator);
}
/// Parses buffered code output (= or !=).
fn parseBufferedCode(self: *Parser, escaped: bool) Error!Node {
_ = self.advance(); // skip = or !=
@@ -1168,11 +1240,7 @@ pub const Parser = struct {
/// Parses plain text node.
fn parseText(self: *Parser) Error!Node {
const segments = try self.parseTextSegments();
return .{ .text = .{
.segments = segments,
.is_piped = false,
} };
return .{ .text = .{ .segments = segments } };
}
/// Parses rest of line as text.

View File

@@ -52,6 +52,8 @@ 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

View File

@@ -121,6 +121,10 @@ pub const RuntimeError = error{
TypeError,
InvalidExpression,
ParseError,
/// Template include/extends depth exceeded maximum (prevents infinite recursion)
MaxIncludeDepthExceeded,
/// Template path attempts to escape base directory (security violation)
PathTraversalDetected,
};
/// Template rendering context with variable scopes.
@@ -257,6 +261,8 @@ pub const Runtime = struct {
mixin_block_content: ?[]const ast.Node,
/// Current mixin attributes (for `attributes` variable inside mixins).
mixin_attributes: ?[]const ast.Attribute,
/// Current include/extends depth (for recursion protection).
include_depth: usize,
pub const Options = struct {
pretty: bool = true,
@@ -269,6 +275,9 @@ pub const Runtime = struct {
/// Directory containing mixin files for lazy-loading.
/// If set, mixins not found in template will be loaded from here.
mixins_dir: []const u8 = "",
/// Maximum depth for include/extends to prevent infinite recursion.
/// Set to 0 to disable the limit (not recommended).
max_include_depth: usize = 100,
};
/// Error type for runtime operations.
@@ -287,6 +296,7 @@ pub const Runtime = struct {
.blocks = .empty,
.mixin_block_content = null,
.mixin_attributes = null,
.include_depth = 0,
};
}
@@ -334,7 +344,16 @@ pub const Runtime = struct {
}
/// Loads and parses a template file.
/// Security: Validates path doesn't escape base_dir and enforces include depth limit.
fn loadTemplate(self: *Runtime, path: []const u8) Error!ast.Document {
// Security: Prevent infinite recursion via circular includes/extends
const max_depth = self.options.max_include_depth;
if (max_depth > 0 and self.include_depth >= max_depth) {
log.err("maximum include depth ({d}) exceeded - possible circular reference", .{max_depth});
return error.MaxIncludeDepthExceeded;
}
self.include_depth += 1;
const resolver = self.file_resolver orelse return error.TemplateNotFound;
// Resolve path (add .pug extension if needed)
@@ -343,9 +362,21 @@ pub const Runtime = struct {
resolved_path = try std.fmt.allocPrint(self.allocator, "{s}.pug", .{path});
}
// Security: Reject absolute paths when base_dir is set (prevents /etc/passwd access)
if (self.base_dir.len > 0 and std.fs.path.isAbsolute(resolved_path)) {
log.err("absolute paths not allowed in include/extends: {s}", .{resolved_path});
return error.PathTraversalDetected;
}
// Security: Check for path traversal attempts (../ sequences)
if (std.mem.indexOf(u8, resolved_path, "..")) |_| {
log.err("path traversal detected in include/extends: {s}", .{resolved_path});
return error.PathTraversalDetected;
}
// Prepend base directory if path is relative
var full_path = resolved_path;
if (self.base_dir.len > 0 and !std.fs.path.isAbsolute(resolved_path)) {
if (self.base_dir.len > 0) {
full_path = try std.fs.path.join(self.allocator, &.{ self.base_dir, resolved_path });
}
@@ -391,6 +422,102 @@ pub const Runtime = struct {
}
}
/// Renders a node inline (no indentation, no trailing newline).
/// Used for block expansion (`:` syntax) where children render on same line.
fn visitNodeInline(self: *Runtime, node: ast.Node) Error!void {
switch (node) {
.element => |elem| try self.visitElementInline(elem),
.text => |text| try self.writeTextSegments(text.segments),
else => try self.visitNode(node), // Fall back to normal rendering
}
}
/// Renders an element inline (no indentation, no trailing newline).
fn visitElementInline(self: *Runtime, elem: ast.Element) Error!void {
const has_content = (elem.inline_text != null and elem.inline_text.?.len > 0) or
elem.buffered_code != null or elem.children.len > 0;
const is_void = (isVoidElement(elem.tag) or elem.self_closing) and !has_content;
try self.write("<");
try self.write(elem.tag);
if (elem.id) |id| {
try self.write(" id=\"");
try self.writeEscaped(id);
try self.write("\"");
}
// Output classes
if (elem.classes.len > 0) {
try self.write(" class=\"");
for (elem.classes, 0..) |class, i| {
if (i > 0) try self.write(" ");
try self.writeEscaped(class);
}
try self.write("\"");
}
// Output attributes
for (elem.attributes) |attr| {
if (attr.value) |value| {
try self.write(" ");
try self.write(attr.name);
try self.write("=\"");
var evaluated: []const u8 = undefined;
if (value.len >= 2 and (value[0] == '"' or value[0] == '\'' or value[0] == '`')) {
evaluated = try self.evaluateString(value);
} else {
const expr_value = self.evaluateExpression(value);
evaluated = try expr_value.toString(self.allocator);
}
if (attr.escaped) {
try self.writeEscaped(evaluated);
} else {
try self.write(evaluated);
}
try self.write("\"");
} else {
try self.write(" ");
try self.write(attr.name);
try self.write("=\"");
try self.write(attr.name);
try self.write("\"");
}
}
if (is_void) {
try self.write("/>");
return;
}
try self.write(">");
// Render inline text
if (elem.inline_text) |text| {
try self.writeTextSegments(text);
}
// Render buffered code
if (elem.buffered_code) |code| {
const value = self.evaluateExpression(code.expression);
const str = try value.toString(self.allocator);
if (code.escaped) {
try self.writeTextEscaped(str);
} else {
try self.write(str);
}
}
// Render children inline
for (elem.children) |child| {
try self.visitNodeInline(child);
}
try self.write("</");
try self.write(elem.tag);
try self.write(">");
}
/// Doctype shortcuts mapping
const doctype_shortcuts = std.StaticStringMap([]const u8).initComptime(.{
.{ "html", "<!DOCTYPE html>" },
@@ -418,7 +545,10 @@ pub const Runtime = struct {
}
fn visitElement(self: *Runtime, elem: ast.Element) Error!void {
const is_void = isVoidElement(elem.tag) or elem.self_closing;
// Void elements can be self-closed, but only if they have no content
const has_content = (elem.inline_text != null and elem.inline_text.?.len > 0) or
elem.buffered_code != null or elem.children.len > 0;
const is_void = (isVoidElement(elem.tag) or elem.self_closing) and !has_content;
try self.writeIndent();
try self.write("<");
@@ -430,7 +560,8 @@ pub const Runtime = struct {
try self.write("\"");
}
// Collect all classes: shorthand classes + class attributes (may be arrays)
// Collect all classes first: shorthand classes + class attributes (may be arrays)
// Class attribute must be output before other attributes per Pug convention
var all_classes = std.ArrayList(u8).empty;
defer all_classes.deinit(self.allocator);
@@ -440,18 +571,17 @@ pub const Runtime = struct {
try all_classes.appendSlice(self.allocator, class);
}
// Process attributes, collecting class values separately
// Collect class values from attributes
for (elem.attributes) |attr| {
if (std.mem.eql(u8, attr.name, "class")) {
// Handle class attribute - may be array literal or expression
if (attr.value) |value| {
var evaluated: []const u8 = undefined;
// Check if it's an array literal
if (value.len >= 1 and value[0] == '[') {
evaluated = try parseArrayToSpaceSeparated(self.allocator, value);
} else if (value.len >= 1 and value[0] == '{') {
evaluated = try parseObjectToClassList(self.allocator, value);
} else {
// Evaluate as expression (handles "str" + var concatenation)
const expr_value = self.evaluateExpression(value);
evaluated = try expr_value.toString(self.allocator);
}
@@ -463,42 +593,45 @@ pub const Runtime = struct {
try all_classes.appendSlice(self.allocator, evaluated);
}
}
continue; // Don't output class as regular attribute
}
}
// Output combined class attribute immediately after id (before other attributes)
if (all_classes.items.len > 0) {
try self.write(" class=\"");
try self.writeEscaped(all_classes.items);
try self.write("\"");
}
// Output other attributes (skip class since already handled)
for (elem.attributes) |attr| {
if (std.mem.eql(u8, attr.name, "class")) continue;
if (attr.value) |value| {
// Handle boolean literals: true -> checked="checked", false -> omit
if (std.mem.eql(u8, value, "true")) {
// true becomes attribute="attribute"
try self.write(" ");
try self.write(attr.name);
try self.write("=\"");
try self.write(attr.name);
try self.write("\"");
} else if (std.mem.eql(u8, value, "false")) {
// false omits the attribute entirely
continue;
} else {
try self.write(" ");
try self.write(attr.name);
try self.write("=\"");
// Evaluate attribute value - could be a quoted string, object/array literal, or variable
var evaluated: []const u8 = undefined;
// Check if it's a quoted string, object literal, or array literal
if (value.len >= 2 and (value[0] == '"' or value[0] == '\'' or value[0] == '`')) {
// Quoted string - strip quotes
evaluated = try self.evaluateString(value);
} else if (value.len >= 1 and (value[0] == '{' or value[0] == '[')) {
// Object or array literal - use as-is
evaluated = value;
} else {
// Unquoted - evaluate as expression (variable lookup)
const expr_value = self.evaluateExpression(value);
evaluated = try expr_value.toString(self.allocator);
}
// Special handling for style attribute with object literal
if (std.mem.eql(u8, attr.name, "style") and evaluated.len > 0 and evaluated[0] == '{') {
evaluated = try parseObjectToCSS(self.allocator, evaluated);
}
@@ -520,13 +653,6 @@ pub const Runtime = struct {
}
}
// Output combined class attribute
if (all_classes.items.len > 0) {
try self.write(" class=\"");
try self.writeEscaped(all_classes.items);
try self.write("\"");
}
// Output spread attributes: &attributes({'data-foo': 'bar'}) or &attributes(attributes)
if (elem.spread_attributes) |spread| {
// First try to evaluate as a variable (for mixin attributes)
@@ -553,7 +679,7 @@ pub const Runtime = struct {
}
if (is_void and self.options.self_closing) {
try self.write(" />");
try self.write("/>");
try self.writeNewline();
return;
}
@@ -573,20 +699,60 @@ pub const Runtime = struct {
const value = self.evaluateExpression(code.expression);
const str = try value.toString(self.allocator);
if (code.escaped) {
try self.writeEscaped(str);
try self.writeTextEscaped(str);
} else {
try self.write(str);
}
}
if (has_children) {
if (!has_inline and !has_buffered) try self.writeNewline();
self.depth += 1;
for (elem.children) |child| {
try self.visitNode(child);
// Check if single text child - render inline (like blockquote with one piped line)
const single_text = elem.children.len == 1 and elem.children[0] == .text;
// Check for whitespace-preserving elements (pre, script, style, textarea)
const preserve_ws = isWhitespacePreserving(elem.tag);
if (single_text) {
// Render single text child inline (no newlines/indents)
try self.writeTextSegments(elem.children[0].text.segments);
} else if (elem.is_inline and canRenderInlineForParent(elem)) {
// Block expansion (`:` syntax) - render children inline only in specific cases
for (elem.children) |child| {
try self.visitNodeInline(child);
}
} else if (preserve_ws) {
// Whitespace-preserving element - render content without extra formatting
for (elem.children) |child| {
switch (child) {
.raw_text => |raw| {
// Check if content has multiple lines - if so, add leading newline
// Single-line content renders inline and stripped: <script>var x = 1;</script>
// Multi-line content has newline: <script>\n if (x) {\n }\n</script>
const has_multiple_lines = std.mem.indexOfScalar(u8, raw.content, '\n') != null;
if (has_multiple_lines and !has_inline and !has_buffered) {
try self.write("\n");
try self.writeRawTextPreserved(raw.content);
} else {
// Single line - strip leading whitespace
const stripped = std.mem.trimLeft(u8, raw.content, " \t");
try self.write(stripped);
}
},
.element => |child_elem| {
// Nested element in whitespace-preserving context (e.g., pre > code)
try self.visitElementPreserved(child_elem);
},
else => try self.visitNode(child),
}
}
} else {
if (!has_inline and !has_buffered) try self.writeNewline();
self.depth += 1;
for (elem.children) |child| {
try self.visitNode(child);
}
self.depth -= 1;
if (!has_inline and !has_buffered) try self.writeIndent();
}
self.depth -= 1;
if (!has_inline and !has_buffered) try self.writeIndent();
}
try self.write("</");
@@ -601,17 +767,111 @@ pub const Runtime = struct {
try self.writeNewline();
}
/// Writes raw text content as-is.
fn writeRawTextPreserved(self: *Runtime, content: []const u8) Error!void {
try self.write(content);
}
/// Renders an element within a whitespace-preserving context (no indentation/newlines)
fn visitElementPreserved(self: *Runtime, elem: ast.Element) Error!void {
try self.write("<");
try self.write(elem.tag);
// Output classes
if (elem.classes.len > 0) {
try self.write(" class=\"");
for (elem.classes, 0..) |class, i| {
if (i > 0) try self.write(" ");
try self.writeEscaped(class);
}
try self.write("\"");
}
// Output attributes
for (elem.attributes) |attr| {
if (attr.value) |value| {
try self.write(" ");
try self.write(attr.name);
try self.write("=\"");
var evaluated: []const u8 = undefined;
if (value.len >= 2 and (value[0] == '"' or value[0] == '\'' or value[0] == '`')) {
evaluated = try self.evaluateString(value);
} else {
const expr_value = self.evaluateExpression(value);
evaluated = try expr_value.toString(self.allocator);
}
if (attr.escaped) {
try self.writeEscaped(evaluated);
} else {
try self.write(evaluated);
}
try self.write("\"");
} else {
try self.write(" ");
try self.write(attr.name);
try self.write("=\"");
try self.write(attr.name);
try self.write("\"");
}
}
try self.write(">");
// Render children without formatting
for (elem.children) |child| {
switch (child) {
.raw_text => |raw| try self.writeRawTextPreserved(raw.content),
.text => |text| try self.writeTextSegments(text.segments),
else => {},
}
}
try self.write("</");
try self.write(elem.tag);
try self.write(">");
}
fn visitComment(self: *Runtime, comment: ast.Comment) Error!void {
if (!comment.rendered) return;
try self.writeIndent();
try self.write("<!--");
if (comment.content.len > 0) {
try self.write(" ");
try self.write(comment.content);
try self.write(" ");
// Check if this is a block comment (has children)
if (comment.children.len > 0) {
// Block comment: render children as raw text inside comment
// Content already includes leading space if present (e.g., " foo" from "// foo")
if (comment.content.len > 0) {
try self.write(comment.content);
}
try self.writeNewline();
// Render children as raw content (they are stored as raw_text nodes)
for (comment.children) |child| {
switch (child) {
.raw_text => |raw| {
try self.write(raw.content);
try self.writeNewline();
},
.comment => |nested| {
// Nested comment inside block comment - render as text
if (nested.rendered) {
try self.write("// ");
try self.write(nested.content);
try self.writeNewline();
}
},
else => {},
}
}
try self.write("-->");
} else {
// Inline comment
// Content already includes leading space if present (e.g., " foo" from "// foo")
if (comment.content.len > 0) {
try self.write(comment.content);
}
try self.write("-->");
}
try self.write("-->");
try self.writeNewline();
}
@@ -846,7 +1106,14 @@ pub const Runtime = struct {
}
// Set current mixin's block content and attributes
self.mixin_block_content = if (call.block_children.len > 0) call.block_children else null;
// If block content is a single mixin_block node, pass through parent's block content
// to avoid infinite recursion when nesting mixins with `block` passthrough
self.mixin_block_content = blk: {
if (call.block_children.len == 1 and call.block_children[0] == .mixin_block) {
break :blk prev_block_content;
}
break :blk if (call.block_children.len > 0) call.block_children else null;
};
self.mixin_attributes = if (call.attributes.len > 0) call.attributes else null;
// Set 'attributes' variable with the passed attributes as an object
@@ -1025,7 +1292,7 @@ pub const Runtime = struct {
try self.writeIndent();
if (code.escaped) {
try self.writeEscaped(str);
try self.writeTextEscaped(str);
} else {
try self.write(str);
}
@@ -1035,7 +1302,11 @@ pub const Runtime = struct {
fn visitRawText(self: *Runtime, raw: ast.RawText) Error!void {
// Raw text already includes its own indentation, don't add extra
try self.write(raw.content);
try self.writeNewline();
// Only add newline if content doesn't already end with one
// This prevents double newlines at end of dot blocks
if (raw.content.len == 0 or raw.content[raw.content.len - 1] != '\n') {
try self.writeNewline();
}
}
/// Visits a block node, handling inheritance (replace/append/prepend).
@@ -1270,9 +1541,9 @@ pub const Runtime = struct {
return current;
}
/// Evaluates a string value, stripping surrounding quotes if present.
/// Evaluates a string value, stripping surrounding quotes and processing escape sequences.
/// Used for HTML attribute values.
fn evaluateString(_: *Runtime, str: []const u8) ![]const u8 {
fn evaluateString(self: *Runtime, str: []const u8) ![]const u8 {
// Strip surrounding quotes if present (single, double, or backtick)
if (str.len >= 2) {
const first = str[0];
@@ -1281,12 +1552,65 @@ pub const Runtime = struct {
(first == '\'' and last == '\'') or
(first == '`' and last == '`'))
{
return str[1 .. str.len - 1];
const inner = str[1 .. str.len - 1];
// Process escape sequences (e.g., \\ -> \, \n -> newline)
return try self.processEscapeSequences(inner);
}
}
return str;
}
/// Process JavaScript-style escape sequences in strings
fn processEscapeSequences(self: *Runtime, str: []const u8) ![]const u8 {
// Quick check - if no backslashes, return as-is
if (std.mem.indexOfScalar(u8, str, '\\') == null) {
return str;
}
var result = std.ArrayList(u8).empty;
var i: usize = 0;
while (i < str.len) {
if (str[i] == '\\' and i + 1 < str.len) {
const next = str[i + 1];
switch (next) {
'\\' => {
try result.append(self.allocator, '\\');
i += 2;
},
'n' => {
try result.append(self.allocator, '\n');
i += 2;
},
'r' => {
try result.append(self.allocator, '\r');
i += 2;
},
't' => {
try result.append(self.allocator, '\t');
i += 2;
},
'\'' => {
try result.append(self.allocator, '\'');
i += 2;
},
'"' => {
try result.append(self.allocator, '"');
i += 2;
},
else => {
// Unknown escape - keep the backslash and character
try result.append(self.allocator, str[i]);
i += 1;
},
}
} else {
try result.append(self.allocator, str[i]);
i += 1;
}
}
return result.items;
}
// ─────────────────────────────────────────────────────────────────────────
// Output helpers
// ─────────────────────────────────────────────────────────────────────────
@@ -1294,11 +1618,11 @@ pub const Runtime = struct {
fn writeTextSegments(self: *Runtime, segments: []const ast.TextSegment) Error!void {
for (segments) |seg| {
switch (seg) {
.literal => |lit| try self.writeEscaped(lit),
.literal => |lit| try self.writeTextEscaped(lit),
.interp_escaped => |expr| {
const value = self.evaluateExpression(expr);
const str = try value.toString(self.allocator);
try self.writeEscaped(str);
try self.writeTextEscaped(str);
},
.interp_unescaped => |expr| {
const value = self.evaluateExpression(expr);
@@ -1528,7 +1852,118 @@ pub const Runtime = struct {
}
}
/// Lookup table for characters that need HTML escaping
/// Writes text content with HTML escaping (no quote escaping needed in text)
/// Preserves existing HTML entities (e.g., &#8217; stays as &#8217;)
fn writeTextEscaped(self: *Runtime, str: []const u8) Error!void {
var i: usize = 0;
var start: usize = 0;
while (i < str.len) {
const c = str[i];
if (c == '&') {
// Check if this is an existing HTML entity - don't double-escape
if (isHtmlEntity(str[i..])) {
i += 1;
continue;
}
// Not an entity, escape the &
if (i > start) {
const chunk = str[start..i];
const dest = try self.output.addManyAsSlice(self.allocator, chunk.len);
@memcpy(dest, chunk);
}
const esc = "&amp;";
const dest = try self.output.addManyAsSlice(self.allocator, esc.len);
@memcpy(dest, esc);
start = i + 1;
i += 1;
} else if (c == '<') {
if (i > start) {
const chunk = str[start..i];
const dest = try self.output.addManyAsSlice(self.allocator, chunk.len);
@memcpy(dest, chunk);
}
const esc = "&lt;";
const dest = try self.output.addManyAsSlice(self.allocator, esc.len);
@memcpy(dest, esc);
start = i + 1;
i += 1;
} else if (c == '>') {
if (i > start) {
const chunk = str[start..i];
const dest = try self.output.addManyAsSlice(self.allocator, chunk.len);
@memcpy(dest, chunk);
}
const esc = "&gt;";
const dest = try self.output.addManyAsSlice(self.allocator, esc.len);
@memcpy(dest, esc);
start = i + 1;
i += 1;
} else {
i += 1;
}
}
if (start < str.len) {
const chunk = str[start..];
const dest = try self.output.addManyAsSlice(self.allocator, chunk.len);
@memcpy(dest, chunk);
}
}
/// Checks if string starts with an HTML entity (&#nnnn; or &#xhhhh; or &name;)
fn isHtmlEntity(str: []const u8) bool {
if (str.len < 3 or str[0] != '&') return false;
var i: usize = 1;
if (str[i] == '#') {
// Numeric entity: &#nnnn; or &#xhhhh;
i += 1;
if (i >= str.len) return false;
if (str[i] == 'x' or str[i] == 'X') {
// Hex: &#xhhhh;
i += 1;
var has_hex = false;
while (i < str.len and i < 10) : (i += 1) {
const c = str[i];
if (c == ';') return has_hex;
if ((c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F')) {
has_hex = true;
} else {
return false;
}
}
} else {
// Decimal: &#nnnn;
var has_digit = false;
while (i < str.len and i < 10) : (i += 1) {
const c = str[i];
if (c == ';') return has_digit;
if (c >= '0' and c <= '9') {
has_digit = true;
} else {
return false;
}
}
}
} else {
// Named entity: &name;
var has_alpha = false;
while (i < str.len and i < 32) : (i += 1) {
const c = str[i];
if (c == ';') return has_alpha;
if ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9')) {
has_alpha = true;
} else {
return false;
}
}
}
return false;
}
/// Lookup table for characters that need HTML escaping (for attributes - includes quotes)
const escape_table = blk: {
var table: [256]bool = [_]bool{false} ** 256;
table['&'] = true;
@@ -1539,7 +1974,7 @@ pub const Runtime = struct {
break :blk table;
};
/// Escape strings for each character
/// Escape strings for each character (for attributes)
const escape_strings = blk: {
var strings: [256][]const u8 = [_][]const u8{""} ** 256;
strings['&'] = "&amp;";
@@ -1549,6 +1984,24 @@ pub const Runtime = struct {
strings['\''] = "&#x27;";
break :blk strings;
};
/// Lookup table for text content (no quotes - only &, <, >)
const text_escape_table = blk: {
var table: [256]bool = [_]bool{false} ** 256;
table['&'] = true;
table['<'] = true;
table['>'] = true;
break :blk table;
};
/// Escape strings for text content
const text_escape_strings = blk: {
var strings: [256][]const u8 = [_][]const u8{""} ** 256;
strings['&'] = "&amp;";
strings['<'] = "&lt;";
strings['>'] = "&gt;";
break :blk strings;
};
};
// ─────────────────────────────────────────────────────────────────────────────
@@ -1566,6 +2019,50 @@ fn isVoidElement(tag: []const u8) bool {
return void_elements.has(tag);
}
/// Whitespace-preserving elements - don't add indentation or extra newlines
fn isWhitespacePreserving(tag: []const u8) bool {
const ws_elements = std.StaticStringMap(void).initComptime(.{
.{ "pre", {} },
.{ "script", {} },
.{ "style", {} },
.{ "textarea", {} },
});
return ws_elements.has(tag);
}
/// Checks if children can be rendered inline (for block expansion).
/// For inline rendering, the direct child element must have NO content at all
/// (no children, no inline_text, no buffered_code) OR be a void element.
/// e.g., `a: img` can be inline (img is void element)
/// `li: a(href='#') foo` - the `a` has inline_text so renders inline
/// but `li: .foo: #bar baz` cannot (div.foo has child #bar)
/// Checks if a parent element can render its children inline.
/// For block expansion (`:` syntax), inline rendering is only allowed when:
/// - Child has no element children AND
/// - Child was not created via block expansion (not chained) AND
/// - Child has no text/buffered content if parent is in a chain (child.is_inline check handles this)
fn canRenderInlineForParent(parent: ast.Element) bool {
for (parent.children) |child| {
switch (child) {
.element => |elem| {
// If child has element children, can't render inline
if (elem.children.len > 0) return false;
// If child was created via block expansion (chained `:` syntax), can't render inline
// This handles `li: .foo: #bar` where .foo has is_inline=true
if (elem.is_inline) return false;
// If child has content AND parent's child will itself be inline-rendered,
// we need to check if this is a chain. Since parent.is_inline is true (we're here),
// check if any child element has text - if the depth > 1, don't render inline.
// This is approximated by: if child has inline_text AND is followed by `:` somewhere in the chain
// But we can't easily detect chain depth here.
// For now, leave as is - the is_inline check above should handle most cases.
},
else => {},
}
}
return true;
}
/// Parses a JS array literal and converts it to space-separated string.
/// Input: ['foo', 'bar', 'baz']
/// Output: foo bar baz
@@ -1713,6 +2210,97 @@ fn parseObjectToCSS(allocator: std.mem.Allocator, input: []const u8) ![]const u8
return result.toOwnedSlice(allocator);
}
/// Parses a JS object literal for class attribute and returns space-separated class names.
/// Only includes keys where the value is truthy (true, non-empty string, non-zero number).
/// Input: {foo: true, bar: false, baz: true}
/// Output: foo baz
fn parseObjectToClassList(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
const trimmed = std.mem.trim(u8, input, " \t\n\r");
// Must start with { and end with }
if (trimmed.len < 2 or trimmed[0] != '{' or trimmed[trimmed.len - 1] != '}') {
return input; // Not an object, return as-is
}
const content = std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], " \t\n\r");
if (content.len == 0) return "";
var result = std.ArrayList(u8).empty;
errdefer result.deinit(allocator);
var pos: usize = 0;
while (pos < content.len) {
// Skip whitespace
while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t' or content[pos] == '\n' or content[pos] == '\r')) {
pos += 1;
}
if (pos >= content.len) break;
// Parse property name (class name)
const name_start = pos;
while (pos < content.len and content[pos] != ':' and content[pos] != ' ' and content[pos] != ',') {
pos += 1;
}
const name = content[name_start..pos];
// Skip to colon
while (pos < content.len and content[pos] != ':') {
pos += 1;
}
if (pos >= content.len) break;
pos += 1; // skip :
// Skip whitespace
while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t')) {
pos += 1;
}
// Parse value
var value_start = pos;
var value_end = pos;
if (pos < content.len and (content[pos] == '\'' or content[pos] == '"')) {
const quote = content[pos];
pos += 1;
value_start = pos;
while (pos < content.len and content[pos] != quote) {
pos += 1;
}
value_end = pos;
if (pos < content.len) pos += 1; // skip closing quote
} else {
// Unquoted value (true, false, number, variable)
while (pos < content.len and content[pos] != ',' and content[pos] != '}' and content[pos] != ' ') {
pos += 1;
}
value_end = pos;
}
const value = std.mem.trim(u8, content[value_start..value_end], " \t");
// Check if value is truthy
const is_truthy = !std.mem.eql(u8, value, "false") and
!std.mem.eql(u8, value, "null") and
!std.mem.eql(u8, value, "undefined") and
!std.mem.eql(u8, value, "0") and
!std.mem.eql(u8, value, "''") and
!std.mem.eql(u8, value, "\"\"") and
value.len > 0;
if (is_truthy and name.len > 0) {
if (result.items.len > 0) {
try result.append(allocator, ' ');
}
try result.appendSlice(allocator, name);
}
// Skip comma and whitespace
while (pos < content.len and (content[pos] == ' ' or content[pos] == ',' or content[pos] == '\t' or content[pos] == '\n' or content[pos] == '\r')) {
pos += 1;
}
}
return result.toOwnedSlice(allocator);
}
// ─────────────────────────────────────────────────────────────────────────────
// Convenience function
// ─────────────────────────────────────────────────────────────────────────────
@@ -1750,7 +2338,16 @@ pub fn renderTemplate(allocator: std.mem.Allocator, source: []const u8, data: an
/// Renders a pre-parsed document with the given data context.
/// Use this when you want to parse once and render multiple times with different data.
/// Options for render function.
pub const RenderOptions = struct {
pretty: bool = true,
};
pub fn render(allocator: std.mem.Allocator, doc: ast.Document, data: anytype) ![]u8 {
return renderWithOptions(allocator, doc, data, .{});
}
pub fn renderWithOptions(allocator: std.mem.Allocator, doc: ast.Document, data: anytype, opts: RenderOptions) ![]u8 {
var ctx = Context.init(allocator);
defer ctx.deinit();
@@ -1761,7 +2358,7 @@ pub fn render(allocator: std.mem.Allocator, doc: ast.Document, data: anytype) ![
try ctx.set(field.name, toValue(allocator, value));
}
var runtime = Runtime.init(allocator, &ctx, .{});
var runtime = Runtime.init(allocator, &ctx, .{ .pretty = opts.pretty });
defer runtime.deinit();
return runtime.renderOwned(doc);

View File

@@ -0,0 +1,6 @@
<foo data-user="{&quot;name&quot;:&quot;tobi&quot;}"></foo>
<foo data-items="[1,2,3]"></foo>
<foo data-username="tobi"></foo>
<foo data-escaped="{&quot;message&quot;:&quot;Let's rock!&quot;}"></foo>
<foo data-ampersand="{&quot;message&quot;:&quot;a quote: &amp;quot; this &amp; that&quot;}"></foo>
<foo data-epoc="1970-01-01T00:00:00.000Z"></foo>

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,4 @@
<div :my-var="model"></div>
<span v-for="item in items" :key="item.id" :value="item.name"></span>
<span v-for="item in items" :key="item.id" :value="item.name"></span>
<a :link="goHere" value="static" :my-value="dynamic" @click="onClick()" :another="more">Click Me!</a>

View File

@@ -0,0 +1,9 @@
//- Tests for using a colon-prefexed attribute (typical when using short-cut for Vue.js `v-bind`)
div(:my-var="model")
span(v-for="item in items" :key="item.id" :value="item.name")
span(
v-for="item in items"
:key="item.id"
:value="item.name"
)
a(:link="goHere" value="static" :my-value="dynamic" @click="onClick()" :another="more") Click Me!

View File

@@ -0,0 +1,20 @@
<a href="/contact">contact</a><a class="button" href="/save">save</a><a foo="foo" bar="bar" baz="baz"></a><a foo="foo, bar, baz" bar="1"></a><a foo="((foo))" bar="1"></a>
<select>
<option value="foo" selected="selected">Foo</option>
<option selected="selected" value="bar">Bar</option>
</select><a foo="class:"></a>
<input pattern="\S+"/><a href="/contact">contact</a><a class="button" href="/save">save</a><a foo="foo" bar="bar" baz="baz"></a><a foo="foo, bar, baz" bar="1"></a><a foo="((foo))" bar="1"></a>
<select>
<option value="foo" selected="selected">Foo</option>
<option selected="selected" value="bar">Bar</option>
</select><a foo="class:"></a>
<input pattern="\S+"/>
<foo terse="true"></foo>
<foo date="1970-01-01T00:00:00.000Z"></foo>
<foo abc="abc" def="def"></foo>
<foo abc="abc" def="def"></foo>
<foo abc="abc" def="def"></foo>
<foo abc="abc" def="def"></foo>
<foo abc="abc" def="def"></foo>
<foo abc="abc" def="def"></foo>
<div foo="bar" bar="<baz>"></div><a foo="foo" bar="bar"></a><a foo="foo" bar="bar"></a>

View File

@@ -0,0 +1,5 @@
<a class="button" href="/user/5"></a><a class="button" href="/user/5"></a>
<meta key="answer" value="42"/><a class="class1 class2"></a><a class="tag-class class1 class2"></a><a class="button" href="/user/5"></a><a class="button" href="/user/5"></a>
<meta key="answer" value="42"/><a class="class1 class2"></a><a class="tag-class class1 class2"></a>
<div id="5" foo="bar"></div>
<div baz="baz"></div>

View File

@@ -0,0 +1,17 @@
- 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'})

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,5 @@
<script type="text/x-template">
<div id="user-<%= user.id %>">
<h1><%= user.title %></h1>
</div>
</script>

View File

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

View File

@@ -0,0 +1 @@
block content

View File

@@ -0,0 +1,4 @@
mixin test()
.test&attributes(attributes)
+test()

View File

@@ -0,0 +1,8 @@
doctype html
html
head
title Default title
body
block body
.container
block content

View File

@@ -0,0 +1,6 @@
extends window.pug
block window-content
.dialog
block content

View File

@@ -0,0 +1,2 @@
block test

View File

@@ -0,0 +1,3 @@
<script>
console.log("foo\nbar")
</script>

View File

@@ -0,0 +1,5 @@
extends empty-block.pug
block test
div test1

View File

@@ -0,0 +1,5 @@
extends empty-block.pug
block test
div test2

View File

@@ -0,0 +1,4 @@
extends /auxiliary/layout.pug
block content
include /auxiliary/include-from-root.pug

View File

@@ -0,0 +1,4 @@
extends ../../cases/auxiliary/layout
block content
include ../../cases/auxiliary/include-from-root

View File

@@ -0,0 +1,8 @@
html
head
style(type="text/css")
:less
@pad: 15px;
body {
padding: @pad;
}

View File

@@ -0,0 +1,8 @@
var STRING_SUBSTITUTIONS = {
// table of character substitutions
'\t': '\\t',
'\r': '\\r',
'\n': '\\n',
'"': '\\"',
'\\': '\\\\',
};

View File

@@ -0,0 +1 @@
h1 hello

View File

@@ -0,0 +1,11 @@
mixin article()
article
block
html
head
title My Application
block head
body
+article
block content

View File

@@ -0,0 +1,2 @@
h1 grand-grandparent
block grand-grandparent

View File

@@ -0,0 +1,6 @@
extends inheritance.extend.recursive-grand-grandparent.pug
block grand-grandparent
h2 grandparent
block grandparent

View File

@@ -0,0 +1,5 @@
extends inheritance.extend.recursive-grandparent.pug
block grandparent
h3 parent
block parent

View File

@@ -0,0 +1,7 @@
html
head
title My Application
block head
body
block content
include window.pug

View File

@@ -0,0 +1,6 @@
html
head
title My Application
block head
body
block content

View File

@@ -0,0 +1,3 @@
mixin slide
section.slide
block

View File

@@ -0,0 +1,3 @@
mixin foo()
p bar

View File

@@ -0,0 +1,3 @@
.pet
h1 {{name}}
p {{name}} is a {{species}} that is {{age}} old

View File

@@ -0,0 +1 @@
<p>:)</p>

View File

@@ -0,0 +1,4 @@
.window
a(href='#').close Close
block window-content

View File

@@ -0,0 +1,10 @@
html
head
title
body
h1 Page
#content
#content-wrapper
yield
#footer
stuff

View File

@@ -0,0 +1,5 @@
<html>
<body>
<h1>Title</h1>
</body>
</html>

View File

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

View File

@@ -0,0 +1,5 @@
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>

View File

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

View File

@@ -0,0 +1,5 @@
<ul>
<li><a href="#">foo</a></li>
<li><a href="#">bar</a></li>
</ul>
<p>baz</p>

View File

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

View File

@@ -0,0 +1,5 @@
<ul>
<li class="list-item">
<div class="foo"><div id="bar">baz</div></div>
</li>
</ul>

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.</blockquote>
<figcaption>from @thefray at 1:43pm on May 10</figcaption>
</figure>

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,9 @@
<!DOCTYPE html>
<html>
<head>
<title>Default title</title>
</head>
<body>
<h1>Page 2</h1>
</body>
</html>

View File

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

View File

@@ -0,0 +1 @@
<p>ajax contents</p>

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,5 @@
<html>
<body>
<p>you have a friend</p>
</body>
</html>

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,8 @@
<html>
<body>
<p>you have a friend</p>
<p>you have very few friends</p>
<p>Friend is a string</p>
</body>
</html>

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></a>
<a></a>
<a></a>

View File

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

View File

@@ -0,0 +1 @@
<a class="foo bar baz"></a><a class="foo bar baz"></a><a class="foo-bar_baz"></a><a class="foo baz"></a>

View File

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

View File

@@ -0,0 +1,11 @@
<p>foo</p>
<p>foo</p>
<p>foo</p>
<p>bar</p>
<p>baz</p>
<p>bar</p>
<p>yay</p>
<div class="bar"></div>
<div class="bar"></div>
<div class="bing"></div>
<div class="foo"></div>

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>&lt;script&gt;</p>
<p><script></p>

View File

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

View File

@@ -0,0 +1,10 @@
<p></p>
<p></p>
<p></p>
<p>0</p>
<p>false</p>
<p></p>
<p></p>
<p foo=""></p>
<p foo="0"></p>
<p></p>

View File

@@ -0,0 +1,36 @@
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
<ul>
<li class="item-0">1</li>
<li class="item-1">2</li>
<li class="item-2">3</li>
</ul>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
<ul>
<li>1: a</li>
<li>2: a</li>
<li>3: a</li>
<li>1: b</li>
<li>2: b</li>
<li>3: b</li>
<li>1: c</li>
<li>2: c</li>
<li>3: c</li>
</ul>
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>

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,6 @@
<!DOCTYPE html>
<html>
<body>
<p>It's this!</p>
</body>
</html>

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,32 @@
<!-- foo-->
<ul>
<!-- bar-->
<li>one</li>
<!-- baz-->
<li>two</li>
</ul>
<!--
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</p>
<div class="foo">// not a comment</div>

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

0
root → src/tests/check_list/comments.source.html Executable file → Normal file
View File

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 @@
doctype custom stuff

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body>
<h1>Title</h1>
</body>
</html>

View File

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

View File

@@ -0,0 +1 @@
<!DOCTYPE html>

View File

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

View File

@@ -0,0 +1,17 @@
<ul>
<li>no users!</li>
</ul>
<ul>
<li>tobi</li>
<li>loki</li>
</ul>
<ul>
<li>name: tobi</li>
<li>age: 10</li>
</ul>
<ul>
<li>user has no details!</li>
</ul>
<ul>
<li>name: tobi</li>
</ul>

View File

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

View File

@@ -0,0 +1 @@
<script>var re = /\d+/;</script>

View File

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

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>escape-test</title>
</head>
<body>
<textarea>&lt;param name=&quot;flashvars&quot; value=&quot;a=&amp;quot;value_a&amp;quot;&amp;b=&amp;quot;value_b&amp;quot;&amp;c=3&quot;/&gt;</textarea>
</body>
</html>

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}

View File

@@ -0,0 +1,6 @@
<foo attr="&lt;%= bar %&gt;"></foo>
<foo class="&lt;%= bar %&gt;"></foo>
<foo attr="<%= bar %>"></foo>
<foo class="<%= bar %>"></foo>
<foo class="<%= bar %> lol rofl"></foo>
<foo class="<%= bar %> lol rofl <%= lmao %>"></foo>

View File

@@ -0,0 +1,6 @@
foo(attr="<%= bar %>")
foo(class="<%= bar %>")
foo(attr!="<%= bar %>")
foo(class!="<%= bar %>")
foo(class!="<%= bar %> lol rofl")
foo(class!="<%= bar %> lol rofl <%= lmao %>")

View File

@@ -0,0 +1,7 @@
<html>
<head><style type="text/css">body {
padding: 15px;
}
</style>
</head>
</html>

View File

@@ -0,0 +1 @@
include ./auxiliary/filter-in-include.pug

View File

@@ -0,0 +1,4 @@
<fb:users>
<fb:user age="2"><![CDATA[]]>
</fb:user>
</fb:users>

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