diff --git a/build.zig.zon b/build.zig.zon index 7b23897..c5d5b80 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .pugz, - .version = "0.3.5", + .version = "0.3.6", .fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications. .minimum_zig_version = "0.15.2", .dependencies = .{}, diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 817bcaa..6a7c06f 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -12,10 +12,11 @@ Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It compiles Pug t - When the user specifies a new rule, update this CLAUDE.md file to include it. - Code comments are required but must be meaningful, not bloated. Focus on explaining "why" not "what". Avoid obvious comments like "// increment counter" - instead explain complex logic, non-obvious decisions, or tricky edge cases. - **All documentation files (.md) must be saved to the `docs/` directory.** Do not create .md files in the root directory or examples directories - always place them in `docs/`. -- **Publish command**: When user says "publish", do the following: +- **Publish command**: Only when user explicitly says "publish", do the following: 1. Bump the fix version (patch version in build.zig.zon) 2. Git commit with appropriate message 3. Git push to remote `origin` and remote `github` + - Do NOT publish automatically or without explicit user request. ## Build Commands @@ -35,9 +36,13 @@ Source → Lexer → Tokens → StripComments → Parser → AST → Linker → ### Three Rendering Modes -1. **Static compilation** (`pug.compile`): Outputs HTML directly -2. **Data binding** (`template.renderWithData`): Supports `#{field}` interpolation with Zig structs -3. **Compiled templates** (`.pug` → `.zig`): Pre-compile templates to Zig functions for maximum performance +1. **Static compilation** (`pug.compile`): Outputs HTML directly via `codegen.zig` +2. **Data binding** (`template.renderWithData`): Supports `#{field}` interpolation with Zig structs via `template.zig` +3. **Compiled templates** (`.pug` → `.zig`): Pre-compile templates to Zig functions via `zig_codegen.zig` + +### Important: Shared AST Consumers + +**codegen.zig**, **template.zig**, and **zig_codegen.zig** all consume the AST from the parser. When fixing bugs related to AST structure (like attribute handling, class merging, etc.), prefer fixing in **parser.zig** so all three rendering paths benefit from the fix automatically. Only fix in the individual codegen modules if the behavior should differ between rendering modes. ### Core Modules diff --git a/src/codegen.zig b/src/codegen.zig index a784968..194f277 100644 --- a/src/codegen.zig +++ b/src/codegen.zig @@ -354,31 +354,19 @@ pub const Compiler = struct { } fn visitAttributes(self: *Compiler, tag: *Node) CompilerError!void { - // Collect class values to merge them into a single attribute - var class_values = std.ArrayListUnmanaged([]const u8){}; - defer class_values.deinit(self.allocator); - - // First pass: collect class values and output non-class attributes for (tag.attrs.items) |attr| { if (attr.val) |val| { - // Check if value should be skipped (empty, null, undefined) - const should_skip = val.len == 0 or - mem.eql(u8, val, "''") or - mem.eql(u8, val, "\"\"") or - mem.eql(u8, val, "null") or - mem.eql(u8, val, "undefined"); - - if (mem.eql(u8, attr.name, "class")) { - // Collect class values to merge later - if (!should_skip) { - try class_values.append(self.allocator, val); + // Skip empty class/style attributes + if (mem.eql(u8, attr.name, "class") or mem.eql(u8, attr.name, "style")) { + // Skip if value is empty, null, or undefined + if (val.len == 0 or + mem.eql(u8, val, "''") or + mem.eql(u8, val, "\"\"") or + mem.eql(u8, val, "null") or + mem.eql(u8, val, "undefined")) + { + continue; } - continue; - } - - // Skip empty style attributes - if (mem.eql(u8, attr.name, "style") and should_skip) { - continue; } // Check for boolean attributes in terse mode @@ -410,19 +398,6 @@ pub const Compiler = struct { try self.write(attr.name); } } - - // Output merged class attribute if any classes were collected - if (class_values.items.len > 0) { - try self.writeChar(' '); - try self.write("class=\""); - for (class_values.items, 0..) |class_val, i| { - if (i > 0) { - try self.writeChar(' '); - } - try self.write(class_val); - } - try self.writeChar('"'); - } } fn visitText(self: *Compiler, text: *Node) CompilerError!void { diff --git a/src/parser.zig b/src/parser.zig index 0a29f0e..4e54308 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -1486,6 +1486,12 @@ pub const Parser = struct { var attribute_names = std.ArrayListUnmanaged([]const u8){}; defer attribute_names.deinit(self.allocator); + // Collect class values to merge into single attribute + var class_values = std.ArrayListUnmanaged([]const u8){}; + defer class_values.deinit(self.allocator); + var class_line: usize = 0; + var class_column: usize = 0; + // (attrs | class | id)* outer: while (true) { switch (self.peek().type) { @@ -1500,21 +1506,30 @@ pub const Parser = struct { } } try attribute_names.append(self.allocator, "id"); + // ID: add directly to attrs + const val_str = tok.val.getString() orelse ""; + const final_val = try self.allocator.dupe(u8, val_str); + try tag.attrs.append(self.allocator, .{ + .name = "id", + .val = final_val, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + .must_escape = false, + .val_owned = true, + .quoted = true, + }); + } else { + // Class: collect for merging later + const val_str = tok.val.getString() orelse ""; + if (val_str.len > 0) { + try class_values.append(self.allocator, val_str); + if (class_line == 0) { + class_line = tok.loc.start.line; + class_column = tok.loc.start.column; + } + } } - // Class/id values from shorthand are always static strings - const val_str = tok.val.getString() orelse ""; - const final_val = try self.allocator.dupe(u8, val_str); - - try tag.attrs.append(self.allocator, .{ - .name = if (tok.type == .id) "id" else "class", - .val = final_val, - .line = tok.loc.start.line, - .column = tok.loc.start.column, - .filename = self.filename, - .must_escape = false, - .val_owned = true, // We allocated this string - .quoted = true, // Shorthand class/id are always static - }); }, .start_attributes => { if (seen_attrs) { @@ -1523,7 +1538,32 @@ pub const Parser = struct { seen_attrs = true; var new_attrs = try self.attrs(&attribute_names); for (new_attrs.items) |attr| { - try tag.attrs.append(self.allocator, attr); + // Collect class attributes for merging + if (mem.eql(u8, attr.name, "class")) { + if (attr.val) |val| { + // Skip empty, null, undefined values + const should_skip = val.len == 0 or + mem.eql(u8, val, "null") or + mem.eql(u8, val, "undefined") or + mem.eql(u8, val, "''") or + mem.eql(u8, val, "\"\""); + if (!should_skip) { + try class_values.append(self.allocator, val); + if (class_line == 0) { + class_line = attr.line; + class_column = attr.column; + } + } + } + // Free owned value since we're collecting it + if (attr.val_owned) { + if (attr.val) |val| { + self.allocator.free(val); + } + } + } else { + try tag.attrs.append(self.allocator, attr); + } } new_attrs.deinit(self.allocator); }, @@ -1540,6 +1580,39 @@ pub const Parser = struct { } } + // Create single merged class attribute if any classes were collected + if (class_values.items.len > 0) { + // Calculate total length needed + var total_len: usize = 0; + for (class_values.items, 0..) |val, i| { + total_len += val.len; + if (i > 0) total_len += 1; // space separator + } + + // Build merged class string + const merged = try self.allocator.alloc(u8, total_len); + var pos: usize = 0; + for (class_values.items, 0..) |val, i| { + if (i > 0) { + merged[pos] = ' '; + pos += 1; + } + @memcpy(merged[pos .. pos + val.len], val); + pos += val.len; + } + + try tag.attrs.append(self.allocator, .{ + .name = "class", + .val = merged, + .line = class_line, + .column = class_column, + .filename = self.filename, + .must_escape = false, + .val_owned = true, + .quoted = true, // Merged classes are static + }); + } + // Check for textOnly (.) if (self.peek().type == .dot) { tag.text_only = true; diff --git a/src/template.zig b/src/template.zig index 2011a74..7706a14 100644 --- a/src/template.zig +++ b/src/template.zig @@ -277,10 +277,6 @@ fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *No try output.appendSlice(allocator, "<"); try output.appendSlice(allocator, name); - // Collect class values separately to merge them into one attribute - var class_parts = std.ArrayListUnmanaged([]const u8){}; - defer class_parts.deinit(allocator); - // Render attributes directly to output buffer (avoids intermediate allocations) for (tag.attrs.items) |attr| { // Substitute mixin arguments in attribute value if we're inside a mixin @@ -294,25 +290,7 @@ fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *No else try evaluateAttrValue(allocator, final_val, data); - // Collect class attributes for merging - if (std.mem.eql(u8, attr.name, "class")) { - switch (attr_val) { - .string => |s| if (s.len > 0) try class_parts.append(allocator, s), - else => {}, - } - } else { - try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse); - } - } - - // Output merged class attribute - if (class_parts.items.len > 0) { - try output.appendSlice(allocator, " class=\""); - for (class_parts.items, 0..) |part, i| { - if (i > 0) try output.append(allocator, ' '); - try output.appendSlice(allocator, part); - } - try output.append(allocator, '"'); + try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse); } // Self-closing logic differs by mode: @@ -683,10 +661,6 @@ fn renderTagWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), try output.appendSlice(allocator, "<"); try output.appendSlice(allocator, name); - // Collect class values separately to merge them into one attribute - var class_parts = std.ArrayListUnmanaged([]const u8){}; - defer class_parts.deinit(allocator); - // Render attributes directly to output buffer (avoids intermediate allocations) for (tag.attrs.items) |attr| { // Static/quoted values (e.g., from .class shorthand) should not be looked up in data @@ -695,25 +669,7 @@ fn renderTagWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), else try evaluateAttrValue(allocator, attr.val, data); - // Collect class attributes for merging - if (std.mem.eql(u8, attr.name, "class")) { - switch (attr_val) { - .string => |s| if (s.len > 0) try class_parts.append(allocator, s), - else => {}, - } - } else { - try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse); - } - } - - // Output merged class attribute - if (class_parts.items.len > 0) { - try output.appendSlice(allocator, " class=\""); - for (class_parts.items, 0..) |part, i| { - if (i > 0) try output.append(allocator, ' '); - try output.appendSlice(allocator, part); - } - try output.append(allocator, '"'); + try runtime.appendAttr(allocator, output, attr.name, attr_val, true, ctx.terse); } const is_void = isSelfClosing(name); diff --git a/src/tpl_compiler/zig_codegen.zig b/src/tpl_compiler/zig_codegen.zig index 67c6f68..d2ab183 100644 --- a/src/tpl_compiler/zig_codegen.zig +++ b/src/tpl_compiler/zig_codegen.zig @@ -224,69 +224,28 @@ pub const Codegen = struct { if (!has_dynamic_attrs) { // All static attributes - include in buffer - // Collect class values to merge into single attribute - var class_values = std.ArrayListUnmanaged([]const u8){}; - defer class_values.deinit(self.allocator); - for (tag.attrs.items) |attr| { - if (std.mem.eql(u8, attr.name, "class")) { - // Collect class values for merging - if (attr.val) |val| { - if (val.len > 0) { - try class_values.append(self.allocator, val); - } - } + try self.addStatic(" "); + try self.addStatic(attr.name); + if (attr.val) |val| { + try self.addStatic("=\""); + try self.addStatic(val); + try self.addStatic("\""); } else { - try self.addStatic(" "); - try self.addStatic(attr.name); - if (attr.val) |val| { + // Boolean attribute + if (!self.terse) { try self.addStatic("=\""); - try self.addStatic(val); + try self.addStatic(attr.name); try self.addStatic("\""); - } else { - // Boolean attribute - if (!self.terse) { - try self.addStatic("=\""); - try self.addStatic(attr.name); - try self.addStatic("\""); - } } } } - - // Output merged class attribute - if (class_values.items.len > 0) { - try self.addStatic(" class=\""); - for (class_values.items, 0..) |class_val, i| { - if (i > 0) { - try self.addStatic(" "); - } - try self.addStatic(class_val); - } - try self.addStatic("\""); - } - try self.addStatic(">"); } else { // Flush static content before dynamic attributes (this closes any open string) try self.flushStaticBuffer(); - // Collect static class values for merging - var static_class_values = std.ArrayListUnmanaged([]const u8){}; - defer static_class_values.deinit(self.allocator); - - // First pass: output non-class attributes for (tag.attrs.items) |attr| { - // Skip class attributes - handle them separately - if (std.mem.eql(u8, attr.name, "class")) { - if (attr.val) |val| { - if (val.len > 0) { - try static_class_values.append(self.allocator, val); - } - } - continue; - } - if (attr.val) |val| { // Quoted values are always static, unquoted can be field references if (!attr.quoted and self.isDataFieldReference(val)) { @@ -344,19 +303,6 @@ pub const Codegen = struct { } } - // Output merged class attribute - if (static_class_values.items.len > 0) { - try self.writeIndent(); - try self.write("try buf.appendSlice(allocator, \" class=\\\""); - for (static_class_values.items, 0..) |class_val, i| { - if (i > 0) { - try self.write(" "); - } - try self.writeEscaped(class_val); - } - try self.writeLine("\\\"\");"); - } - try self.writeIndent(); try self.writeLine("try buf.appendSlice(allocator, \">\");"); }