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:
@@ -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.
|
||||
|
||||
17
build.zig
17
build.zig
@@ -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/
|
||||
|
||||
@@ -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 = .{},
|
||||
|
||||
@@ -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>");
|
||||
|
||||
60
src/ast.zig
60
src/ast.zig
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(">");
|
||||
|
||||
472
src/compiler.zig
472
src/compiler.zig
@@ -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['&'] = "&";
|
||||
\\ t['<'] = "<";
|
||||
\\ t['>'] = ">";
|
||||
\\ t['"'] = """;
|
||||
\\ t['\''] = "'";
|
||||
\\ 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});
|
||||
}
|
||||
182
src/lexer.zig
182
src/lexer.zig
@@ -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();
|
||||
}
|
||||
|
||||
100
src/parser.zig
100
src/parser.zig
@@ -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,7 +266,8 @@ pub const Parser = struct {
|
||||
try children.append(self.allocator, child);
|
||||
}
|
||||
|
||||
return .{ .element = .{
|
||||
return .{
|
||||
.element = .{
|
||||
.tag = tag,
|
||||
.classes = try classes.toOwnedSlice(self.allocator),
|
||||
.id = id,
|
||||
@@ -257,7 +277,9 @@ pub const Parser = struct {
|
||||
.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
669
src/runtime.zig
669
src/runtime.zig
@@ -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)
|
||||
@@ -573,13 +699,52 @@ 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) {
|
||||
// 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| {
|
||||
@@ -588,6 +753,7 @@ pub const Runtime = struct {
|
||||
self.depth -= 1;
|
||||
if (!has_inline and !has_buffered) try self.writeIndent();
|
||||
}
|
||||
}
|
||||
|
||||
try self.write("</");
|
||||
try self.write(elem.tag);
|
||||
@@ -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("<!--");
|
||||
|
||||
// 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(" ");
|
||||
try self.write(comment.content);
|
||||
try self.write(" ");
|
||||
}
|
||||
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.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,8 +1302,12 @@ 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);
|
||||
// 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).
|
||||
fn visitBlock(self: *Runtime, blk: ast.Block) Error!void {
|
||||
@@ -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., ’ stays as ’)
|
||||
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 = "&";
|
||||
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 = "<";
|
||||
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 = ">";
|
||||
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['&'] = "&";
|
||||
@@ -1549,6 +1984,24 @@ pub const Runtime = struct {
|
||||
strings['\''] = "'";
|
||||
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['&'] = "&";
|
||||
strings['<'] = "<";
|
||||
strings['>'] = ">";
|
||||
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);
|
||||
|
||||
6
src/tests/check_list/attrs-data.html
Normal file
6
src/tests/check_list/attrs-data.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<foo data-user="{"name":"tobi"}"></foo>
|
||||
<foo data-items="[1,2,3]"></foo>
|
||||
<foo data-username="tobi"></foo>
|
||||
<foo data-escaped="{"message":"Let's rock!"}"></foo>
|
||||
<foo data-ampersand="{"message":"a quote: &quot; this & that"}"></foo>
|
||||
<foo data-epoc="1970-01-01T00:00:00.000Z"></foo>
|
||||
7
src/tests/check_list/attrs-data.pug
Normal file
7
src/tests/check_list/attrs-data.pug
Normal 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: " this & that"})
|
||||
foo(data-epoc=new Date(0))
|
||||
4
src/tests/check_list/attrs.colon.html
Normal file
4
src/tests/check_list/attrs.colon.html
Normal 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>
|
||||
9
src/tests/check_list/attrs.colon.pug
Normal file
9
src/tests/check_list/attrs.colon.pug
Normal 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!
|
||||
20
src/tests/check_list/attrs.html
Normal file
20
src/tests/check_list/attrs.html
Normal 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>
|
||||
5
src/tests/check_list/attrs.js.html
Normal file
5
src/tests/check_list/attrs.js.html
Normal 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>
|
||||
17
src/tests/check_list/attrs.js.pug
Normal file
17
src/tests/check_list/attrs.js.pug
Normal 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'})
|
||||
43
src/tests/check_list/attrs.pug
Normal file
43
src/tests/check_list/attrs.pug
Normal 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')
|
||||
5
src/tests/check_list/attrs.unescaped.html
Normal file
5
src/tests/check_list/attrs.unescaped.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<script type="text/x-template">
|
||||
<div id="user-<%= user.id %>">
|
||||
<h1><%= user.title %></h1>
|
||||
</div>
|
||||
</script>
|
||||
3
src/tests/check_list/attrs.unescaped.pug
Normal file
3
src/tests/check_list/attrs.unescaped.pug
Normal file
@@ -0,0 +1,3 @@
|
||||
script(type='text/x-template')
|
||||
div(id!='user-<%= user.id %>')
|
||||
h1 <%= user.title %>
|
||||
1
src/tests/check_list/auxiliary/1794-extends.pug
Normal file
1
src/tests/check_list/auxiliary/1794-extends.pug
Normal file
@@ -0,0 +1 @@
|
||||
block content
|
||||
4
src/tests/check_list/auxiliary/1794-include.pug
Normal file
4
src/tests/check_list/auxiliary/1794-include.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
mixin test()
|
||||
.test&attributes(attributes)
|
||||
|
||||
+test()
|
||||
@@ -0,0 +1,8 @@
|
||||
doctype html
|
||||
html
|
||||
head
|
||||
title Default title
|
||||
body
|
||||
block body
|
||||
.container
|
||||
block content
|
||||
6
src/tests/check_list/auxiliary/dialog.pug
Normal file
6
src/tests/check_list/auxiliary/dialog.pug
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
extends window.pug
|
||||
|
||||
block window-content
|
||||
.dialog
|
||||
block content
|
||||
2
src/tests/check_list/auxiliary/empty-block.pug
Normal file
2
src/tests/check_list/auxiliary/empty-block.pug
Normal file
@@ -0,0 +1,2 @@
|
||||
block test
|
||||
|
||||
3
src/tests/check_list/auxiliary/escapes.html
Normal file
3
src/tests/check_list/auxiliary/escapes.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<script>
|
||||
console.log("foo\nbar")
|
||||
</script>
|
||||
5
src/tests/check_list/auxiliary/extends-empty-block-1.pug
Normal file
5
src/tests/check_list/auxiliary/extends-empty-block-1.pug
Normal file
@@ -0,0 +1,5 @@
|
||||
extends empty-block.pug
|
||||
|
||||
block test
|
||||
div test1
|
||||
|
||||
5
src/tests/check_list/auxiliary/extends-empty-block-2.pug
Normal file
5
src/tests/check_list/auxiliary/extends-empty-block-2.pug
Normal file
@@ -0,0 +1,5 @@
|
||||
extends empty-block.pug
|
||||
|
||||
block test
|
||||
div test2
|
||||
|
||||
4
src/tests/check_list/auxiliary/extends-from-root.pug
Normal file
4
src/tests/check_list/auxiliary/extends-from-root.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
extends /auxiliary/layout.pug
|
||||
|
||||
block content
|
||||
include /auxiliary/include-from-root.pug
|
||||
4
src/tests/check_list/auxiliary/extends-relative.pug
Normal file
4
src/tests/check_list/auxiliary/extends-relative.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
extends ../../cases/auxiliary/layout
|
||||
|
||||
block content
|
||||
include ../../cases/auxiliary/include-from-root
|
||||
8
src/tests/check_list/auxiliary/filter-in-include.pug
Normal file
8
src/tests/check_list/auxiliary/filter-in-include.pug
Normal file
@@ -0,0 +1,8 @@
|
||||
html
|
||||
head
|
||||
style(type="text/css")
|
||||
:less
|
||||
@pad: 15px;
|
||||
body {
|
||||
padding: @pad;
|
||||
}
|
||||
8
src/tests/check_list/auxiliary/includable.js
Normal file
8
src/tests/check_list/auxiliary/includable.js
Normal file
@@ -0,0 +1,8 @@
|
||||
var STRING_SUBSTITUTIONS = {
|
||||
// table of character substitutions
|
||||
'\t': '\\t',
|
||||
'\r': '\\r',
|
||||
'\n': '\\n',
|
||||
'"': '\\"',
|
||||
'\\': '\\\\',
|
||||
};
|
||||
1
src/tests/check_list/auxiliary/include-from-root.pug
Normal file
1
src/tests/check_list/auxiliary/include-from-root.pug
Normal file
@@ -0,0 +1 @@
|
||||
h1 hello
|
||||
@@ -0,0 +1,11 @@
|
||||
mixin article()
|
||||
article
|
||||
block
|
||||
|
||||
html
|
||||
head
|
||||
title My Application
|
||||
block head
|
||||
body
|
||||
+article
|
||||
block content
|
||||
@@ -0,0 +1,2 @@
|
||||
h1 grand-grandparent
|
||||
block grand-grandparent
|
||||
@@ -0,0 +1,6 @@
|
||||
extends inheritance.extend.recursive-grand-grandparent.pug
|
||||
|
||||
block grand-grandparent
|
||||
h2 grandparent
|
||||
block grandparent
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
extends inheritance.extend.recursive-grandparent.pug
|
||||
|
||||
block grandparent
|
||||
h3 parent
|
||||
block parent
|
||||
7
src/tests/check_list/auxiliary/layout.include.pug
Normal file
7
src/tests/check_list/auxiliary/layout.include.pug
Normal file
@@ -0,0 +1,7 @@
|
||||
html
|
||||
head
|
||||
title My Application
|
||||
block head
|
||||
body
|
||||
block content
|
||||
include window.pug
|
||||
6
src/tests/check_list/auxiliary/layout.pug
Normal file
6
src/tests/check_list/auxiliary/layout.pug
Normal file
@@ -0,0 +1,6 @@
|
||||
html
|
||||
head
|
||||
title My Application
|
||||
block head
|
||||
body
|
||||
block content
|
||||
3
src/tests/check_list/auxiliary/mixin-at-end-of-file.pug
Normal file
3
src/tests/check_list/auxiliary/mixin-at-end-of-file.pug
Normal file
@@ -0,0 +1,3 @@
|
||||
mixin slide
|
||||
section.slide
|
||||
block
|
||||
3
src/tests/check_list/auxiliary/mixins.pug
Normal file
3
src/tests/check_list/auxiliary/mixins.pug
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
mixin foo()
|
||||
p bar
|
||||
3
src/tests/check_list/auxiliary/pet.pug
Normal file
3
src/tests/check_list/auxiliary/pet.pug
Normal file
@@ -0,0 +1,3 @@
|
||||
.pet
|
||||
h1 {{name}}
|
||||
p {{name}} is a {{species}} that is {{age}} old
|
||||
1
src/tests/check_list/auxiliary/smile.html
Normal file
1
src/tests/check_list/auxiliary/smile.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>:)</p>
|
||||
4
src/tests/check_list/auxiliary/window.pug
Normal file
4
src/tests/check_list/auxiliary/window.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
.window
|
||||
a(href='#').close Close
|
||||
block window-content
|
||||
10
src/tests/check_list/auxiliary/yield-nested.pug
Normal file
10
src/tests/check_list/auxiliary/yield-nested.pug
Normal file
@@ -0,0 +1,10 @@
|
||||
html
|
||||
head
|
||||
title
|
||||
body
|
||||
h1 Page
|
||||
#content
|
||||
#content-wrapper
|
||||
yield
|
||||
#footer
|
||||
stuff
|
||||
5
src/tests/check_list/basic.html
Normal file
5
src/tests/check_list/basic.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<html>
|
||||
<body>
|
||||
<h1>Title</h1>
|
||||
</body>
|
||||
</html>
|
||||
3
src/tests/check_list/basic.pug
Normal file
3
src/tests/check_list/basic.pug
Normal file
@@ -0,0 +1,3 @@
|
||||
html
|
||||
body
|
||||
h1 Title
|
||||
5
src/tests/check_list/blanks.html
Normal file
5
src/tests/check_list/blanks.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<ul>
|
||||
<li>foo</li>
|
||||
<li>bar</li>
|
||||
<li>baz</li>
|
||||
</ul>
|
||||
8
src/tests/check_list/blanks.pug
Normal file
8
src/tests/check_list/blanks.pug
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
ul
|
||||
li foo
|
||||
|
||||
li bar
|
||||
|
||||
li baz
|
||||
5
src/tests/check_list/block-expansion.html
Normal file
5
src/tests/check_list/block-expansion.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<ul>
|
||||
<li><a href="#">foo</a></li>
|
||||
<li><a href="#">bar</a></li>
|
||||
</ul>
|
||||
<p>baz</p>
|
||||
5
src/tests/check_list/block-expansion.pug
Normal file
5
src/tests/check_list/block-expansion.pug
Normal file
@@ -0,0 +1,5 @@
|
||||
ul
|
||||
li: a(href='#') foo
|
||||
li: a(href='#') bar
|
||||
|
||||
p baz
|
||||
5
src/tests/check_list/block-expansion.shorthands.html
Normal file
5
src/tests/check_list/block-expansion.shorthands.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<ul>
|
||||
<li class="list-item">
|
||||
<div class="foo"><div id="bar">baz</div></div>
|
||||
</li>
|
||||
</ul>
|
||||
2
src/tests/check_list/block-expansion.shorthands.pug
Normal file
2
src/tests/check_list/block-expansion.shorthands.pug
Normal file
@@ -0,0 +1,2 @@
|
||||
ul
|
||||
li.list-item: .foo: #bar baz
|
||||
4
src/tests/check_list/blockquote.html
Normal file
4
src/tests/check_list/blockquote.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<figure>
|
||||
<blockquote>Try to define yourself by what you do, and you’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>
|
||||
4
src/tests/check_list/blockquote.pug
Normal file
4
src/tests/check_list/blockquote.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
figure
|
||||
blockquote
|
||||
| Try to define yourself by what you do, and you’ll burnout every time. You are. That is enough. I rest in that.
|
||||
figcaption from @thefray at 1:43pm on May 10
|
||||
9
src/tests/check_list/blocks-in-blocks.html
Normal file
9
src/tests/check_list/blocks-in-blocks.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Default title</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Page 2</h1>
|
||||
</body>
|
||||
</html>
|
||||
4
src/tests/check_list/blocks-in-blocks.pug
Normal file
4
src/tests/check_list/blocks-in-blocks.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
extends ./auxiliary/blocks-in-blocks-layout.pug
|
||||
|
||||
block body
|
||||
h1 Page 2
|
||||
1
src/tests/check_list/blocks-in-if.html
Normal file
1
src/tests/check_list/blocks-in-if.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>ajax contents</p>
|
||||
19
src/tests/check_list/blocks-in-if.pug
Normal file
19
src/tests/check_list/blocks-in-if.pug
Normal 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
|
||||
5
src/tests/check_list/case-blocks.html
Normal file
5
src/tests/check_list/case-blocks.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<html>
|
||||
<body>
|
||||
<p>you have a friend</p>
|
||||
</body>
|
||||
</html>
|
||||
10
src/tests/check_list/case-blocks.pug
Normal file
10
src/tests/check_list/case-blocks.pug
Normal 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
|
||||
8
src/tests/check_list/case.html
Normal file
8
src/tests/check_list/case.html
Normal 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>
|
||||
19
src/tests/check_list/case.pug
Normal file
19
src/tests/check_list/case.pug
Normal 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
|
||||
3
src/tests/check_list/classes-empty.html
Normal file
3
src/tests/check_list/classes-empty.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<a></a>
|
||||
<a></a>
|
||||
<a></a>
|
||||
3
src/tests/check_list/classes-empty.pug
Normal file
3
src/tests/check_list/classes-empty.pug
Normal file
@@ -0,0 +1,3 @@
|
||||
a(class='')
|
||||
a(class=null)
|
||||
a(class=undefined)
|
||||
1
src/tests/check_list/classes.html
Normal file
1
src/tests/check_list/classes.html
Normal 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>
|
||||
11
src/tests/check_list/classes.pug
Normal file
11
src/tests/check_list/classes.pug
Normal 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})
|
||||
11
src/tests/check_list/code.conditionals.html
Normal file
11
src/tests/check_list/code.conditionals.html
Normal 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>
|
||||
43
src/tests/check_list/code.conditionals.pug
Normal file
43
src/tests/check_list/code.conditionals.pug
Normal 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
|
||||
2
src/tests/check_list/code.escape.html
Normal file
2
src/tests/check_list/code.escape.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<p><script></p>
|
||||
<p><script></p>
|
||||
2
src/tests/check_list/code.escape.pug
Normal file
2
src/tests/check_list/code.escape.pug
Normal file
@@ -0,0 +1,2 @@
|
||||
p= '<script>'
|
||||
p!= '<script>'
|
||||
10
src/tests/check_list/code.html
Normal file
10
src/tests/check_list/code.html
Normal 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>
|
||||
36
src/tests/check_list/code.iteration.html
Normal file
36
src/tests/check_list/code.iteration.html
Normal 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>
|
||||
35
src/tests/check_list/code.iteration.pug
Normal file
35
src/tests/check_list/code.iteration.pug
Normal 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}
|
||||
10
src/tests/check_list/code.pug
Normal file
10
src/tests/check_list/code.pug
Normal 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)
|
||||
6
src/tests/check_list/comments-in-case.html
Normal file
6
src/tests/check_list/comments-in-case.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>It's this!</p>
|
||||
</body>
|
||||
</html>
|
||||
10
src/tests/check_list/comments-in-case.pug
Normal file
10
src/tests/check_list/comments-in-case.pug
Normal 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!
|
||||
32
src/tests/check_list/comments.html
Normal file
32
src/tests/check_list/comments.html
Normal 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>
|
||||
29
src/tests/check_list/comments.pug
Normal file
29
src/tests/check_list/comments.pug
Normal 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
0
root → src/tests/check_list/comments.source.html
Executable file → Normal file
9
src/tests/check_list/comments.source.pug
Normal file
9
src/tests/check_list/comments.source.pug
Normal file
@@ -0,0 +1,9 @@
|
||||
//-
|
||||
s/s.
|
||||
|
||||
//- test/cases/comments.source.pug
|
||||
|
||||
//-
|
||||
test/cases/comments.source.pug
|
||||
when
|
||||
()
|
||||
1
src/tests/check_list/doctype.custom.html
Normal file
1
src/tests/check_list/doctype.custom.html
Normal file
@@ -0,0 +1 @@
|
||||
<!DOCTYPE custom stuff>
|
||||
1
src/tests/check_list/doctype.custom.pug
Normal file
1
src/tests/check_list/doctype.custom.pug
Normal file
@@ -0,0 +1 @@
|
||||
doctype custom stuff
|
||||
6
src/tests/check_list/doctype.default.html
Normal file
6
src/tests/check_list/doctype.default.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Title</h1>
|
||||
</body>
|
||||
</html>
|
||||
4
src/tests/check_list/doctype.default.pug
Normal file
4
src/tests/check_list/doctype.default.pug
Normal file
@@ -0,0 +1,4 @@
|
||||
doctype
|
||||
html
|
||||
body
|
||||
h1 Title
|
||||
1
src/tests/check_list/doctype.keyword.html
Normal file
1
src/tests/check_list/doctype.keyword.html
Normal file
@@ -0,0 +1 @@
|
||||
<!DOCTYPE html>
|
||||
1
src/tests/check_list/doctype.keyword.pug
Normal file
1
src/tests/check_list/doctype.keyword.pug
Normal file
@@ -0,0 +1 @@
|
||||
doctype html
|
||||
17
src/tests/check_list/each.else.html
Normal file
17
src/tests/check_list/each.else.html
Normal 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>
|
||||
43
src/tests/check_list/each.else.pug
Normal file
43
src/tests/check_list/each.else.pug
Normal 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!
|
||||
1
src/tests/check_list/escape-chars.html
Normal file
1
src/tests/check_list/escape-chars.html
Normal file
@@ -0,0 +1 @@
|
||||
<script>var re = /\d+/;</script>
|
||||
2
src/tests/check_list/escape-chars.pug
Normal file
2
src/tests/check_list/escape-chars.pug
Normal file
@@ -0,0 +1,2 @@
|
||||
script.
|
||||
var re = /\d+/;
|
||||
9
src/tests/check_list/escape-test.html
Normal file
9
src/tests/check_list/escape-test.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>escape-test</title>
|
||||
</head>
|
||||
<body>
|
||||
<textarea><param name="flashvars" value="a=&quot;value_a&quot;&b=&quot;value_b&quot;&c=3"/></textarea>
|
||||
</body>
|
||||
</html>
|
||||
8
src/tests/check_list/escape-test.pug
Normal file
8
src/tests/check_list/escape-test.pug
Normal file
@@ -0,0 +1,8 @@
|
||||
doctype html
|
||||
html
|
||||
head
|
||||
title escape-test
|
||||
body
|
||||
textarea
|
||||
- var txt = '<param name="flashvars" value="a="value_a"&b="value_b"&c=3"/>'
|
||||
| #{txt}
|
||||
6
src/tests/check_list/escaping-class-attribute.html
Normal file
6
src/tests/check_list/escaping-class-attribute.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<foo attr="<%= bar %>"></foo>
|
||||
<foo class="<%= bar %>"></foo>
|
||||
<foo attr="<%= bar %>"></foo>
|
||||
<foo class="<%= bar %>"></foo>
|
||||
<foo class="<%= bar %> lol rofl"></foo>
|
||||
<foo class="<%= bar %> lol rofl <%= lmao %>"></foo>
|
||||
6
src/tests/check_list/escaping-class-attribute.pug
Normal file
6
src/tests/check_list/escaping-class-attribute.pug
Normal 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 %>")
|
||||
7
src/tests/check_list/filter-in-include.html
Normal file
7
src/tests/check_list/filter-in-include.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<html>
|
||||
<head><style type="text/css">body {
|
||||
padding: 15px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</html>
|
||||
1
src/tests/check_list/filter-in-include.pug
Normal file
1
src/tests/check_list/filter-in-include.pug
Normal file
@@ -0,0 +1 @@
|
||||
include ./auxiliary/filter-in-include.pug
|
||||
4
src/tests/check_list/filters-empty.html
Normal file
4
src/tests/check_list/filters-empty.html
Normal 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
Reference in New Issue
Block a user